From c4e9df53487b7ff162ca507af5e648ce26c79ed6 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 6 Oct 2026 16:54:44 -0500 Subject: [PATCH] More demo work. Forge split into it's own project. --- CHANGELOG | 259 +- CMakeLists.txt | 32 +- build-docs.sh | 24 +- cmake/Superbuild.cmake | 34 - demos/README.md | 24 +- demos/art/dragon.svg | 79 + demos/art/kangarooPunch.svg | 221 + demos/art/kangarooPunchLogo.png | 3 - demos/art/singeLogo.png | 3 - demos/art/singeText.svg | 8 + demos/insertCoin/art/bossArm.png | 4 +- demos/insertCoin/art/bossCore.png | 4 +- demos/insertCoin/art/bullet.png | 4 +- demos/insertCoin/art/scanlines.png | 4 +- demos/insertCoin/art/target.png | 4 +- demos/insertCoin/art/turret.png | 4 +- demos/insertCoin/games.dat | 8 +- demos/insertCoin/insertCoin.singe | 166 +- demos/kit.lua | 350 +- demos/mirrorball/art/bob.png | 4 +- demos/mirrorball/art/logo.png | 4 +- demos/mirrorball/art/singe.png | 4 +- demos/mirrorball/games.dat | 8 +- demos/mirrorball/mirrorball.singe | 54 +- demos/neonCoast/art/bob.png | 4 +- demos/neonCoast/art/car.png | 4 +- demos/neonCoast/art/lamp.png | 4 +- demos/neonCoast/art/logo.png | 4 +- demos/neonCoast/art/palm.png | 4 +- demos/neonCoast/art/singe.png | 4 +- demos/neonCoast/art/traffic.png | 4 +- demos/neonCoast/games.dat | 8 +- demos/neonCoast/neonCoast.singe | 103 +- docs/Forge.adoc | 1928 -- docs/ForgeVocabulary.adoc | 721 - docs/Learn.adoc | 10 +- docs/Manual.adoc | 10 +- docs/Reference.adoc | 8 +- docs/images/tutorials/01-chooser.png | 3 - docs/images/tutorials/01-entities.png | 3 - docs/images/tutorials/01-moved.png | 3 - docs/images/tutorials/01-playing.png | 3 - docs/images/tutorials/01-recoloured.png | 3 - docs/images/tutorials/01-rules.png | 3 - docs/images/tutorials/01-saved.png | 3 - docs/images/tutorials/01-types.png | 3 - docs/images/tutorials/02-cave.png | 3 - docs/images/tutorials/02-coinType.png | 3 - docs/images/tutorials/02-coins.png | 3 - docs/images/tutorials/02-placed.png | 3 - docs/images/tutorials/02-playing.png | 3 - docs/images/tutorials/02-rule.png | 3 - docs/images/tutorials/02-rules.png | 3 - docs/images/tutorials/03-coinSpin.png | 3 - docs/images/tutorials/03-heroType.png | 3 - docs/images/tutorials/03-jumpRule.png | 3 - docs/images/tutorials/03-music.png | 3 - docs/images/tutorials/03-playing.png | 3 - docs/images/tutorials/04-cleared.png | 3 - docs/images/tutorials/04-hitRule.png | 3 - docs/images/tutorials/04-placed.png | 3 - docs/images/tutorials/04-playing.png | 3 - docs/images/tutorials/04-rules.png | 3 - docs/images/tutorials/04-ship.png | 3 - docs/images/tutorials/04-types.png | 3 - docs/images/tutorials/05-disc.png | 3 - docs/images/tutorials/05-playing.png | 3 - docs/images/tutorials/05-prompts.png | 3 - docs/images/tutorials/05-rules.png | 3 - docs/images/tutorials/05-window.png | 3 - docs/images/tutorials/06-firstKey.png | 3 - docs/images/tutorials/06-playing.png | 3 - docs/images/tutorials/06-rules.png | 3 - docs/images/tutorials/06-tracks.png | 3 - docs/images/tutorials/06-types.png | 3 - docs/images/tutorials/07-dialogue.png | 3 - docs/images/tutorials/07-hall.png | 3 - docs/images/tutorials/07-hotspots.png | 3 - docs/images/tutorials/07-playing.png | 3 - docs/images/tutorials/07-verbRules.png | 3 - docs/images/tutorials/07-walkArea.png | 3 - docs/images/tutorials/08-hero.png | 3 - docs/images/tutorials/08-meshes.png | 3 - docs/images/tutorials/08-playing.png | 3 - docs/images/tutorials/08-rules.png | 3 - docs/images/tutorials/08-scene.png | 3 - docs/images/tutorials/08-viewport.png | 3 - docs/images/tutorials/09-hitRule.png | 3 - docs/images/tutorials/09-placed.png | 3 - docs/images/tutorials/09-playing.png | 3 - docs/images/tutorials/09-rail.png | 3 - docs/images/tutorials/09-room.png | 3 - docs/images/tutorials/09-rules.png | 3 - docs/images/tutorials/09-zombie.png | 3 - docs/images/tutorials/10-arcadeRules.png | 3 - docs/images/tutorials/10-bezel.png | 3 - docs/images/tutorials/10-playing.png | 3 - docs/images/tutorials/10-released.png | 3 - docs/lessons/31-cabinet.adoc | 5 +- forge/Author.singe | 14807 ---------------- forge/AuthorCompile.singe | 896 - forge/Forge.rml | 63 - forge/Forge.singe | 5248 ------ forge/games.dat | 26 - forge/kit/LICENSE | 8 - forge/kit/README.txt | 11 - forge/kit/click.wav | 3 - forge/kit/clip.mkv | 3 - forge/kit/coin.png | 3 - forge/kit/coin.wav | 3 - forge/kit/crate.png | 3 - forge/kit/door.png | 3 - forge/kit/door.wav | 3 - forge/kit/enemy.png | 3 - forge/kit/fox.glb | 3 - forge/kit/hall.png | 3 - forge/kit/hero.png | 3 - forge/kit/hit.wav | 3 - forge/kit/jump.wav | 3 - forge/kit/key.png | 3 - forge/kit/loop.ogg | 3 - forge/kit/rock.png | 3 - forge/kit/ship.png | 3 - forge/kit/shot.png | 3 - forge/kit/shot.wav | 3 - forge/kit/spike.png | 3 - forge/kit/target.png | 3 - forge/kit/tiles.png | 3 - forge/kit/yard.png | 3 - forge/tutorials/01-tenMinutes.forge | 76 - forge/tutorials/02-platformer.forge | 144 - forge/tutorials/03-sprites.forge | 156 - forge/tutorials/04-shmup.forge | 135 - forge/tutorials/05-qte.forge | 140 - forge/tutorials/06-gun.forge | 115 - forge/tutorials/07-adventure.forge | 244 - forge/tutorials/08-scene.forge | 129 - forge/tutorials/09-rail.forge | 213 - forge/tutorials/10-release.forge | 193 - ...angaroo Punch Logo with Text on Black.svgz | Bin 0 -> 189708 bytes src/main.c | 4 +- src/singe.c | 141 +- testScripts/README.md | 2 + testScripts/author/adventure2d.forge | 140 - testScripts/author/adventure3d.forge | 53 - testScripts/author/bowling.forge | 55 - testScripts/author/breakout.forge | 55 - testScripts/author/fmv.forge | 74 - testScripts/author/fps.forge | 81 - testScripts/author/gunvideo.forge | 61 - testScripts/author/hud.rml | 17 - testScripts/author/mash.forge | 34 - testScripts/author/maze.forge | 48 - testScripts/author/overlayBits.forge | 47 - testScripts/author/painted.forge | 43 - testScripts/author/picture.forge | 37 - testScripts/author/platformer.forge | 62 - testScripts/author/platformer3d.forge | 65 - testScripts/author/pool.forge | 42 - testScripts/author/qte.forge | 47 - testScripts/author/racing.forge | 66 - testScripts/author/railshooter.forge | 120 - testScripts/author/rhythm.forge | 50 - testScripts/author/shmup.forge | 77 - testScripts/author/space.forge | 48 - testScripts/author/tail.forge | 73 - testScripts/author/thirdperson.forge | 78 - testScripts/author/towerdefence.forge | 70 - testScripts/author/vocab.forge | 32 - testScripts/author/vocab.singe | 47 - testScripts/author/waves.forge | 37 - testScripts/games.dat | 21 + 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 | 234 - testScripts/ports/karisDrive.singe | 776 - testScripts/ports/karisOriginal.singe | 107 - testScripts/ports/karisPort.singe | 106 - testScripts/ports/launch.sh | 49 - testScripts/ports/ledger.md | 242 - testScripts/ports/shotDiff.py | 57 - testScripts/ports/shots.sh | 22 - testScripts/ports/standalone.txt | 22 - testScripts/ports/sweep.sh | 101 - 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/scene100.singe | 89 - testScripts/scene101.singe | 132 + testScripts/scene102.singe | 153 + testScripts/scene52.singe | 102 - testScripts/scene53.singe | 51 - testScripts/scene54.singe | 168 - testScripts/scene55.singe | 178 - testScripts/scene56.singe | 116 - testScripts/scene57.singe | 244 - testScripts/scene58.singe | 71 - testScripts/scene59.singe | 93 - testScripts/scene60.singe | 148 - testScripts/scene61.singe | 105 - testScripts/scene62.singe | 64 - testScripts/scene63.singe | 139 - testScripts/scene64.singe | 145 - testScripts/scene65.singe | 147 - testScripts/scene66.singe | 55 - testScripts/scene67.singe | 64 - testScripts/scene68.singe | 75 - testScripts/scene69.singe | 179 - testScripts/scene70.singe | 82 - testScripts/scene71.singe | 49 - testScripts/scene72.singe | 96 - testScripts/scene73.singe | 53 - testScripts/scene74.singe | 51 - testScripts/scene75.singe | 79 - testScripts/scene76.singe | 49 - testScripts/scene77.singe | 52 - testScripts/scene78.singe | 43 - testScripts/scene79.singe | 51 - testScripts/scene80.singe | 131 - testScripts/scene81.singe | 73 - testScripts/scene82.singe | 134 - testScripts/scene83.singe | 99 - testScripts/scene84.singe | 186 - testScripts/scene85.singe | 130 - testScripts/scene86.singe | 232 - testScripts/scene87.singe | 208 - testScripts/scene88.singe | 254 - testScripts/scene89.singe | 353 - testScripts/scene90.singe | 246 - testScripts/scene91.singe | 308 - testScripts/scene92.singe | 175 - testScripts/scene93.singe | 51 - testScripts/scene94.singe | 54 - testScripts/scene95.singe | 56 - testScripts/scene96.singe | 62 - testScripts/scene97.singe | 85 - testScripts/scene98.singe | 56 - testScripts/tutorialDrive.singe | 186 - util/demoCapture.sh | 5 +- util/demoLogos.sh | 38 +- util/demoRecord.sh | 5 +- util/forgeCredits.lua | 191 - util/forgeKit.py | 371 - util/forgeLedger.py | 182 - util/forgePortActionMax.py | 294 - util/forgePortKaris.lua | 1177 -- util/forgePorts.py | 321 - util/forgeShots.py | 38 - util/forgeStandalone.py | 156 - util/forgeVocabulary.lua | 71 - util/insertCoinArt.py | 44 +- util/mirrorballArt.py | 11 +- util/neonCoastArt.py | 15 +- util/neonCoastCar.sh | 13 +- util/neonCoastCar.singe | 4 +- 263 files changed, 1435 insertions(+), 40244 deletions(-) create mode 100644 demos/art/dragon.svg create mode 100644 demos/art/kangarooPunch.svg delete mode 100644 demos/art/kangarooPunchLogo.png delete mode 100644 demos/art/singeLogo.png create mode 100644 demos/art/singeText.svg delete mode 100644 docs/Forge.adoc delete mode 100644 docs/ForgeVocabulary.adoc delete mode 100644 docs/images/tutorials/01-chooser.png delete mode 100644 docs/images/tutorials/01-entities.png delete mode 100644 docs/images/tutorials/01-moved.png delete mode 100644 docs/images/tutorials/01-playing.png delete mode 100644 docs/images/tutorials/01-recoloured.png delete mode 100644 docs/images/tutorials/01-rules.png delete mode 100644 docs/images/tutorials/01-saved.png delete mode 100644 docs/images/tutorials/01-types.png delete mode 100644 docs/images/tutorials/02-cave.png delete mode 100644 docs/images/tutorials/02-coinType.png delete mode 100644 docs/images/tutorials/02-coins.png delete mode 100644 docs/images/tutorials/02-placed.png delete mode 100644 docs/images/tutorials/02-playing.png delete mode 100644 docs/images/tutorials/02-rule.png delete mode 100644 docs/images/tutorials/02-rules.png delete mode 100644 docs/images/tutorials/03-coinSpin.png delete mode 100644 docs/images/tutorials/03-heroType.png delete mode 100644 docs/images/tutorials/03-jumpRule.png delete mode 100644 docs/images/tutorials/03-music.png delete mode 100644 docs/images/tutorials/03-playing.png delete mode 100644 docs/images/tutorials/04-cleared.png delete mode 100644 docs/images/tutorials/04-hitRule.png delete mode 100644 docs/images/tutorials/04-placed.png delete mode 100644 docs/images/tutorials/04-playing.png delete mode 100644 docs/images/tutorials/04-rules.png delete mode 100644 docs/images/tutorials/04-ship.png delete mode 100644 docs/images/tutorials/04-types.png delete mode 100644 docs/images/tutorials/05-disc.png delete mode 100644 docs/images/tutorials/05-playing.png delete mode 100644 docs/images/tutorials/05-prompts.png delete mode 100644 docs/images/tutorials/05-rules.png delete mode 100644 docs/images/tutorials/05-window.png delete mode 100644 docs/images/tutorials/06-firstKey.png delete mode 100644 docs/images/tutorials/06-playing.png delete mode 100644 docs/images/tutorials/06-rules.png delete mode 100644 docs/images/tutorials/06-tracks.png delete mode 100644 docs/images/tutorials/06-types.png delete mode 100644 docs/images/tutorials/07-dialogue.png delete mode 100644 docs/images/tutorials/07-hall.png delete mode 100644 docs/images/tutorials/07-hotspots.png delete mode 100644 docs/images/tutorials/07-playing.png delete mode 100644 docs/images/tutorials/07-verbRules.png delete mode 100644 docs/images/tutorials/07-walkArea.png delete mode 100644 docs/images/tutorials/08-hero.png delete mode 100644 docs/images/tutorials/08-meshes.png delete mode 100644 docs/images/tutorials/08-playing.png delete mode 100644 docs/images/tutorials/08-rules.png delete mode 100644 docs/images/tutorials/08-scene.png delete mode 100644 docs/images/tutorials/08-viewport.png delete mode 100644 docs/images/tutorials/09-hitRule.png delete mode 100644 docs/images/tutorials/09-placed.png delete mode 100644 docs/images/tutorials/09-playing.png delete mode 100644 docs/images/tutorials/09-rail.png delete mode 100644 docs/images/tutorials/09-room.png delete mode 100644 docs/images/tutorials/09-rules.png delete mode 100644 docs/images/tutorials/09-zombie.png delete mode 100644 docs/images/tutorials/10-arcadeRules.png delete mode 100644 docs/images/tutorials/10-bezel.png delete mode 100644 docs/images/tutorials/10-playing.png delete mode 100644 docs/images/tutorials/10-released.png delete mode 100644 forge/Author.singe delete mode 100644 forge/AuthorCompile.singe delete mode 100644 forge/Forge.rml delete mode 100644 forge/Forge.singe delete mode 100644 forge/games.dat delete mode 100644 forge/kit/LICENSE delete mode 100644 forge/kit/README.txt delete mode 100644 forge/kit/click.wav delete mode 100644 forge/kit/clip.mkv delete mode 100644 forge/kit/coin.png delete mode 100644 forge/kit/coin.wav delete mode 100644 forge/kit/crate.png delete mode 100644 forge/kit/door.png delete mode 100644 forge/kit/door.wav delete mode 100644 forge/kit/enemy.png delete mode 100644 forge/kit/fox.glb delete mode 100644 forge/kit/hall.png delete mode 100644 forge/kit/hero.png delete mode 100644 forge/kit/hit.wav delete mode 100644 forge/kit/jump.wav delete mode 100644 forge/kit/key.png delete mode 100644 forge/kit/loop.ogg delete mode 100644 forge/kit/rock.png delete mode 100644 forge/kit/ship.png delete mode 100644 forge/kit/shot.png delete mode 100644 forge/kit/shot.wav delete mode 100644 forge/kit/spike.png delete mode 100644 forge/kit/target.png delete mode 100644 forge/kit/tiles.png delete mode 100644 forge/kit/yard.png delete mode 100644 forge/tutorials/01-tenMinutes.forge delete mode 100644 forge/tutorials/02-platformer.forge delete mode 100644 forge/tutorials/03-sprites.forge delete mode 100644 forge/tutorials/04-shmup.forge delete mode 100644 forge/tutorials/05-qte.forge delete mode 100644 forge/tutorials/06-gun.forge delete mode 100644 forge/tutorials/07-adventure.forge delete mode 100644 forge/tutorials/08-scene.forge delete mode 100644 forge/tutorials/09-rail.forge delete mode 100644 forge/tutorials/10-release.forge create mode 100644 media/Kangaroo Punch Logo with Text on Black.svgz delete mode 100644 testScripts/author/adventure2d.forge delete mode 100644 testScripts/author/adventure3d.forge delete mode 100644 testScripts/author/bowling.forge delete mode 100644 testScripts/author/breakout.forge delete mode 100644 testScripts/author/fmv.forge delete mode 100644 testScripts/author/fps.forge delete mode 100644 testScripts/author/gunvideo.forge delete mode 100644 testScripts/author/hud.rml delete mode 100644 testScripts/author/mash.forge delete mode 100644 testScripts/author/maze.forge delete mode 100644 testScripts/author/overlayBits.forge delete mode 100644 testScripts/author/painted.forge delete mode 100644 testScripts/author/picture.forge delete mode 100644 testScripts/author/platformer.forge delete mode 100644 testScripts/author/platformer3d.forge delete mode 100644 testScripts/author/pool.forge delete mode 100644 testScripts/author/qte.forge delete mode 100644 testScripts/author/racing.forge delete mode 100644 testScripts/author/railshooter.forge delete mode 100644 testScripts/author/rhythm.forge delete mode 100644 testScripts/author/shmup.forge delete mode 100644 testScripts/author/space.forge delete mode 100644 testScripts/author/tail.forge delete mode 100644 testScripts/author/thirdperson.forge delete mode 100644 testScripts/author/towerdefence.forge delete mode 100644 testScripts/author/vocab.forge delete mode 100644 testScripts/author/vocab.singe delete mode 100644 testScripts/author/waves.forge delete mode 100644 testScripts/ports/README delete mode 100644 testScripts/ports/actionMaxInputs.singe delete mode 100644 testScripts/ports/actionMaxOriginal.singe delete mode 100644 testScripts/ports/actionMaxPort.singe delete mode 100644 testScripts/ports/algDrive.singe delete mode 100644 testScripts/ports/algOriginal.singe delete mode 100644 testScripts/ports/algPort.singe delete mode 100755 testScripts/ports/compare.sh delete mode 100644 testScripts/ports/karisDrive.singe delete mode 100644 testScripts/ports/karisOriginal.singe delete mode 100644 testScripts/ports/karisPort.singe delete mode 100755 testScripts/ports/launch.sh delete mode 100644 testScripts/ports/ledger.md delete mode 100755 testScripts/ports/shotDiff.py delete mode 100755 testScripts/ports/shots.sh delete mode 100644 testScripts/ports/standalone.txt delete mode 100755 testScripts/ports/sweep.sh delete mode 100644 testScripts/ports/timetravelerDrive.singe delete mode 100644 testScripts/ports/timetravelerOriginal.singe delete mode 100644 testScripts/ports/timetravelerPort.singe delete mode 100644 testScripts/ports/videolandDrive.singe delete mode 100644 testScripts/ports/videolandOriginal.singe delete mode 100644 testScripts/ports/videolandPort.singe delete mode 100644 testScripts/scene100.singe create mode 100644 testScripts/scene101.singe create mode 100644 testScripts/scene102.singe delete mode 100644 testScripts/scene52.singe delete mode 100644 testScripts/scene53.singe delete mode 100644 testScripts/scene54.singe delete mode 100644 testScripts/scene55.singe delete mode 100644 testScripts/scene56.singe delete mode 100644 testScripts/scene57.singe delete mode 100644 testScripts/scene58.singe delete mode 100644 testScripts/scene59.singe delete mode 100644 testScripts/scene60.singe delete mode 100644 testScripts/scene61.singe delete mode 100644 testScripts/scene62.singe delete mode 100644 testScripts/scene63.singe delete mode 100644 testScripts/scene64.singe delete mode 100644 testScripts/scene65.singe delete mode 100644 testScripts/scene66.singe delete mode 100644 testScripts/scene67.singe delete mode 100644 testScripts/scene68.singe delete mode 100644 testScripts/scene69.singe delete mode 100644 testScripts/scene70.singe delete mode 100644 testScripts/scene71.singe delete mode 100644 testScripts/scene72.singe delete mode 100644 testScripts/scene73.singe delete mode 100644 testScripts/scene74.singe delete mode 100644 testScripts/scene75.singe delete mode 100644 testScripts/scene76.singe delete mode 100644 testScripts/scene77.singe delete mode 100644 testScripts/scene78.singe delete mode 100644 testScripts/scene79.singe delete mode 100644 testScripts/scene80.singe delete mode 100644 testScripts/scene81.singe delete mode 100644 testScripts/scene82.singe delete mode 100644 testScripts/scene83.singe delete mode 100644 testScripts/scene84.singe delete mode 100644 testScripts/scene85.singe delete mode 100644 testScripts/scene86.singe delete mode 100644 testScripts/scene87.singe delete mode 100644 testScripts/scene88.singe delete mode 100644 testScripts/scene89.singe delete mode 100644 testScripts/scene90.singe delete mode 100644 testScripts/scene91.singe delete mode 100644 testScripts/scene92.singe delete mode 100644 testScripts/scene93.singe delete mode 100644 testScripts/scene94.singe delete mode 100644 testScripts/scene95.singe delete mode 100644 testScripts/scene96.singe delete mode 100644 testScripts/scene97.singe delete mode 100644 testScripts/scene98.singe delete mode 100644 testScripts/tutorialDrive.singe delete mode 100644 util/forgeCredits.lua delete mode 100644 util/forgeKit.py delete mode 100644 util/forgeLedger.py delete mode 100644 util/forgePortActionMax.py delete mode 100644 util/forgePortKaris.lua delete mode 100644 util/forgePorts.py delete mode 100644 util/forgeShots.py delete mode 100644 util/forgeStandalone.py delete mode 100644 util/forgeVocabulary.lua diff --git a/CHANGELOG b/CHANGELOG index 398649987..20ed965d6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -119,20 +119,11 @@ API Changes offered v3.00 the day it appears, and the minor is compared as a number, so 3.09 precedes 3.10. The launcher ranks the binaries beside it the same way. -- A Forge game says who made it. The game's form has credits fields -- - developer, publisher, year, platform, genre, creator, source, and - description -- and a release writes them into its games.dat, where the menu - shows them. Notes go into a CREDITS.txt beside the game, and licence - files named in the credits travel with it. A port of an existing game gets - its original author's credits filled in from the original's own games.dat - and script header. - -- A game can be sent to the master service for review from Forge. Shift+P - in the editor exports, packs, and uploads it; an upload is not in the catalogue - until it has been reviewed, and Forge shows where each version of yours - stands. Behind it: singePack(directory, database) packs a game from - a script exactly as --pack does, and the network layer can send a request - body straight from a file, so a game is never held in memory whole. +- A game can be sent to the master service for review. An upload is not in + the catalogue until it has been reviewed. Behind it: singePack(directory, + database) packs a game from a script exactly as --pack does, and the + network layer can send a request body straight from a file, so a game is + never held in memory whole. - The menu knows which of its games are out of date, and updates them from the game's own page. A packed game now carries VERSION in its games.dat, so the @@ -143,9 +134,7 @@ API Changes the same checked download the Get Games tool uses. Nothing about this machine is kept anywhere but the question: the menu says which games and versions it holds and is told which are newer, because a record of what a - cabinet has installed could never have stayed true. Forge gives every - description a permanent GAME_ID the first time it is saved and writes it - into the games.dat it exports, so a Forge game can be published and updated. + cabinet has installed could never have stayed true. - overlayGetFontWidth and overlayGetFontHeight report the size in pixels of one character cell of the built-in console font. overlayPrint counts in cells @@ -1187,6 +1176,19 @@ New Features mixer's buffers. Animating to the music no longer means counting ticks from musicPlay and hoping they keep pace. +- Stretched, tinted, and faded sprites draw several times faster. A sprite + drawn larger or smaller than itself, or with spriteSetTint or + spriteSetAlpha, used to go pixel by pixel through a slow general path: a + sprite stretched over a whole 1920 by 1080 overlay took 8 ms, half a + frame. It now takes about 1 ms, and a coloured box the size of the screen + half that, so games with a large overlay keep their frame rate. What is + drawn is the same, give or take one step of rounding in a tinted colour. + +- Run headless with SDL_VIDEODRIVER=offscreen, Singe keeps the window the + size it was given. That driver reports a 1024 by 768 screen, so a larger + window used to be shrunk to fit it, and a recording made that way came out + smaller than the game. + - 3D in front of the overlay. nodeSetLayer(node, LAYER_FRONT) moves a node, and everything parented to it, out of the scene beneath the overlay and into a front layer drawn after it, by the same camera with the same lights, @@ -1307,18 +1309,6 @@ New Features - The menu no longer needs its background video as the disc; Menu.sh passes its own arguments on to Singe. -- Forge can judge a shot by the picture rather than by a hitbox. lightSensor - is a behaviour of the framework now, the way a home laserdisc gun works: a - spot on the picture flashes on the frames a target is shown, and a shot is - good when the picture under the gun changes the way the spot does. It began - as five games' own vocabulary and was general all along, so it moved in, and - those five name no vocabulary of their own any more. pictureAt came with it, - a condition any rule can ask -- is the picture at this point bright, dark, or - neither -- for a flash hidden in a game's own video, a colour cue, or a lamp - in a scene; a game with no disc reads dark everywhere. Both are in the - book's vocabulary tables, which are generated from the manifest, and scene - 100 covers them. - - The launcher runs the best Singe it finds, and the support folder can be rewritten on demand. Menu.sh and Menu.bat read each Singe* name beside them and rank it by this machine's architecture first and the version second. @@ -1338,9 +1328,9 @@ New Features writes a fresh one, so a file an older release shipped and this one does not cannot linger. A second --refresh keeps its own as old-Singe--1. Nothing is deleted anywhere in this: what is set aside is the owner's to - remove. For the same reason the menu's game update and Forge's publish - write a new file beside the old one and only release the old copy once the - new one is in place, so an interrupted write cannot lose either. + remove. For the same reason the menu's game update writes a new file + beside the old one and only releases the old copy once the new one is in + place, so an interrupted write cannot lose either. - --patch gives back the space a replaced file was using. SQLite keeps the pages a deleted blob held for the next thing written, so a developer who @@ -1352,124 +1342,6 @@ New Features pack. Under a megabyte it is left alone, since compacting rewrites the whole file. -- Forge has tutorials: ten sittings at the front of Forge.pdf, each - ending with a game that plays, with the kit of pictures, sounds, a clip, - and a model they use packed in Forge.game, and the finished description - of each in the chooser. Each tutorial's scene presses the keys the text - names and checks the result, so the book cannot drift from the editor. - On the way the editor gained the game's own form (title, players, vars, - verbs, layers, and each layer's parameters), the digits as panel keys, - DELETE to empty a field, X to release a game with every file it names, - a file picker that keeps an unset field unset, and a panel that steps - aside while a polygon is drawn. What an entity is made from is now a - type, not a kind: types = { hero = ... } in a description, type = "hero" - on an entity and in an event's filter, and the Types panel. Forge's - launcher takes every key (MODE_FULL), so ENTER and TAB reach it, and - its panel's rows and fields take clicks. A value is edited in place: - it starts selected, so typing replaces it, and the arrows put a caret - into it. A field whose values are a fixed set opens a dropdown of them - -- true and false, the scancodes, the switches, the looks, the events, - the words a parameter takes, the game's own types, rooms, entities, - states, tracks, and nodes -- opened on the value it has, under its own - row, upward when there is no room below. The panel's list and fields - box scroll, with bars, and keep the chosen row in view. The game's - name at the top of the panel, or a press on empty canvas, brings the - game's own form back. A waves track (W in the tracks) spawns a type - 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. Ports of the test library began (FORGE.md section 14): the - five light gun games of the VHS era are Forge descriptions written by - a porting script, 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. Their light sensor lives in one now, - beside those five games, and not in Forge. The Hypseus library's - eleven Karis Framework 3.32b titles 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 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. - -- A game can be described rather than written. Forge/Author.singe and - Forge/AuthorCompile.singe take a table of layers, entities, behaviours - and rules and compile it into an ordinary Singe game: the rules become - real Lua, so nothing walks a table every frame and the result can be - read and edited by hand. There is no notion of genre in it -- a game - declares which of the engine's layers it uses, and that is the whole - difference between a platformer and a quick-time event over video. The - vocabulary of conditions and actions is declared in a table rather than - built into the compiler, so new kinds of game are entries rather than - releases, and the "lua" action is the deliberate way out when a rule - needs something the vocabulary cannot say. See the manual. - -- Forge runs as a game rather than only as a library: started from the - menu it opens on a chooser (its own descriptions, a new game from a - starter, a copy of anything dropped into its directory), the keys, the - mouse and the pad reach the editor, P saves, builds and plays what is on - screen and comes back to it, and ESC closes or leaves. Entities can be - added, duplicated, deleted and typed field by field -- position, look, - behaviours and their parameters, all from the manifest -- and renaming - one renames it in every rule. Conditions and actions are picked from - the vocabulary with their help beside them, rules reorder, a rule's - note is editable, and U undoes forty steps. The compiler loads the - runtime from Forge/, where it lives, so it no longer fails looking for - a Singe/Author.singe that never shipped. - -- An editor for those descriptions, Forge, which is itself - a Singe game: the canvas is the same overlay at the same coordinates the - game will be played in, so what is placed is what is seen. Entity list - and details are an RmlUi document, the canvas beside them is drawn into - the overlay and picked the way a light gun game picks a target, and the - two compose because a button is offered to the GUI first while pointer - motion is never consumed. A description survives a round trip through - it: load, save, load again, and it compiles to the same game. Forge is - distributed on its own and no part of it ships inside Singe -- not the - editor, not the compiler, not the runtime. A game Forge builds carries - its own copy of that runtime, so it runs on a machine that has never had - Forge on it and cannot change behaviour because the engine moved on. - -- A game released from Forge is standalone. forgeExport writes the - compiled script, a games.dat, the description it came from and a copy of - the runtime into a directory of its own, taken out of Forge. --pack turns - that directory into a .game. The game locates its own directory with - debug.getinfo rather than trusting DIR, which names the directory of the - script the engine was launched with -- not this one when a game is reached - by dofile. - -- The editor edits rules, not only entity positions: Tab swaps the panel - between the entities and the event sheet, the selected rule opens in - place with its conditions and actions under it, and conditions and - actions are added from the same manifest the compiler reads, so the rule - editor needs no change when the vocabulary grows. It is driven by keys - as well as the pointer, which is how the bundled menu has always worked - and what a cabinet needs. ENTER types a value into the selected - condition or action and moves to its next one, ESC puts it back; a - number typed in comes back a number. - - The menu has an audio delay calibration screen for the machine's own delay (DAC, receiver, Bluetooth, display). Press the service key on the game list; a click plays once a second and the screen flashes. @@ -1582,22 +1454,22 @@ New Features making games -- the programming model, packing, games.dat, the GUI, 3D, physics, particles, the formats, and every function in the Lua API -- is the new Singe Reference, docs/Reference.adoc. The two link into each - other where they always did. ./build-docs.sh builds both, the Forge book, - and the beginner's course, and packs the three books that do not ship in - the binary, with the lesson files and the licence, into + other where they always did. ./build-docs.sh builds both and the + beginner's course, and packs the two books that do not ship in the binary, + with the lesson files and the licence, into Singe-Docs-v.zip, an optional download beside the engine. The HTML pages carry their pictures inside them, so each is one file wherever it is copied. -- ./build-docs.sh preview builds the four books with PREVIEW laid diagonally +- ./build-docs.sh preview builds the three books with PREVIEW laid diagonally across every page, PDF and HTML alike, for proofreaders, and names the documentation download -preview so it cannot pass for a release. The plain build, and the manual the binary carries, never wear it. -- The Singe logo is on the cover of all four books. +- The Singe logo is on the cover of all three books. - A third book: "Learn to Program with Singe", docs/Learn.adoc, built by - ./build-docs.sh beside the manual and the Forge book. Thirty lessons that + ./build-docs.sh beside the manual and the Reference. Thirty lessons that take somebody who has never written a line of code from one word on a black screen to a finished, packaged, cabinet-ready game. Part one teaches programming with no artwork and no video at all; part two adds sprites, @@ -1678,81 +1550,6 @@ New Features are rendered into .builddir/ by the build and by build-docs.sh alike, and the binary embeds the PDF from there. -- Forge's description format is new (FORGE.md section 4): kinds with - instances, rooms that remember themselves, rules triggered by events - (collisions between kinds, hits, deaths, timers, disc frames) as well as - every frame, vars and states on every instance, expressions where a - number goes, any-of groups, timers, sound, a HUD document bound to vars, - actions that take time run as coroutines, and hitbox tracks keyed to - disc frames or time. A shoot-em-up and a light gun game over video - prove it (testScripts/author, scenes 58 and 59). The editor gained - kinds, rooms, event pickers, and a timeline that scrubs the disc's video - under the cursor (scene 60). Nothing of the first slice's format loads; - it was never released. - -- Forge builds 3D games (FORGE.md section 5): a scene3d layer, model, - mesh, light, billboard, and text looks, the character controller moving - relative to a camera that is fixed, follows, orbits, sits in first - person, or rides a rail of points with stops, enemies that seek, an - animator playing a clip per state, targets with zones on named bones so - a gun's ray says which part it hit, ragdolls on death, bodies and - triggers. A rail shooter in the manner of House of the Dead and a 3D - platformer prove it (scenes 61 and 62), and the editor shows a 3D room - as the scene itself under an orbiting camera (scene 63). - -- Forge builds adventure games (FORGE.md section 6): rooms that remember - themselves, walk areas baked to a navigation mesh in a painted room or - a modelled one, hotspots, a verb line and a text parser with the most - specific rule winning, an inventory, dialogue trees, cut-scenes across - rooms with fades, depth sorting and scaling, and whole-game saves. The - same rules run a two-room adventure in 2D and in 3D (scenes 64 and 65). - -- Forge builds racing games and tower defences (FORGE.md section 7): - vehicles on the engine's vehicle physics with an opponent that drives a - track on its own, walkers that follow a target or patrol a lane, turrets - that fire at what is in range, instances placed by a click on the floor, - thrust, and hinge, ball, and slider joints (scenes 66 and 67). - -- Forge builds branching video games and carries the arcade plumbing - (FORGE.md section 8): windows on the disc that want a move, with the - disc sent to a success or a failure clip, the score bezel mirroring the - game's vars, coins, continues, a results card with the best score kept, - and scores sent to the master service's board (scene 68). - -- Forge's editor gained a search in every picker, a file picker for any - field that names a file, polygon drawing for walk areas and hotspot - outlines, a dialogue outline panel, and play-from-this-room (FORGE.md - section 9, scene 69). - -- Forge's long tail (FORGE.md section 10): sprite sheets stepped by - state, tile maps from a sheet, MIDI notes as events, water that bodies - float in, boats under their propellers, and soft bodies (scenes 70 and - 71). Every kind of game in FORGE.md's catalogue now has its vocabulary. - -- Forge's sprites turn (a var, a spin behaviour, or a projectile aiming - along its flight), a particles look streams from an instance, the - checker names misspelt fields, and 2D particle bursts are drawn at all - (they never had been). - -- Forge builds painted rooms in 3D with occluders, and its 3D viewport - gained axis gizmos with a turn handle; entities carry a rotation, and - a sprite faces left through a frame range of its own (FORGE.md section - 12, scenes 70, 79, and 80). - -- Forge's catalogue is written out (FORGE.md section 11): a first-person - shooter, breakout, a maze, third-person action, a space shooter, a - rhythm game, and bowling join the samples (scenes 72 to 78), and what - they found is fixed: a gun's ray passes through what is not a target, - events raised while the first room is built reach the rules, and a - rule on a key or a room can run once per instance of a kind. - -- Forge has its own manual, docs/Forge.adoc, built to Forge.html and - Forge.pdf beside the Singe manual; the engine's manual keeps a pointer. - The build packs Forge.game itself, with the manual and four samples - that need no art inside, and Forge's first run copies Forge.pdf out to - its data directory and says where. - R redoes what U undid, forty steps deep. - Fixes ----- diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e218a62a..a4a71bd59 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -174,7 +174,6 @@ endfunction() # assets/ holds only what is embedded in Singe. Sources those files are made from (the traced # artwork, OBJ and STL models, the sound samples) and anything else not embedded live in media/. -# Forge, which ships on its own, lives in forge/. # Images singeEmbedImage(font) @@ -230,9 +229,6 @@ singeEmbed(${CMAKE_SOURCE_DIR}/assets/settings.cfg ${GENERATED_DIR}/settings_cfg singeEmbed(${CMAKE_SOURCE_DIR}/assets/Menu.singe ${GENERATED_DIR}/Menu_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Tools.singe ${GENERATED_DIR}/Tools_singe.h "") -# Forge, the authoring tool, is built from forge but is NOT embedded: it is distributed on -# its own, not with the engine. Nothing of it -- not the editor, not the compiler, not the runtime -# a game it builds loads -- belongs inside Singe. singeEmbed(${CMAKE_SOURCE_DIR}/assets/Net.singe ${GENERATED_DIR}/Net_singe.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/cacerts.pem ${GENERATED_DIR}/cacerts_pem.h "") singeEmbed(${CMAKE_SOURCE_DIR}/assets/Master.singe ${GENERATED_DIR}/Master_singe.h "") @@ -321,34 +317,8 @@ singeEmbedLua(thirdparty/lume/lume.lua "") singeEmbedLua(thirdparty/inspect.lua/inspect.lua "") singeEmbedLua(thirdparty/bump.lua/bump.lua "") -# Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into -# Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge. -# Its vocabulary tables are generated from the manifest in Author.singe, so the manual cannot -# drift from it; the generated file is kept in the tree for a host without a Lua interpreter. -find_program(LUA_INTERPRETER NAMES lua5.4 lua) -if(LUA_INTERPRETER) - add_custom_command( - OUTPUT ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc - COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR} ${LUA_INTERPRETER} util/forgeVocabulary.lua > ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc - DEPENDS forge/Author.singe util/forgeVocabulary.lua - COMMENT "Writing the Forge vocabulary" - ) -endif() -# The book embeds the tutorials' pictures and includes their shipped descriptions, so any of -# those changing renders it again; the globs are re-run on every build. -file(GLOB_RECURSE FORGE_BOOK_PARTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/docs/images/tutorials/* ${CMAKE_SOURCE_DIR}/forge/tutorials/*.game) -add_custom_command( - OUTPUT ${MANUAL_DIR}/Forge.pdf - COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR} - COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${SINGE_VERSION_FULL} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf - DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc ${FORGE_BOOK_PARTS} - COMMENT "Rendering Forge.pdf" - VERBATIM -) -add_custom_target(forgeManual ALL DEPENDS ${MANUAL_DIR}/Forge.pdf) - # Optional HTML for browsing: cmake --build . --target docs. The user manual is the one book the -# binary carries; the reference, the Forge book, and the beginner's course are the optional +# binary carries; the reference and the beginner's course are the optional # documentation download that build-docs.sh packs. if(ASCIIDOCTOR) add_custom_target(docs diff --git a/build-docs.sh b/build-docs.sh index b47ac2b95..d6602cade 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Build the four books: the user manual (docs/Manual.adoc, which ships inside the binary), the -# programming reference (docs/Reference.adoc), the Forge manual (docs/Forge.adoc), and the -# beginner's course (docs/Learn.adoc). Produces .builddir/.html and .pdf for each, and then -# packs the three books that do not ship in the binary, with the lesson files and the licence, into +# Build the three books: the user manual (docs/Manual.adoc, which ships inside the binary), the +# programming reference (docs/Reference.adoc), and the beginner's course (docs/Learn.adoc). +# Produces .builddir/.html and .pdf for each, and then packs the two books that do not ship +# in the binary, with the lesson files and the licence, into # .builddir/Singe-Docs-v.zip, the optional documentation download. The version comes from # CMakeLists.txt. # @@ -24,17 +24,16 @@ elif [[ -n "${1:-}" ]]; then exit 2 fi src=$here/docs/Manual.adoc -forge=$here/docs/Forge.adoc learn=$here/docs/Learn.adoc ref=$here/docs/Reference.adoc -for doc in "$src" "$forge" "$learn" "$ref"; do +for doc in "$src" "$learn" "$ref"; do if [[ ! -f $doc ]]; then echo "error: $doc not found" >&2 exit 1 fi done -for tool in asciidoctor asciidoctor-pdf lua5.4 zip; do +for tool in asciidoctor asciidoctor-pdf zip; do if ! command -v "$tool" > /dev/null; then echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2 exit 1 @@ -62,12 +61,8 @@ fi out=$here/.builddir mkdir -p "$out" -# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it. -lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc" asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.html" asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$src" -o "$out/Manual.pdf" -asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.html" -asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$forge" -o "$out/Forge.pdf" asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.html" asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$learn" -o "$out/Learn.pdf" asciidoctor ${htmlOpts[@]+"${htmlOpts[@]}"} -a revnumber="$version" "$ref" -o "$out/Reference.html" @@ -78,7 +73,7 @@ asciidoctor-pdf ${pdfOpts[@]+"${pdfOpts[@]}"} -a revnumber="$version" "$ref" -o stage=$out/Singe-Docs-v$version$suffix rm -rf "$stage" "$stage.zip" mkdir -p "$stage/learn" -cp "$out/Reference.pdf" "$out/Reference.html" "$out/Forge.pdf" "$out/Forge.html" "$out/Learn.pdf" "$out/Learn.html" "$stage/" +cp "$out/Reference.pdf" "$out/Reference.html" "$out/Learn.pdf" "$out/Learn.html" "$stage/" cp -r "$here/docs/learn/." "$stage/learn/" cp "$here/COPYING" "$stage/" cat > "$stage/README.txt" < + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Singe dragon + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/art/kangarooPunch.svg b/demos/art/kangarooPunch.svg new file mode 100644 index 000000000..7f9066a0e --- /dev/null +++ b/demos/art/kangarooPunch.svg @@ -0,0 +1,221 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/art/kangarooPunchLogo.png b/demos/art/kangarooPunchLogo.png deleted file mode 100644 index fb28430ab..000000000 --- a/demos/art/kangarooPunchLogo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3cb2a66a7ba00a428b216280fd5a47bd634eb836723e7d13fcdfc84041f24014 -size 126885 diff --git a/demos/art/singeLogo.png b/demos/art/singeLogo.png deleted file mode 100644 index cd65460b9..000000000 --- a/demos/art/singeLogo.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:93e2c6636acbf639fc6095045115e448dfcd801807845b7702c9166711fc21db -size 300781 diff --git a/demos/art/singeText.svg b/demos/art/singeText.svg new file mode 100644 index 000000000..67d567547 --- /dev/null +++ b/demos/art/singeText.svg @@ -0,0 +1,8 @@ + + + + Singe + + + + diff --git a/demos/insertCoin/art/bossArm.png b/demos/insertCoin/art/bossArm.png index 9310f2ed1..b9f841952 100644 --- a/demos/insertCoin/art/bossArm.png +++ b/demos/insertCoin/art/bossArm.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49ffd18585bb818691eef6550d191f0695f6c3e8ad3cd60b8bb17585aac3092e -size 264 +oid sha256:07acde2c36546226e51f1d4d3df53b3b711169b7d1c8cd867d339e252e7c9afa +size 420 diff --git a/demos/insertCoin/art/bossCore.png b/demos/insertCoin/art/bossCore.png index 7119a40b4..9cae65ba8 100644 --- a/demos/insertCoin/art/bossCore.png +++ b/demos/insertCoin/art/bossCore.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d0512f98f4843abc0b515a5b2e32100c00ff15788ddcde45af1bb5b6897f477 -size 7881 +oid sha256:96f4d32cf82efab4a5d451de5a23fe82d270d79661f3fa999f75d53b78a57ae2 +size 25873 diff --git a/demos/insertCoin/art/bullet.png b/demos/insertCoin/art/bullet.png index 28f76f157..b89cdeda0 100644 --- a/demos/insertCoin/art/bullet.png +++ b/demos/insertCoin/art/bullet.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3dcd135e5514ada0a303806f4d621b97d930d403bb0144d0596287db9b80c27 -size 242 +oid sha256:2b001982846af6795ad952a6cea19dba7c9d3a516bb3fae78d1a6569a93e78b4 +size 685 diff --git a/demos/insertCoin/art/scanlines.png b/demos/insertCoin/art/scanlines.png index 0532e1d5a..3c42c0f34 100644 --- a/demos/insertCoin/art/scanlines.png +++ b/demos/insertCoin/art/scanlines.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b215035af6990880d75a34996d99c086eda11fabd5cf227239fe73dc692be002 -size 78 +oid sha256:10a40d79ed291db25cc865dd2a45cb9c59759223ecf0697c5302a16e8e7eb1d7 +size 69 diff --git a/demos/insertCoin/art/target.png b/demos/insertCoin/art/target.png index 348b5d17f..ebf2802cd 100644 --- a/demos/insertCoin/art/target.png +++ b/demos/insertCoin/art/target.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8361778f70dcdaa9015682ef5d6958dc4719b6850ed9a53efba133c508cc676d -size 926 +oid sha256:34b0bca6329ef2ddb7bc236b5a0f9e11009598eba13bfd022c15655a3a596090 +size 2527 diff --git a/demos/insertCoin/art/turret.png b/demos/insertCoin/art/turret.png index 41f1feeb8..9c4fd3c20 100644 --- a/demos/insertCoin/art/turret.png +++ b/demos/insertCoin/art/turret.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2695fd5aa69743c1e2568467ebe15edafa6a6f47283b1161e587e14c64a1172f -size 296 +oid sha256:85392a47fd34322161c8bfddd4fd941f12124e1f125f75554a293fbf67638b43 +size 619 diff --git a/demos/insertCoin/games.dat b/demos/insertCoin/games.dat index 0ecea18c0..213aea3ec 100644 --- a/demos/insertCoin/games.dat +++ b/demos/insertCoin/games.dat @@ -3,10 +3,10 @@ GAMES = { { TITLE = "Insert Coin", SCRIPT = "insertCoin/insertCoin.singe", - CANVAS_X = 640, - CANVAS_Y = 480, - RESOLUTION_X = 1024, - RESOLUTION_Y = 768, + CANVAS_X = 1920, + CANVAS_Y = 1080, + RESOLUTION_X = 1920, + RESOLUTION_Y = 1080, REQUIRES = { "SCENE", "POST", "SHADOWS" } } } diff --git a/demos/insertCoin/insertCoin.singe b/demos/insertCoin/insertCoin.singe index 70bf98cf3..cf5d5b50d 100644 --- a/demos/insertCoin/insertCoin.singe +++ b/demos/insertCoin/insertCoin.singe @@ -11,8 +11,6 @@ dofile("Singe/Framework.singe") local K = dofile(DIR .. "../kit.lua") -local W = 640 -local H = 480 local FOV = 60 local PHOSPHOR = { 70, 255, 140 } local NEON = { { 255, 60, 90 }, { 255, 170, 40 }, { 255, 240, 70 }, { 70, 230, 120 }, { 60, 180, 255 }, { 190, 90, 255 } } @@ -37,9 +35,10 @@ local FEATURE_TEXT = "YOU ARE WATCHING INSERT COIN, A DEMO OF THE SINGE GAME ENG local FEATURE_ALPHA = 190 -- How see-through its letters are: 0 to 255 local FEATURE_CELL = 12 -- A block of its letters, in pixels, as a letter rests -local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown -local FX_H = 240 -local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes +local ART_DENSITY = 2.25 -- The boss's, its bullets', and the targets' pixels to a design unit (util/insertCoinArt.py DENSITY) +local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown +local FX_H = 240 +local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes local PLASMA_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.25, { 10, 40, 160 } }, { 0.50, { 20, 200, 120 } }, { 0.70, { 240, 240, 60 } }, { 0.85, { 255, 255, 255 } }, { 1.00, { 0, 0, 20 } } } local TUNNEL_STOPS = { { 0.00, { 0, 0, 20 } }, { 0.30, { 10, 40, 160 } }, { 0.55, { 20, 200, 120 } }, { 0.80, { 240, 240, 60 } }, { 1.00, { 255, 255, 255 } } } local ALIEN_COLS = 10 @@ -81,7 +80,9 @@ local COIN_THICK = 0.2 local BRICK_COLS = 9 local BRICK_ROWS = 6 -local sync = K.load(DIR, W, H) +local sync = K.load(DIR) +local W = K.W -- The screen in design units: 480 tall (kit.lua) +local H = K.H local beat = sync.beats[3] - sync.beats[2] local art = {} local world = {} @@ -102,27 +103,46 @@ end -- Pixel drawing ------------------------------------------------------------------------------------ --- Frame frame of a sheet of frames frameW wide, at x, y, scale pixels a pixel. -local function px(sprite, frameW, frame, x, y, scale) - local h = spriteGetHeight(sprite) - - spriteDrawGrid(sprite, math.floor(x), math.floor(y), frame * frameW, 0, frameW, h, math.floor(x + frameW * scale) - 1, math.floor(y + h * scale) - 1) +-- Pixel art is drawn a whole number of the window's pixels to each of its own, the nearest to scale +-- design units, so every art pixel is the same size and crisp on any screen. +local function pixels(scale) + return math.max(1, math.floor(scale * K.S + 0.5)) end --- Text in the 8 by 8 font, scale pixels a pixel. align "center" centres it on x. +-- Frame frame of a sheet of frames frameW wide, at x, y, scale design units a pixel. +local function px(sprite, frameW, frame, x, y, scale) + local h = spriteGetHeight(sprite) + local size = pixels(scale) + local rx = math.floor(x * K.S) + local ry = math.floor(y * K.S) + + spriteDrawGrid(sprite, rx, ry, frame * frameW, 0, frameW, h, rx + frameW * size - 1, ry + h * size - 1) +end + + +-- How wide text in the 8 by 8 font is at scale, in design units. +local function ptextWidth(text, scale) + return #text * 8 * pixels(scale) / K.S +end + + +-- Text in the 8 by 8 font, scale design units a pixel. align "center" centres it on x. local function ptext(x, y, text, scale, r, g, b, align) - local w = #text * 8 * scale + local size = pixels(scale) + local rx = 0 + local ry = math.floor(y * K.S) if align == "center" then - x = x - w / 2 + x = x - ptextWidth(text, scale) / 2 end + rx = math.floor(x * K.S) spriteSetTint(art.font, r, g, b) for i = 1, #text do local c = text:byte(i) - 32 if c > 0 and c < 96 then - spriteDrawGrid(art.font, math.floor(x + (i - 1) * 8 * scale), math.floor(y), (c % 16) * 8, (c // 16) * 8, 8, 8, math.floor(x + i * 8 * scale) - 1, math.floor(y + 8 * scale) - 1) + spriteDrawGrid(art.font, rx + (i - 1) * 8 * size, ry, (c % 16) * 8, (c // 16) * 8, 8, 8, rx + i * 8 * size - 1, ry + 8 * size - 1) end end spriteSetTint(art.font, 255, 255, 255) @@ -131,32 +151,34 @@ end -- The same, each letter its own colour round the wheel, cycling: the high score table's rainbow. local function rainbow(x, y, text, scale, t, align) - local w = #text * 8 * scale + local step = ptextWidth("X", scale) if align == "center" then - x = x - w / 2 + x = x - ptextWidth(text, scale) / 2 end for i = 1, #text do local r, g, b = K.hue(i * 0.06 - t * 0.8) - ptext(x + (i - 1) * 8 * scale, y, text:sub(i, i), scale, r, g, b) + ptext(x + (i - 1) * step, y, text:sub(i, i), scale, r, g, b) end end +-- A CRT's scanlines over everything: one screen row in three darkened. local function scanlines(alpha) - spriteSetAlpha(art.scanlines, alpha or 255) - spriteDraw(art.scanlines, 0, 0, W - 1, H - 1) - spriteSetAlpha(art.scanlines, 255) + K.cover(art.scanlines, discGetHeight() / spriteGetHeight(art.scanlines), alpha) end -- Loading -------------------------------------------------------------------------------------------- local function loadArt() - for _, name in ipairs({ "invaderA", "invaderB", "cannon", "runner", "coin", "ship", "kart", "bossCore", "bossArm", "turret", "bullet", "font", "mountains", "hills", "ground", "scanlines", "fighterA", "fighterB", "target" }) do + for _, name in ipairs({ "invaderA", "invaderB", "cannon", "runner", "coin", "ship", "kart", "font", "mountains", "hills", "ground", "scanlines", "fighterA", "fighterB" }) do art[name] = spriteLoad(DIR .. "art/" .. name .. ".png") end + for _, name in ipairs({ "bossCore", "bossArm", "turret", "bullet", "target" }) do + art[name] = K.sprite(DIR .. "art/" .. name .. ".png", ART_DENSITY) + end spriteQuality(art.bossCore, RENDER_SMOOTH) spriteQuality(art.bossArm, RENDER_SMOOTH) -- The plasma and the tunnel, worked out live in Lua a frame at a time. @@ -168,17 +190,17 @@ local function loadArt() emitterSetBlend(art.boom, PARTICLE_ADD) emitterSetRate(art.boom, 0) emitterSetLife(art.boom, 0.3, 0.9) - emitterSetSpeed(art.boom, 60, 320) + emitterSetSpeed(art.boom, 60 * K.S, 320 * K.S) emitterSetSpread(art.boom, 180) emitterSetDrag(art.boom, 2) - emitterSetSize(art.boom, 9, 2) + emitterSetSize(art.boom, 9 * K.S, 2 * K.S) emitterSetMax(art.boom, 4000) emitterStart(art.boom) end local function boom(x, y, count, r, g, b) - emitterSetPosition(art.boom, x, y) + emitterSetPosition(art.boom, x * K.S, y * K.S) emitterSetColor(art.boom, 255, 255, 255, 255, r, g, b, 0) emitterBurst(art.boom, count) end @@ -402,7 +424,7 @@ local function buildBonus() materialSetTexture(tunnel, art.tunnel.sprite) materialSetUnlit(tunnel, true) materialSetDoubleSided(tunnel, true) - world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 80, 1.8) + world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 80, true) -- A coin is a gold rim and two struck faces: the name on one side and the dragon on the other. world.coinMesh = meshCylinder(COIN_RADIUS, COIN_THICK, 24) world.coinMaterial = K.chrome(255, 200, 60, 0.25) @@ -508,6 +530,7 @@ local function buildWorld() world.camera = nodeNew() cameraSet(world.camera) cameraSetPerspective(FOV, 0.1, 800) + K.screen(world.camera, FOV, 800) art.blocks = K.blockFont(dofile(DIR .. "art/font.lua"), world.camera, FOV, FEATURE_CELL, FEATURE_ALPHA) buildRacer() buildPolygons() @@ -555,7 +578,7 @@ ERA.boot = { hideAll() end, draw = function(t, bar, k) - K.flash(0, 0, 0, 255) + K.fill(0, 0, 0) local warm = K.smooth(1, 1.6, bar) if warm < 1 then @@ -589,7 +612,7 @@ ERA.pong = { world.pongScore = { 0, 0 } end, draw = function(t, bar, k) - K.flash(0, 6, 2, 255) + K.fill(0, 6, 2) local b = K.beat(t) local left = 60 local right = W - 60 @@ -649,7 +672,7 @@ ERA.invaders = { world.alienBase = K.beat(t) end, draw = function(t, bar, k) - K.flash(0, 0, 8, 255) + K.fill(0, 0, 8) local step = math.floor(K.beat(t) - world.alienBase) local ox, oy = alienPositions(step) local frame = step % 2 @@ -736,11 +759,11 @@ ERA.runner = { K.box(0, i * 30, W - 1, i * 30 + 29, r, g, math.min(255, b), 255) end local function layer(sprite, speed, y, scale) - local w = spriteGetWidth(sprite) * scale + local w = K.width(sprite) * scale local offset = (t * speed) % w for x = -offset, W, w do - spriteDraw(sprite, math.floor(x), y, math.floor(x + w) - 1, y + spriteGetHeight(sprite) * scale - 1) + K.draw(sprite, x, y, x + w, y + K.height(sprite) * scale) end end layer(art.mountains, RUN_SPEED * 0.15, 170, 2) @@ -1039,7 +1062,7 @@ ERA.continue = { hideAll() end, draw = function(t, bar, k) - K.flash(0, 0, 0, 255) + K.fill(0, 0, 0) local n = 8 - math.floor(bar - 73) if bar < 74 then @@ -1078,8 +1101,8 @@ ERA.boss = { end, draw = function(t, bar, k) K.plasmaUpdate(art.plasma, t) - spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1) - K.flash(0, 0, 10, 200) + K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true) + K.under(nil, 0, 0, W, H, 0, 0, 10, 200) local dt = t - lastT local cx = W / 2 + math.sin(t * 0.5) * 60 local cy = 150 + math.sin(t * 0.8) * 20 @@ -1126,14 +1149,10 @@ ERA.boss = { local ax = cx + math.cos(math.rad(a)) * 60 local ay = cy + math.sin(math.rad(a)) * 60 - spriteRotate(art.bossArm, a) - spriteDraw(art.bossArm, math.floor(ax), math.floor(ay), true) - spriteDraw(art.turret, math.floor(cx + math.cos(math.rad(a)) * 110) - 14, math.floor(cy + math.sin(math.rad(a)) * 110) - 14) + K.turn(art.bossArm, ax, ay, a) + K.turn(art.turret, cx + math.cos(math.rad(a)) * 110, cy + math.sin(math.rad(a)) * 110, 0) end - spriteSetTint(art.bossCore, 255, hitFlash and 200 or 255, hitFlash and 200 or 255) - spriteRotateAndScale(art.bossCore, -spin * 0.5, 1.4 + 0.1 * K.decay("kicks", t, 8)) - spriteDraw(art.bossCore, math.floor(cx), math.floor(cy), true) - spriteSetTint(art.bossCore, 255, 255, 255) + K.turn(art.bossCore, cx, cy, -spin * 0.5, 1.4 + 0.1 * K.decay("kicks", t, 8), 255, hitFlash and 200 or 255, hitFlash and 200 or 255) -- Bullets. local tints = { { 255, 80, 220 }, { 80, 230, 255 }, { 255, 230, 80 } } @@ -1148,7 +1167,7 @@ ERA.boss = { local c = tints[bl.c] spriteSetTint(art.bullet, c[1], c[2], c[3]) - spriteDraw(art.bullet, math.floor(bl.x) - 6, math.floor(bl.y) - 6) + K.draw(art.bullet, bl.x, bl.y, true) end end spriteSetTint(art.bullet, 255, 255, 255) @@ -1173,10 +1192,6 @@ ERA.boss = { K.flash(255, 255, 255, math.floor(255 * K.smooth(96.85, 97, bar))) end end, - leave = function(t) - spriteRotate(art.bossArm, 0) - spriteRotateAndScale(art.bossCore, 0, 1) - end, } -- The bonus stage: coins pour down further along the tunnel, COIN_RATE a beat, land on its floor, and the @@ -1262,26 +1277,39 @@ ERA.scores = { end, draw = function(t, bar, k) K.plasmaUpdate(art.plasma, t) - spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1) - K.flash(0, 0, 20, 190) + K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true) + K.under(nil, 0, 0, W, H, 0, 0, 20, 190) rainbow(W / 2, 40, "HIGH SCORES", 4, t, "center") - -- Rows share the space under the title and appear one after another over the first half of the - -- scene, however many there are. + -- Rows share the space under the title, centred in it, and appear one after another over the + -- first half of the scene, however many there are. The table is centred across the screen by + -- its widest row; a name too long for the screen is drawn a size smaller. local count = #HIGH_SCORES local top = 110 - local spacing = math.min(52, (H - 40 - top) / math.max(count, 1)) + local room = H - 40 - top + local spacing = math.min(52, room / math.max(count, 1)) local scale = (spacing >= 34) and 3 or 2 + local scoreAt = 100 + local nameAt = 290 + local fit = math.floor((W - 80 - nameAt) / ptextWidth("X", scale)) + local sizes = {} + local widest = 0 for i, row in ipairs(HIGH_SCORES) do - if bar >= 113 + (i - 1) * 4 / count then - local y = top + (i - 1) * spacing - local name = tostring(row[1]) - local fit = math.floor((W - 330 - 16) / (8 * scale)) - local size = (#name > fit) and math.max(1, scale - 1) or scale + local name = tostring(row[1]) - ptext(40, y, ordinal(i), scale, 255, 255, 255) - ptext(140, y, string.format("%06d", math.floor(row[2] or 0) % 1000000), scale, 255, 230, 80) - rainbow(330, y, name:upper(), size, t + i * 0.3) + sizes[i] = (#name > fit) and math.max(1, scale - 1) or scale + widest = math.max(widest, ptextWidth(name, sizes[i])) + end + local left = (W - nameAt - widest) / 2 + + top = top + (room - spacing * count) / 2 + for i, row in ipairs(HIGH_SCORES) do + if bar >= 113 + (i - 1) * 4 / count then + local y = top + (i - 1) * spacing + + ptext(left, y, ordinal(i), scale, 255, 255, 255) + ptext(left + scoreAt, y, string.format("%06d", math.floor(row[2] or 0) % 1000000), scale, 255, 230, 80) + rainbow(left + nameAt, y, tostring(row[1]):upper(), sizes[i], t + i * 0.3) end end scanlines(120) @@ -1296,7 +1324,7 @@ ERA.outro = { hideAll() end, draw = function(t, bar, k) - K.flash(0, 0, 0, 255) + K.fill(0, 0, 0) local p = t - K.barTime(137) local last = K.duration - 0.9 @@ -1529,7 +1557,7 @@ ERA.gallery = { x = W - x end if not g.shot[id] and x > -40 and x < W + 40 then - spriteDraw(art.target, math.floor(x - 32), y - 20) + K.draw(art.target, x - 32, y - 20) live[#live + 1] = { id = id, x = x, y = y + 12 } end end @@ -1576,7 +1604,7 @@ ERA.gallery = { table.remove(g.falling, i) else spriteRotate(art.target, math.floor(f.spin) % 360) - spriteDraw(art.target, math.floor(f.x), math.floor(f.y), true) + K.draw(art.target, f.x, f.y, true) end end spriteRotate(art.target, 0) @@ -1585,9 +1613,11 @@ ERA.gallery = { local cx = math.floor(g.cx) local cy = math.floor(g.cy) + -- The ring, drawn a screen pixel at a time as thick as one design unit. colorForeground(255, 40, 40, 255) - overlayCircle(cx, cy, 26) - overlayCircle(cx, cy, 27) + for r = math.floor(26 * K.S), math.floor(28 * K.S) - 1 do + overlayCircle(math.floor(cx * K.S), math.floor(cy * K.S), r) + end K.box(cx - 40, cy - 1, cx - 12, cy + 1, 255, 40, 40, 255) K.box(cx + 12, cy - 1, cx + 40, cy + 1, 255, 40, 40, 255) K.box(cx - 1, cy - 40, cx + 1, cy - 12, 255, 40, 40, 255) @@ -1625,7 +1655,7 @@ ERA.fighter = { for i = 0, 9 do K.box(0, i * 33, W - 1, i * 33 + 32, 60 + i * 14, 30 + i * 8, 90 + i * 6, 255) end - spriteDraw(art.hills, 0, floor - 150, W - 1, floor - 30) + K.draw(art.hills, 0, floor - 150, W, floor - 30) K.box(0, floor - 30, W - 1, H - 1, 90, 60, 50, 255) for x = 0, W, 64 do K.box(x, floor - 30, x + 2, H - 1, 70, 45, 38, 255) @@ -1702,8 +1732,8 @@ ERA.rhythm = { local r = world.rhythm K.plasmaUpdate(art.plasma, t) - spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1) - K.flash(0, 0, 20, 200) + K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true) + K.under(nil, 0, 0, W, H, 0, 0, 20, 200) -- The highway, a row at a time, narrowing to the horizon, and the beat lines rushing down it. for y = RHYTHM_TOP, H - 1, 2 do local s = (y - RHYTHM_TOP) / (RHYTHM_HIT - RHYTHM_TOP) @@ -1809,7 +1839,7 @@ ERA.montage = { end local r, g, b = K.hue(t * 0.7) - K.flash(r // 3, g // 3, b // 3, 255) + K.fill(r // 3, g // 3, b // 3) local s = 10 + 4 * math.exp(-(bar - 135) * 6) rainbow(W / 2, 170, "GAME ON", math.floor(s), t, "center") diff --git a/demos/kit.lua b/demos/kit.lua index 603398f94..dc33a4e47 100644 --- a/demos/kit.lua +++ b/demos/kit.lua @@ -23,10 +23,16 @@ local HIT_LISTS = { "kicks", "snares", "hats" } local LEVELS = 63 local PIXEL_PNG = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0b\x49\x44\x41\x54\x78\x9c\x63\xf8\x0f\x04\x00\x09\xfb\x03\xfd\xfb\x5e\x6b\x2b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82" local FONT_FILE = "Singe/FreeSansBold.ttf" +local DESIGN_H = 480 -- The demos are laid out this many units tall, whatever the window: K.S pixels to a unit +local QUAD_FRONT = 0.2 -- The screen quads (K.flash, K.under, K.backdrop): how far from the camera the flashes hang ... +local QUAD_UNDER = 0.25 -- ... the layer under the overlay, in front of the world ... +local QUAD_BACK = 0.9 -- ... and the backdrop, this share of the way to the far plane +local QUAD_STEP = 0.001 -- Each quad drawn after another in its layer comes this share nearer local TAIL = 2 -- Seconds the demo runs on after the music ends, fading to black local LOGO_FADE = 1.0 -- The logo cards before and after a demo: seconds fading up from black ... local LOGO_HOLD = 1.5 -- ... held ... local LOGO_TIME = LOGO_FADE * 2 + LOGO_HOLD -- ... and the whole card, back to black +local LOGO_GAP = 24 -- Design units between the logos stacked on a card local sync = nil local music = nil @@ -41,7 +47,11 @@ local lastTicks = 0 local frames = 0 local stats = { second = 0, frames = 0, worst = 0, last = 0 } --- The scrollers' letters: each a sprite made once per font, white, so it can be tinted. +-- How many of a sprite's pixels make one design unit (K.sprite); 1 when not said. +local density = {} + +-- The scrollers' letters: each a sprite made once per font, white, so it can be tinted, and its +-- size in design units. Fonts from K.font are rendered at the window's resolution. local glyphs = {} local function glyph(font, c) @@ -54,12 +64,105 @@ local function glyph(font, c) fontQuality(FONT_QUALITY_BLENDED) local s = fontToSprite(c) - g = { sprite = s, w = spriteGetWidth(s) } + density[s] = K.S + g = { sprite = s, w = spriteGetWidth(s) / K.S, h = spriteGetHeight(s) / K.S } glyphs[key] = g end return g end +-- The screen quads: pools of camera-facing quads, three layers deep, placed fresh every frame by +-- K.flash, K.fill, K.under, and K.backdrop and hidden again when a frame does not use them. They +-- draw on the GPU, where a whole-screen layer costs nothing; on the overlay it costs the CPU about +-- 8 ms at 1920 by 1080. +local screen = { camera = nil, fov = 60, far = 100, layers = {} } + +local function quadLayer(name, distance, front) + local layer = { nodes = {}, used = 0, distance = distance, front = front } + + screen.layers[name] = layer + return layer +end + + +-- The next quad of a layer, made the first time it is wanted: unlit, see-through, its own +-- material, hung off the camera. +local function quadNext(layer) + local q + + layer.used = layer.used + 1 + q = layer.nodes[layer.used] + if not q then + local m = materialNew() + local pivot = nodeNew(screen.camera) + local quad = nodeNew(pivot) + + materialSetUnlit(m, true) + materialSetBlend(m, true) + materialSetDoubleSided(m, true) + nodeSetMesh(quad, screen.mesh, m) + nodeSetRotation(quad, 90, 0, 0) + nodeSetShadow(quad, false) + if layer.front then + nodeSetLayer(pivot, LAYER_FRONT) + end + q = { pivot = pivot, quad = quad, material = m, tiles = 1, nearest = false } + layer.nodes[layer.used] = q + end + nodeSetVisible(q.pivot, true) + return q +end + + +-- Places a layer's next quad over x1, y1 to x2, y2 in design units, in a colour, showing a sprite +-- or nothing, repeated tiles times down it (once when nil), sampled nearest when asked, and turned +-- by angle degrees about its middle. A sprite whose pixels change from frame to frame is sent again +-- only when dynamic. +local function quadPlace(layer, x1, y1, x2, y2, r, g, b, a, sprite, dynamic, tiles, nearest, angle) + local q = quadNext(layer) + local slope = 2 * math.tan(math.rad(screen.fov / 2)) / K.H + local dx = ((x1 + x2) / 2 - K.W / 2) * slope + local dy = (K.H / 2 - (y1 + y2) / 2) * slope + -- The scene draws see-through things farthest first by the distance of their middles from the + -- eye, so each quad slides along its own line of sight (where it shows does not change) until its + -- middle is as far as its turn in the layer says: then they draw in the order they were placed. + local d = layer.distance * (1 - QUAD_STEP * layer.used) / math.sqrt(1 + dx * dx + dy * dy) + local unit = d * slope + + if sprite ~= q.sprite or dynamic then + materialSetTexture(q.material, sprite) + q.sprite = sprite + end + if (tiles or 1) ~= q.tiles then + q.tiles = tiles or 1 + materialSetTiling(q.material, 1, q.tiles) + end + if (nearest or false) ~= q.nearest then + q.nearest = nearest or false + materialSetFilter(q.material, q.nearest and FILTER_NEAREST or FILTER_LINEAR) + end + materialSetColor(q.material, r, g, b, a or 255) + nodeSetPosition(q.pivot, dx * d, dy * d, -d) + nodeSetRotation(q.pivot, 0, 0, angle or 0) + nodeSetScale(q.quad, (x2 - x1) * unit, 1, (y2 - y1) * unit) +end + + +local function screenBegin() + for _, layer in pairs(screen.layers) do + layer.used = 0 + end +end + + +local function screenEnd() + for _, layer in pairs(screen.layers) do + for i = layer.used + 1, #layer.nodes do + nodeSetVisible(layer.nodes[i].pivot, false) + end + end +end + -- The block letters of K.blockFont: the shade of each way a face can point (front, top, left, right -- and back, bottom), as a strip of greys the hue materials multiply; how many hues round the wheel; @@ -171,6 +274,13 @@ K.W = 640 K.H = 480 +-- A sprite behind the 3D world, over x1, y1 to x2, y2 in design units (the whole screen when +-- they are left out), tinted r, g, b, a; dynamic when its pixels change every frame. On the GPU. +function K.backdrop(sprite, x1, y1, x2, y2, r, g, b, a, dynamic) + quadPlace(screen.layers.back, x1 or 0, y1 or 0, x2 or K.W, y2 or K.H, r or 255, g or 255, b or 255, a, sprite, dynamic) +end + + -- The fractional bar at time t, counting from 1: bar 1 starts on the beat the sync data names. function K.bar(t) return (K.beat(t) - (sync.barStart - 1)) / 4 + 1 @@ -326,7 +436,7 @@ function K.bobs(t, bar, sprite, count, spread, size, cx, cy) local cr, cg, cb = K.hue(p.i / count + t * 0.1) spriteSetTint(sprite, cr, cg, cb) - spriteDraw(sprite, math.floor(px - r), math.floor(py - r), math.floor(px + r), math.floor(py + r)) + K.draw(sprite, px - r, py - r, px + r, py + r) end spriteSetTint(sprite, 255, 255, 255) end @@ -341,7 +451,7 @@ function K.box(x1, y1, x2, y2, r, g, b, a, blend) spriteSetTint(pixel, r, g, b) spriteSetAlpha(pixel, a or 255) spriteSetBlend(pixel, blend or SPRITE_ALPHA) - spriteDraw(pixel, math.floor(x1), math.floor(y1), math.floor(x2), math.floor(y2)) + K.draw(pixel, x1, y1, x2 + 1, y2 + 1) end @@ -382,7 +492,7 @@ function K.circleScroller(t, font, text, cx, cy, r, speed, t0, t1) -- so a narrow letter is not overlapped by its neighbours. spriteSetTint(g.sprite, cr, cg, cb) spriteRotate(g.sprite, math.floor(middle + 90) % 360) - spriteDraw(g.sprite, math.floor(cx + math.cos(rad) * r), math.floor(cy + math.sin(rad) * r), true) + K.draw(g.sprite, cx + math.cos(rad) * r, cy + math.sin(rad) * r, true) spriteRotate(g.sprite, 0) a = a + math.deg(g.w / r) end @@ -400,6 +510,13 @@ function K.clamp(v, lo, hi) end +-- A sprite over the whole screen and everything on it, repeated tiles times from top to bottom and +-- sampled nearest, at alpha a: scanlines, a grille, a screen door. On the GPU. +function K.cover(sprite, tiles, a) + quadPlace(screen.layers.front, 0, 0, K.W, K.H, 255, 255, 255, a, sprite, false, tiles, true) +end + + -- A crawl: lines of text on a plane laid down toward the horizon, sliding away from the viewer and -- shrinking as they go, faded into the distance. The first line starts at the bottom of the screen -- at t0 and the last reaches the horizon at t1. @@ -422,11 +539,11 @@ function K.crawl(t, font, lines, t0, t1, horizonY) for c = 1, #line do local g = glyph(font, line:sub(c, c)) local gw = g.w * scale - local gh = spriteGetHeight(g.sprite) * scale + local gh = g.h * scale spriteSetTint(g.sprite, 255, math.floor(200 + 55 * fade), 120 + math.floor(100 * (1 - fade))) spriteSetAlpha(g.sprite, math.floor(255 * fade)) - spriteDraw(g.sprite, math.floor(x), math.floor(y - gh), math.floor(x + gw), math.floor(y)) + K.draw(g.sprite, x, y - gh, x + gw, y) spriteSetAlpha(g.sprite, 255) x = x + gw end @@ -471,6 +588,45 @@ function K.disc(r, segments) end +-- Draws a sprite in design units: at its own size from x, y, or centred on them when x2 is true, +-- or stretched from x, y up to (not including) x2, y2, so pieces that meet tile without gaps at any +-- scale. Its own size is its pixels over its density (K.sprite). +function K.draw(sprite, x, y, x2, y2) + local k = K.S / (density[sprite] or 1) + local w = 0 + local h = 0 + + if y2 then + spriteDraw(sprite, math.floor(x * K.S), math.floor(y * K.S), math.floor(x2 * K.S) - 1, math.floor(y2 * K.S) - 1) + return + end + w = spriteGetWidth(sprite) * k + h = spriteGetHeight(sprite) * k + x = x * K.S + y = y * K.S + if x2 == true then + x = x - w / 2 + y = y - h / 2 + end + if k == 1 then + spriteDraw(sprite, math.floor(x), math.floor(y)) + else + spriteDraw(sprite, math.floor(x), math.floor(y), math.floor(x + w), math.floor(y + h)) + end +end + + +-- spriteDrawGrid in design units: the part sx, sy, w, h of the sprite (its own pixels) drawn at +-- x, y at its own size, or stretched up to (not including) x2, y2. +function K.drawGrid(sprite, x, y, sx, sy, w, h, x2, y2) + local k = 1 / (density[sprite] or 1) + + x2 = x2 or (x + w * k) + y2 = y2 or (y + h * k) + spriteDrawGrid(sprite, math.floor(x * K.S), math.floor(y * K.S), sx, sy, w, h, math.floor(x2 * K.S) - 1, math.floor(y2 * K.S) - 1) +end + + function K.ease(t) t = K.clamp(t, 0, 1) return t * t * (3 - 2 * t) @@ -511,9 +667,30 @@ function K.env(name, t) end --- The whole screen in one colour: a flash, a fade, or a tint. -function K.flash(r, g, b, a, blend) - K.box(0, 0, K.W - 1, K.H - 1, r, g, b, a, blend) +-- The whole screen in one solid colour under the overlay and over the 3D world: a 2D scene's +-- background. On the GPU. +function K.fill(r, g, b) + quadPlace(screen.layers.under, 0, 0, K.W, K.H, r, g, b, 255) +end + + +-- The whole screen in one colour over everything, the 3D front layer included: a flash, a fade, or +-- a tint. On the GPU. +function K.flash(r, g, b, a) + if (a or 255) > 0 then + quadPlace(screen.layers.front, 0, 0, K.W, K.H, r, g, b, a) + end +end + + +-- A font of the bundled face at a size in design units, rendered at the window's resolution. +function K.font(size) + local real = math.floor(size * K.S + 0.5) + + if not fonts[real] then + fonts[real] = fontLoad(FONT_FILE, real) + end + return fonts[real] end @@ -528,6 +705,12 @@ function K.glass(r, g, b, alpha) end +-- A sprite's height in design units. +function K.height(sprite) + return spriteGetHeight(sprite) / (density[sprite] or 1) +end + + -- Seconds since the most recent hit in a list at time t. function K.hitAge(list, t) local hits = sync[list] @@ -584,12 +767,14 @@ end -- Reads the demo's sync data and its music, makes the overlay, and loads the kit's own pixel. -- Envelopes become arrays of numbers from 0 to 1 here, once. -function K.load(dir, width, height) +function K.load(dir) local value = {} - K.W = width or K.W - K.H = height or K.H - overlaySetResolution(K.W, K.H) + -- The window's size is the canvas in games.dat; the demo works in design units, DESIGN_H tall. + K.S = discGetHeight() / DESIGN_H + K.W = discGetWidth() / K.S + K.H = DESIGN_H + overlaySetResolution(discGetWidth(), discGetHeight()) for i = 1, #ALPHABET do value[ALPHABET:byte(i)] = (i - 1) / LEVELS end @@ -617,10 +802,10 @@ function K.load(dir, width, height) music = musicLoad(dir .. "music.mp3") end pixel = spriteLoadData(PIXEL_PNG) - -- The logos' lettering is opaque black, which the colour key would drop: they go by alpha alone. + -- The logos go by their alpha alone: the colour key would drop any black in them. drawTransparent(true) - logos.singe = spriteLoad(dir .. "../art/singeLogo.png") - logos.kangaroo = spriteLoad(dir .. "../art/kangarooPunchLogo.png") + logos.opening = { K.vector(dir .. "../art/dragon.svg", K.W * 0.6, 270), K.vector(dir .. "../art/singeText.svg", K.W * 0.5, 84) } + logos.closing = { K.vector(dir .. "../art/kangarooPunch.svg", K.W * 0.8, 400) } drawTransparent(false) return sync end @@ -728,16 +913,22 @@ end -- DEMO_CAPTURE = { from = seconds, to = seconds, every = frames } nothing plays: the clock starts -- at from and moves by the frame (run Singe with --deterministic), every frame (or every "every"th) -- is saved as frameNNNNNN.png, and the demo quits at to. --- A logo card p seconds in: the screen fades from black to white with the logo coming up on it, --- holds, and fades back to black, the way Singe's own start does. The logos' lettering is black. -local function logoCard(sprite, p) - local v = math.floor(255 * K.clamp(math.min(p, LOGO_TIME - p) / LOGO_FADE, 0, 1)) - local x = (K.W - spriteGetWidth(sprite)) // 2 - local y = (K.H - spriteGetHeight(sprite)) // 2 +-- A logo card p seconds in: its logos, stacked and centred on black, fade up, hold, and fade back. +local function logoCard(card, p) + local v = math.floor(255 * K.clamp(math.min(p, LOGO_TIME - p) / LOGO_FADE, 0, 1)) + local height = LOGO_GAP * (#card - 1) + local y = 0 - K.flash(v, v, v, 255) - spriteSetAlpha(sprite, v) - spriteDraw(sprite, x, y) + for _, sprite in ipairs(card) do + height = height + K.height(sprite) + end + y = (K.H - height) / 2 + K.fill(0, 0, 0) + for _, sprite in ipairs(card) do + spriteSetAlpha(sprite, v) + K.draw(sprite, K.W / 2, y + K.height(sprite) / 2, true) + y = y + K.height(sprite) + LOGO_GAP + end end @@ -754,15 +945,16 @@ function K.run(scenes) frames = frames + 1 colorBackground(0, 0, 0, 0) overlayClear() + screenBegin() if not DEMO_CAPTURE and not playing and t >= 0 then musicPlay(music) playing = true end -- The Singe card before the music, the demo, and the Kangaroo Punch card after it. if t < 0 then - logoCard(logos.singe, t + LOGO_TIME) + logoCard(logos.opening, t + LOGO_TIME) elseif t >= ending then - logoCard(logos.kangaroo, t - ending) + logoCard(logos.closing, t - ending) else local bar = K.bar(t) @@ -791,6 +983,7 @@ function K.run(scenes) K.flash(0, 0, 0, math.floor(255 * K.clamp((t - sync.duration) / TAIL, 0, 1))) end end + screenEnd() if DEMO_CAPTURE then if frames % (DEMO_CAPTURE.every or 1) == 0 then singeScreenshot(string.format("frame%06d", frames)) @@ -807,12 +1000,27 @@ function K.run(scenes) end --- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger, to stay --- covering while it turns): for a video over, or as, the whole picture in 3D, where it can turn and --- glow. What comes back is a pivot on the camera's axis with the quad stood up under it, so the --- pivot's roll, nodeSetRotation(q, 0, 0, angle), turns the quad in its own plane. It starts hidden. +-- Hangs the screen quads off the demo's camera, whose field of view and far plane they are placed +-- by. Call it once the camera is made, before any K.flash, K.fill, K.under, or K.backdrop. +function K.screen(camera, fov, far) + screen.camera = camera + screen.fov = fov + screen.far = far + screen.mesh = meshPlane(1, 1) + quadLayer("front", QUAD_FRONT, true) + quadLayer("under", QUAD_UNDER, false) + quadLayer("back", far * QUAD_BACK, false) +end + + +-- A flat quad that exactly fills the camera's view at distance d (oversize makes it larger; true +-- makes it as large as it must be to keep the screen covered however it turns, the screen's diagonal +-- over its height): for a video over, or as, the whole picture in 3D, where it can turn and glow. +-- What comes back is a pivot on the camera's axis with the quad stood up under it, so the pivot's +-- roll, nodeSetRotation(q, 0, 0, angle), turns the quad in its own plane. It starts hidden. function K.screenQuad(camera, fov, material, d, oversize) - local h = 2 * d * math.tan(math.rad(fov / 2)) * (oversize or 1) + local turning = math.sqrt(1 + (K.W / K.H) ^ 2) + local h = 2 * d * math.tan(math.rad(fov / 2)) * ((oversize == true) and turning or (oversize or 1)) local pivot = nodeNew(camera) local quad = nodeNew(pivot) @@ -846,13 +1054,13 @@ function K.scroller(t, font, text, y, amp, t0, t1, style) end if x + g.w > 0 then local r, gg, b = K.hue(x / K.W * 0.5 + t * 0.2) - local h = spriteGetHeight(g.sprite) + local h = g.h spriteSetTint(g.sprite, r, gg, b) if style == "bounce" then local hop = math.abs(math.sin(t * 5 + i * 0.45)) * amp * (0.5 + kick) - spriteDraw(g.sprite, math.floor(x), math.floor(y - hop)) + K.draw(g.sprite, x, y - hop) elseif style == "zoom" then -- Laid out at the zoomed width, so the swollen letters push their neighbours apart -- rather than piling onto them. @@ -861,7 +1069,7 @@ function K.scroller(t, font, text, y, amp, t0, t1, style) local w = g.w * scale local hh = h * scale - spriteDraw(g.sprite, math.floor(x), math.floor(y + h - hh), math.floor(x + w), math.floor(y + h)) + K.draw(g.sprite, x, y + h - hh, x + w, y + h) x = x + w - g.w elseif style == "spin" then -- Each letter spins in from the right, reads upright across the middle, and spins @@ -869,10 +1077,10 @@ function K.scroller(t, font, text, y, amp, t0, t1, style) local spin = K.smooth(K.W * 0.62, K.W, x) - K.smooth(K.W * 0.38, 0, x) spriteRotate(g.sprite, math.floor(360 * spin) % 360) - spriteDraw(g.sprite, math.floor(x + g.w / 2), math.floor(y + h / 2 + math.sin(x * 0.012 + t * 3) * amp), true) + K.draw(g.sprite, x + g.w / 2, y + h / 2 + math.sin(x * 0.012 + t * 3) * amp, true) spriteRotate(g.sprite, 0) else - spriteDraw(g.sprite, math.floor(x), math.floor(y + math.sin(x * 0.018 + t * 4) * amp)) + K.draw(g.sprite, x, y + math.sin(x * 0.018 + t * 4) * amp) end end x = x + g.w @@ -1001,6 +1209,16 @@ end -- Under DEMO_STATS, once a second: frames drawn in the last second, and the longest gap between two. +-- Loads a sprite whose pixels are drawn density to a design unit: art made for the window's +-- resolution says K.S and draws pixel for pixel; art made for the old 640 by 480 screen says 1. +function K.sprite(path, d) + local sprite = spriteLoad(path) + + density[sprite] = d or 1 + return sprite +end + + function K.stats(t, bar) local now = singeGetTicks() @@ -1031,12 +1249,8 @@ end -- Text in the bundled font at a point size, in a colour, left, centred ("center"), or right -- aligned on x. function K.text(x, y, text, size, r, g, b, align) - local font = fonts[size] + local font = K.font(size) - if not font then - font = fontLoad(FONT_FILE, size) - fonts[size] = font - end fontSelect(font) fontQuality(FONT_QUALITY_BLENDED) colorForeground(r, g, b, 255) @@ -1050,26 +1264,20 @@ function K.text(x, y, text, size, r, g, b, align) x = x - w end end - fontPrint(math.floor(x), math.floor(y), text) + fontPrint(math.floor(x * K.S), math.floor(y * K.S), text) end --- How wide text is in the bundled font at a point size, in pixels; measured once and remembered. +-- How wide text is in the bundled font at a size, in design units; measured once and remembered. function K.textWidth(text, size) local key = size .. ":" .. text local w = widths[key] if not w then - local font = fonts[size] - - if not font then - font = fontLoad(FONT_FILE, size) - fonts[size] = font - end - fontSelect(font) + fontSelect(K.font(size)) local sprite = fontToSprite(text) - w = spriteGetWidth(sprite) + w = spriteGetWidth(sprite) / K.S widths[key] = w spriteUnload(sprite) end @@ -1247,4 +1455,40 @@ function K.tunnelUpdate(fx, travel, turn) end +-- A sprite turned angle degrees and scaled about its middle at x, y, under the overlay and over the +-- 3D world, tinted r, g, b, a: spriteRotateAndScale and a centred draw, on the GPU, where turning a +-- large picture every frame costs nothing. +function K.turn(sprite, x, y, angle, scale, r, g, b, a) + local w = K.width(sprite) * (scale or 1) / 2 + local h = K.height(sprite) * (scale or 1) / 2 + + quadPlace(screen.layers.under, x - w, y - h, x + w, y + h, r or 255, g or 255, b or 255, a, sprite, false, nil, false, angle) +end + + +-- A sprite, or a colour when it is nil, under the overlay and over the 3D world, over x1, y1 to +-- x2, y2 in design units, tinted r, g, b, a; dynamic when its pixels change every frame. For a +-- 2D effect as big as the screen, which the overlay would spend milliseconds a frame drawing. On +-- the GPU. +function K.under(sprite, x1, y1, x2, y2, r, g, b, a, dynamic) + quadPlace(screen.layers.under, x1, y1, x2, y2, r or 255, g or 255, b or 255, a, sprite, dynamic) +end + + +-- An SVG drawn to fit w by h design units, keeping its shape, rasterised at the window's resolution +-- so it is sharp at any size. +function K.vector(path, w, h) + local sprite = spriteLoad(path, math.floor(w * K.S), math.floor(h * K.S)) + + density[sprite] = K.S + return sprite +end + + +-- A sprite's width in design units. +function K.width(sprite) + return spriteGetWidth(sprite) / (density[sprite] or 1) +end + + return K diff --git a/demos/mirrorball/art/bob.png b/demos/mirrorball/art/bob.png index 76b5f0234..54a9e4654 100644 --- a/demos/mirrorball/art/bob.png +++ b/demos/mirrorball/art/bob.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ace021ff6910172a706b7dc675d34841f7fb9f6356eba68d1c3fbd36da345ca0 -size 2708 +oid sha256:b3033e104d7025d61f5c64e2ea99bc977257318b29d59c3c33f125cce7b6a6c2 +size 7405 diff --git a/demos/mirrorball/art/logo.png b/demos/mirrorball/art/logo.png index 1eebd8607..fc9333bc6 100644 --- a/demos/mirrorball/art/logo.png +++ b/demos/mirrorball/art/logo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a67f837a6a236e3a1171f62becb774c083f7464b4ef65fe79eb2ee0ffecfd8ec -size 89232 +oid sha256:82be33991664409e19279b8dfac860629e0e7dc94bd48388d5112f4d2d183129 +size 300970 diff --git a/demos/mirrorball/art/singe.png b/demos/mirrorball/art/singe.png index 98e7a408e..504c96fcc 100644 --- a/demos/mirrorball/art/singe.png +++ b/demos/mirrorball/art/singe.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e4caf4e7dbebd81bf0042331f2bdfbc5075759f97c1ead0d6e9aeea4030e039 -size 66896 +oid sha256:a320a38428965399f5eb067d7b15c434d89abe2660e215ca5effd17ba6f9c01d +size 215069 diff --git a/demos/mirrorball/games.dat b/demos/mirrorball/games.dat index 67acce6a4..54e88c225 100644 --- a/demos/mirrorball/games.dat +++ b/demos/mirrorball/games.dat @@ -3,10 +3,10 @@ GAMES = { { TITLE = "Mirrorball", SCRIPT = "mirrorball/mirrorball.singe", - CANVAS_X = 640, - CANVAS_Y = 480, - RESOLUTION_X = 1024, - RESOLUTION_Y = 768, + CANVAS_X = 1920, + CANVAS_Y = 1080, + RESOLUTION_X = 1920, + RESOLUTION_Y = 1080, REQUIRES = { "SCENE", "POST", "SHADOWS" } } } diff --git a/demos/mirrorball/mirrorball.singe b/demos/mirrorball/mirrorball.singe index 61d9a7cb4..9433d2122 100644 --- a/demos/mirrorball/mirrorball.singe +++ b/demos/mirrorball/mirrorball.singe @@ -12,8 +12,6 @@ dofile("Singe/Framework.singe") local K = dofile(DIR .. "../kit.lua") -local W = 640 -local H = 480 local FOV = 60 local TILES = 12 -- The dance floor is TILES by TILES local TILE = 2 -- World units across a tile @@ -35,6 +33,7 @@ local GROOVES = 256 -- The record's texture, a side local RAIN_MAX = 70 -- Balls that fall on the snares in the boing scene local TWIST_R = 92 -- The twister's half width, in pixels local TWIST_ROW = 2 -- Screen rows each slice of the twister covers +local ART_DENSITY = 2.25 -- The logo's, the title's, and the bob's pixels to a design unit (util/mirrorballArt.py DENSITY) local FX_W = 320 -- The live plasma's and tunnel's size, stretched to wherever they are shown local FX_H = 240 local TUNNEL_LAP = 4 -- The tunnel's travel counts a lap of its texture as this many passes @@ -42,7 +41,9 @@ local PLASMA_STOPS = { { 0.00, { 20, 0, 10 } }, { 0.25, { 150, 20, 40 } }, { 0.5 local TUNNEL_STOPS = { { 0.00, { 20, 0, 10 } }, { 0.30, { 150, 20, 40 } }, { 0.55, { 255, 150, 30 } }, { 0.80, { 255, 230, 140 } }, { 1.00, { 255, 255, 255 } } } local DISCO = { { 255, 40, 80 }, { 255, 160, 20 }, { 255, 240, 60 }, { 60, 255, 120 }, { 40, 200, 255 }, { 160, 60, 255 } } -local sync = K.load(DIR, W, H) +local sync = K.load(DIR) +local W = K.W -- The screen in design units: 480 tall (kit.lua) +local H = K.H local art = {} local world = {} local lastT = 0 @@ -55,12 +56,15 @@ local PARTY_TEXT = "GREETINGS TO EVERYONE ON THE DANCE FLOOR ... KEEP IT FUNK -- Loading -------------------------------------------------------------------------------------------- local function loadArt() - for _, name in ipairs({ "logo", "singe", "twister", "copper", "bob", "glow", "sparkle" }) do + for _, name in ipairs({ "twister", "copper", "glow", "sparkle" }) do art[name] = spriteLoad(DIR .. "art/" .. name .. ".png") end + for _, name in ipairs({ "logo", "singe", "bob" }) do + art[name] = K.sprite(DIR .. "art/" .. name .. ".png", ART_DENSITY) + end spriteQuality(art.logo, RENDER_SMOOTH) spriteQuality(art.singe, RENDER_SMOOTH) - art.font = fontLoad("Singe/FreeSansBold.ttf", 44) + art.font = K.font(44) -- The plasma and the tunnel, worked out live in Lua a frame at a time. art.plasma = K.plasmaNew(FX_W, FX_H, PLASMA_STOPS) art.tunnel = K.tunnelNew(FX_W, FX_H, TUNNEL_STOPS) @@ -297,14 +301,14 @@ local function buildScreens() materialSetDoubleSided(roto, true) materialSetFilter(roto, FILTER_NEAREST) world.rotoMaterial = roto - world.roto = K.screenQuad(world.camera, FOV, roto, 2, 2.2) + world.roto = K.screenQuad(world.camera, FOV, roto, 2, true) local tunnel = materialNew() world.tunnelMaterial = tunnel materialSetTexture(tunnel, art.tunnel.sprite) materialSetUnlit(tunnel, true) materialSetDoubleSided(tunnel, true) - world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, 1.8) + world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 60, true) world.kaleido = nodeNew(world.camera) world.kaleidoMaterials = {} nodeSetPosition(world.kaleido, 0, 0, -2) @@ -512,6 +516,7 @@ local function buildWorld() world.camera = nodeNew() cameraSet(world.camera) cameraSetPerspective(FOV, 0.1, 400) + K.screen(world.camera, FOV, 400) buildFloor() buildBall() buildLights() @@ -739,18 +744,15 @@ end -- 2D effects -------------------------------------------------------------------------------------------- --- Copper bars: count bars swinging up and down, coloured by hue, each a gradient strip stretched. +-- Copper bars: count bars swinging up and down, coloured by hue, each a gradient strip stretched +-- across the screen under the overlay. local function copperBars(t, count, y0, amp, hueShift, alpha) for i = 1, count do local y = y0 + math.sin(t * 2.1 + i * 0.55) * amp local r, g, b = K.hue(i / count * 0.6 + hueShift) - spriteSetTint(art.copper, r, g, b) - spriteSetAlpha(art.copper, alpha or 255) - spriteDraw(art.copper, 0, math.floor(y), W - 1, math.floor(y) + 23) + K.under(art.copper, 0, y, W, y + 24, r, g, b, alpha) end - spriteSetTint(art.copper, 255, 255, 255) - spriteSetAlpha(art.copper, 255) end @@ -774,7 +776,7 @@ local function twister(t, cx) local shade = math.floor(80 + 175 * math.sin(math.min(math.pi, (x[e + 1] - x[e]) / (2 * TWIST_R) * math.pi * 0.72))) spriteSetTint(art.twister, shade, shade, shade) - spriteDrawGrid(art.twister, math.floor(x[e]), y, e * faceW, row, faceW, 1, math.floor(x[e + 1]), y + TWIST_ROW - 1) + K.drawGrid(art.twister, x[e], y, e * faceW, row, faceW, 1, x[e + 1], y + TWIST_ROW) end end end @@ -1017,16 +1019,16 @@ local SCENES = { hideAll() end, draw = function(t, bar, k) - K.flash(0, 0, 0, 255) + K.fill(0, 0, 0) local gate = K.clamp((K.env("level", t) - 0.15) * 2.5, 0, 1) copperBars(t, 10, 210, 170, t * 0.05, math.floor(255 * gate)) titleDrop(t, K.beat(K.barTime(2)), 2, 230) if bar >= 7 then - local w = spriteGetWidth(art.singe) * 0.5 + local w = K.width(art.singe) * 0.5 spriteSetAlpha(art.singe, math.floor(255 * K.smooth(7, 7.5, bar))) - spriteDraw(art.singe, math.floor(W / 2 - w / 2), 330, math.floor(W / 2 + w / 2), 330 + spriteGetHeight(art.singe) // 2) + K.draw(art.singe, W / 2 - w / 2, 330, W / 2 + w / 2, 330 + K.height(art.singe) / 2) spriteSetAlpha(art.singe, 255) K.text(W / 2, 312, "PRESENTED WITH", 18, 200, 180, 140, "center") end @@ -1053,11 +1055,11 @@ local SCENES = { floor(t, 0, 0.3 + 0.7 * drop) if bar >= 13 then local a = math.floor(255 * K.smooth(13, 13.5, bar)) - local w = spriteGetWidth(art.logo) * 0.85 - local h = spriteGetHeight(art.logo) * 0.85 + local w = K.width(art.logo) * 0.85 + local h = K.height(art.logo) * 0.85 spriteSetAlpha(art.logo, a) - spriteDraw(art.logo, math.floor(W / 2 - w / 2), math.floor(H - 20 - h), math.floor(W / 2 + w / 2), H - 20) + K.draw(art.logo, W / 2 - w / 2, H - 20 - h, W / 2 + w / 2, H - 20) spriteSetAlpha(art.logo, 255) end K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(9, 9.25, bar)))) @@ -1144,7 +1146,7 @@ local SCENES = { hideAll() end, draw = function(t, bar, k) - K.flash(14, 4, 22, 255) + K.fill(14, 4, 22) copperBars(t, 14, 220, 200, t * 0.03, 200) twister(t, W / 2 + math.sin(t * 0.7) * 120) K.scroller(t, art.font, SECOND_TEXT, 30, 14, K.barTime(41), K.barTime(49)) @@ -1249,7 +1251,7 @@ local SCENES = { end, draw = function(t, bar, k) K.plasmaUpdate(art.plasma, t) - spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1) + K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true) local punch = 1 + 0.3 * K.decay("kicks", t, 8) for i = 1, 12 do @@ -1258,16 +1260,12 @@ local SCENES = { local cr, cg, cb = K.hue(i / 12 + t * 0.1) spriteSetTint(art.bob, cr, cg, cb) - spriteDraw(art.bob, math.floor(W / 2 + math.cos(a) * r - 18), math.floor(H / 2 + math.sin(a) * r * 0.8 - 18), math.floor(W / 2 + math.cos(a) * r + 18), math.floor(H / 2 + math.sin(a) * r * 0.8 + 18)) + K.draw(art.bob, W / 2 + math.cos(a) * r - 18, H / 2 + math.sin(a) * r * 0.8 - 18, W / 2 + math.cos(a) * r + 18, H / 2 + math.sin(a) * r * 0.8 + 18) end spriteSetTint(art.bob, 255, 255, 255) - spriteRotateAndScale(art.singe, math.sin(t * 0.9) * 35, (1 + 0.4 * math.sin(t * 1.1)) * punch) - spriteDraw(art.singe, W // 2, H // 2, true) + K.turn(art.singe, W / 2, H / 2, math.sin(t * 0.9) * 35, (1 + 0.4 * math.sin(t * 1.1)) * punch) K.flash(255, 255, 255, math.floor(255 * (1 - K.smooth(73, 73.2, bar)))) end, - leave = function(t) - spriteRotateAndScale(art.singe, 0, 1) - end, }, -- Bars 81 to 88: the breakdown. The kick is gone; the club is dark but for the ball and its -- sparkles, turning slowly. When the bass returns at bar 85 the floor comes back a tile a beat. diff --git a/demos/neonCoast/art/bob.png b/demos/neonCoast/art/bob.png index 76b5f0234..54a9e4654 100644 --- a/demos/neonCoast/art/bob.png +++ b/demos/neonCoast/art/bob.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ace021ff6910172a706b7dc675d34841f7fb9f6356eba68d1c3fbd36da345ca0 -size 2708 +oid sha256:b3033e104d7025d61f5c64e2ea99bc977257318b29d59c3c33f125cce7b6a6c2 +size 7405 diff --git a/demos/neonCoast/art/car.png b/demos/neonCoast/art/car.png index 6bdc78f06..d8b2b7ebf 100644 --- a/demos/neonCoast/art/car.png +++ b/demos/neonCoast/art/car.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28a35d963561579f0253e9fe69046fb77d7667aa25f876c10143f7b3649164b5 -size 167450 +oid sha256:f5f8c213b67a5be9fc6465d4cb853e8448e0c1356701844ff684a964712a7ae5 +size 504857 diff --git a/demos/neonCoast/art/lamp.png b/demos/neonCoast/art/lamp.png index abdeed631..6a9b0d787 100644 --- a/demos/neonCoast/art/lamp.png +++ b/demos/neonCoast/art/lamp.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a0e05567e543918df4b173502cc55399050b25a88062d30a67c583cc0f6bd945 -size 1586 +oid sha256:fb5d32d1987375045dcd36ed1f09b5f3fdfbca7eda17366805723b517ecf3b19 +size 3789 diff --git a/demos/neonCoast/art/logo.png b/demos/neonCoast/art/logo.png index f245c240d..a5ee69876 100644 --- a/demos/neonCoast/art/logo.png +++ b/demos/neonCoast/art/logo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0e78bff3ca3caa2fd1f9c2c728665c3847b0426a9abe026e0c2f46d2aab3692 -size 104947 +oid sha256:4c1ac2d8546fb707f6db68b11bc86a2b7bc94679e9810374a84868b3841445ce +size 316172 diff --git a/demos/neonCoast/art/palm.png b/demos/neonCoast/art/palm.png index 02ce52d86..5aa240c57 100644 --- a/demos/neonCoast/art/palm.png +++ b/demos/neonCoast/art/palm.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bea67a2902ccc17e91a45b69a2b957d02f2468237075ab457707717fb4f8e62b -size 17502 +oid sha256:08fe11d1bf666060193a433b105f836c94bfc49e314e7d7c9cf922287cc5ea7d +size 47082 diff --git a/demos/neonCoast/art/singe.png b/demos/neonCoast/art/singe.png index b4a2be2ee..07eb6ab6b 100644 --- a/demos/neonCoast/art/singe.png +++ b/demos/neonCoast/art/singe.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e978b3715a1c462ce33d43e8f67470311fb8a7500582f7c7b7427a60e8deeabe -size 68433 +oid sha256:b746e6fbc65641fb35a380881e2d47d10abac52bb5a1c3bfac26eec93bb9cfe2 +size 218721 diff --git a/demos/neonCoast/art/traffic.png b/demos/neonCoast/art/traffic.png index c8d30a161..024b8def9 100644 --- a/demos/neonCoast/art/traffic.png +++ b/demos/neonCoast/art/traffic.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53231247134f62fbe0ebfcceb1979eb2b8d52ae465248bc9920516c0cf34b439 -size 97497 +oid sha256:25aff5ec2d027d187cc3031478df8f4ae49a9e06cc481dec751c4ea4bc3b292b +size 266903 diff --git a/demos/neonCoast/games.dat b/demos/neonCoast/games.dat index 09b256392..87bc294a7 100644 --- a/demos/neonCoast/games.dat +++ b/demos/neonCoast/games.dat @@ -3,10 +3,10 @@ GAMES = { { TITLE = "Neon Coast", SCRIPT = "neonCoast/neonCoast.singe", - CANVAS_X = 640, - CANVAS_Y = 480, - RESOLUTION_X = 1024, - RESOLUTION_Y = 768, + CANVAS_X = 1920, + CANVAS_Y = 1080, + RESOLUTION_X = 1920, + RESOLUTION_Y = 1080, REQUIRES = { "SCENE", "POST" } } } diff --git a/demos/neonCoast/neonCoast.singe b/demos/neonCoast/neonCoast.singe index 8461e4d82..e4a34ab06 100644 --- a/demos/neonCoast/neonCoast.singe +++ b/demos/neonCoast/neonCoast.singe @@ -13,9 +13,10 @@ -- five kinds, a rotozoomed logo, and fireworks. Nothing on the screen is a recording. dofile("Singe/Framework.singe") local K = dofile(DIR .. "../kit.lua") +local sync = K.load(DIR) +local W = K.W -- The screen in design units: 480 tall (kit.lua) +local H = K.H -local W = 640 -local H = 480 local FOV = 62 -- The 3D camera's vertical field of view, in degrees local GRID_CELL = 4 -- The floor grid's cell in world units: the tiling the floor uses local FLOOR_SIZE = 600 @@ -34,7 +35,8 @@ local LAMP_EVERY = 8 -- Segments between streetlights: two p local SEGS_PER_BAR = LAMP_EVERY * 8 local PLAYER_AHEAD = 72 -- The car's distance ahead of the camera local CAR_FRAMES = 7 -- The car's sheet: steering from hard left to hard right -local CAR_SCALE = 0.85 +local CAR_SCALE = 0.85 -- The car's size: design units to a unit of its sheet as it was drawn for 640 by 480 +local ART_DENSITY = 2.25 -- The drawn and rendered art's pixels to a design unit (util/neonCoastArt.py, util/neonCoastCar.sh DENSITY) local TRAFFIC_FRAMES = 4 -- The traffic sheet: the same car in four other paints local TRAFFIC_WIDTH = 34 -- A car's width in world units: what our own car is on the road local TRAFFIC_LANE = 73 -- How far either side of the middle the outside lanes run @@ -61,7 +63,6 @@ local PEAK_FOG_FAR = 2700 -- Wholly fogged before the farthest tile local PEAK_HAZE = { 225, 40, 125 } -- The sky's colour just above the horizon: the far ridges melt into it local PEAK_SPEED = 240 -local sync = K.load(DIR, W, H) local beat = sync.beats[3] - sync.beats[2] local speed = LAMP_EVERY * SEG / (beat / 2) -- So two streetlights pass on every beat @@ -116,9 +117,12 @@ local NECK_SHARE = 0.35 -- How much of the head's nod the neck tak -- Loading -------------------------------------------------------------------------------------------- local function loadArt() - for _, name in ipairs({ "palm", "lamp", "road", "bob", "glow", "logo", "singe" }) do + for _, name in ipairs({ "road", "glow" }) do art[name] = spriteLoad(DIR .. "art/" .. name .. ".png") end + for _, name in ipairs({ "palm", "lamp", "bob", "logo", "singe" }) do + art[name] = K.sprite(DIR .. "art/" .. name .. ".png", ART_DENSITY) + end art.car = spriteLoadFrames(CAR_FRAMES, DIR .. "art/car.png") art.traffic = spriteLoad(DIR .. "art/traffic.png") spriteQuality(art.traffic, RENDER_SMOOTH) @@ -143,6 +147,7 @@ local function buildWorld() world.camera = nodeNew() cameraSet(world.camera) cameraSetPerspective(FOV, 0.1, 4000) + K.screen(world.camera, FOV, 4000) -- A dark glossy floor that reflects the sun, with the grid in it as light alone: a black base -- colour would tint the reflection black too. @@ -236,7 +241,7 @@ local function buildWorld() materialSetTexture(tunnel, art.tunnel.sprite) materialSetUnlit(tunnel, true) materialSetDoubleSided(tunnel, true) - world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 2, 1.7) + world.tunnel = K.screenQuad(world.camera, FOV, tunnel, 2, true) -- The flag on its pole, cloth that the kicks blow about. local pole = materialNew() @@ -582,13 +587,16 @@ local function drawRoad(t, position, bright, airY) local height = segment.sy1 - segment.sy2 if top <= bottom then - spriteDrawGrid(art.road, 0, top, 0, (2 + light) * HAZE + haze, STRIP_W, 1, W - 1, bottom) - for row = bottom, top, -1 do + K.drawGrid(art.road, 0, top, 0, (2 + light) * HAZE + haze, STRIP_W, 1, W, bottom + 1) + -- The road itself a screen row at a time, the window's rows rather than design + -- units, so its edges are as fine as the screen. + for real = math.floor((bottom + 1) * K.S) - 1, math.floor(top * K.S), -1 do + local row = real / K.S local k = (segment.sy1 - row) / height local cx = segment.sx1 + (segment.sx2 - segment.sx1) * k local half = ROAD_HALF * (s1 + (s2 - s1) * k) - spriteDrawGrid(art.road, math.floor(cx - half), row, 0, light * HAZE + haze, STRIP_W, 1, math.floor(cx + half), row) + spriteDrawGrid(art.road, math.floor((cx - half) * K.S), real, 0, light * HAZE + haze, STRIP_W, 1, math.floor((cx + half) * K.S), real) end clip = top end @@ -621,18 +629,18 @@ local function drawRoad(t, position, bright, airY) local top = foot - h if w >= 1 and cx + w > 0 and cx - w < W and top < segment.clip then - local left = math.floor(cx - w / 2) - local right = math.floor(cx + w / 2) + local left = cx - w / 2 + local right = cx + w / 2 if thing.width < 0 then left, right = right, left end if foot <= segment.clip then - spriteDraw(thing.sprite, math.min(left, right), math.floor(top), math.max(left, right), math.floor(foot) - 1) + K.draw(thing.sprite, math.min(left, right), top, math.max(left, right), foot) else local shown = math.min(sh, math.max(1, math.floor(sh * (segment.clip - top) / h))) - spriteDrawGrid(thing.sprite, math.min(left, right), math.floor(top), 0, 0, sw, shown, math.max(left, right), segment.clip - 1) + K.drawGrid(thing.sprite, math.min(left, right), top, 0, 0, sw, shown, math.max(left, right), segment.clip) end if thing.lamp and foot <= segment.clip + h * 0.9 then -- The lamp's light: a glow added over its head, the head being at the arm's end. @@ -642,7 +650,7 @@ local function drawRoad(t, position, bright, airY) spriteSetBlend(art.glow, SPRITE_ADD) spriteSetTint(art.glow, 255, 220, 170) spriteSetAlpha(art.glow, 255) - spriteDraw(art.glow, math.floor(headX - g / 2), math.floor(top + h * 0.07 - g / 2), math.floor(headX + g / 2), math.floor(top + h * 0.07 + g / 2)) + K.draw(art.glow, headX - g / 2, top + h * 0.07 - g / 2, headX + g / 2, top + h * 0.07 + g / 2) end end end @@ -659,15 +667,15 @@ local function drawRoad(t, position, bright, airY) if w >= 2 and cx + w > 0 and cx - w < W and top < segment.clip then local shown = math.min(sh, math.max(1, math.floor(sh * (math.min(foot, segment.clip) - top) / h))) - local bottom = math.min(foot, segment.clip) - 1 + local bottom = math.min(foot, segment.clip) - spriteDrawGrid(art.traffic, math.floor(cx - w / 2), math.floor(top), entry.car.paint * frame, 0, frame, shown, math.floor(cx + w / 2), math.floor(bottom)) + K.drawGrid(art.traffic, cx - w / 2, top, entry.car.paint * frame, 0, frame, shown, cx + w / 2, bottom) -- Its taillights, added over the road. if foot <= segment.clip then spriteSetBlend(art.glow, SPRITE_ADD) spriteSetTint(art.glow, 255, 30, 60) spriteSetAlpha(art.glow, 150) - spriteDraw(art.glow, math.floor(cx - w * 0.75), math.floor(top + h * 0.45), math.floor(cx + w * 0.75), math.floor(top + h * 0.95)) + K.draw(art.glow, cx - w * 0.75, top + h * 0.45, cx + w * 0.75, top + h * 0.95) spriteSetAlpha(art.glow, 255) end end @@ -689,15 +697,15 @@ local function drawCar(t, car, lift, spin) local cy = H - 64 - bounce - (lift or 0) spriteRotateFrame(art.car, lean, 4 - steer) - spriteDrawRotatedFrame(art.car, math.floor(cx), math.floor(cy), CAR_SCALE) + spriteDrawRotatedFrame(art.car, math.floor(cx * K.S), math.floor(cy * K.S), CAR_SCALE * K.S / ART_DENSITY) local glowW = 230 + 70 * K.decay("kicks", t, 6) spriteSetBlend(art.glow, SPRITE_ADD) spriteSetTint(art.glow, 255, 30, 60) spriteSetAlpha(art.glow, 170) - spriteDraw(art.glow, math.floor(cx - glowW / 2), math.floor(cy - 22), math.floor(cx + glowW / 2), math.floor(cy + 30)) + K.draw(art.glow, cx - glowW / 2, cy - 22, cx + glowW / 2, cy + 30) spriteSetTint(art.glow, 255, 40, 200) - spriteDraw(art.glow, math.floor(cx - 140), math.floor(cy + 30 + (lift or 0)), math.floor(cx + 140), math.floor(cy + 66 + (lift or 0))) + K.draw(art.glow, cx - 140, cy + 30 + (lift or 0), cx + 140, cy + 66 + (lift or 0)) spriteSetBlend(art.glow, SPRITE_ALPHA) spriteSetTint(art.glow, 255, 255, 255) spriteSetAlpha(art.glow, 255) @@ -805,13 +813,13 @@ end -- new burst never recolours the sparks of the last, with a white crackle at its heart. local function fireworks(t, lastT) if K.hitCount("snares", lastT, t) > 0 then - local x = math.random(80, W - 80) + local x = math.random(80, math.floor(W - 80)) local y = math.random(60, H / 2 - 40) art.nextFirework = art.nextFirework % #art.fireworks + 1 - emitterSetPosition(art.fireworks[art.nextFirework], x, y) + emitterSetPosition(art.fireworks[art.nextFirework], x * K.S, y * K.S) emitterBurst(art.fireworks[art.nextFirework], 140) - emitterSetPosition(art.crackle, x, y) + emitterSetPosition(art.crackle, x * K.S, y * K.S) emitterBurst(art.crackle, 30) end for _, e in ipairs(art.fireworks) do @@ -833,10 +841,11 @@ local SCENES = { end, draw = function(t, bar, k) K.snowUpdate(art.snow, t) - spriteDraw(art.snow.sprite, 0, 0, W - 1, H - 1) + K.under(art.snow.sprite, 0, 0, W, H, nil, nil, nil, nil, true) local dark = 1 - 0.75 * K.smooth(1.0, 2.0, bar) + 0.75 * K.smooth(3.5, 3.95, bar) - K.flash(0, 0, 0, math.floor(255 * K.clamp(dark, 0, 1))) + -- The snow dims, under the deck's lettering. + K.under(nil, 0, 0, W, H, 0, 0, 0, math.floor(255 * K.clamp(dark, 0, 1))) local blink = (math.floor(t * 2) % 2 == 0) K.text(40, 34, "PLAY", 30, 255, 255, 255) @@ -882,16 +891,18 @@ local SCENES = { -- The title slams in on the downbeat and settles; a shine runs across it each bar. local p = (bar - 13) local scale = 1 + 2.5 * math.exp(-p * 9) - local lw = spriteGetWidth(art.logo) * 0.8 * scale - local lh = spriteGetHeight(art.logo) * 0.8 * scale + local lw = K.width(art.logo) * 0.8 * scale + local lh = K.height(art.logo) * 0.8 * scale local y = 395 spriteSetAlpha(art.logo, math.floor(255 * K.clamp(p * 6, 0, 1))) - spriteDraw(art.logo, math.floor(W / 2 - lw / 2), math.floor(y - lh / 2), math.floor(W / 2 + lw / 2), math.floor(y + lh / 2)) + K.draw(art.logo, W / 2 - lw / 2, y - lh / 2, W / 2 + lw / 2, y + lh / 2) spriteSetAlpha(art.logo, 255) -- GAME ENGINE under the logotype, wherever the logotype is on the screen, spelled out -- a letter at a time across the first beat. local _, under = sceneProject(0, 2.6 - LOGO_DROP, -16) + + under = under / K.S local letters = math.floor(K.clamp(p * 4 * #ENGINE_TEXT, 0, #ENGINE_TEXT)) if letters > 0 then @@ -1062,9 +1073,7 @@ local SCENES = { cityRun(K.barTime(39) + road.tape, K.barTime(39)) if rate < 0 then K.snowUpdate(art.snow, t) - spriteSetAlpha(art.snow.sprite, REWIND_SNOW) - spriteDraw(art.snow.sprite, 0, 0, W - 1, H - 1) - spriteSetAlpha(art.snow.sprite, 255) + K.under(art.snow.sprite, 0, 0, W, H, 255, 255, 255, REWIND_SNOW, true) label = "<< REW" elseif resumed then label = "PLAY >" @@ -1145,7 +1154,7 @@ local SCENES = { end, draw = function(t, bar, k) K.plasmaUpdate(art.plasma, t) - spriteDraw(art.plasma.sprite, 0, 0, W - 1, H - 1) + K.under(art.plasma.sprite, 0, 0, W, H, nil, nil, nil, nil, true) local punch = 1 + 0.25 * K.decay("kicks", t, 8) for i = 1, 8 do @@ -1154,14 +1163,12 @@ local SCENES = { local px = W / 2 + math.cos(math.rad(a)) * r local py = H / 2 + math.sin(math.rad(a)) * r - spriteRotateAndScale(art.palm, a + 90, 0.55 * punch) - spriteDraw(art.palm, math.floor(px), math.floor(py), true) + K.turn(art.palm, px, py, a + 90, 0.55 * punch) end local scale = (0.9 + 0.35 * math.sin(t * 1.3)) * punch local angle = math.sin(t * 0.7) * 25 - spriteRotateAndScale(art.singe, angle, scale) - spriteDraw(art.singe, W // 2, H // 2, true) + K.turn(art.singe, W / 2, H / 2, angle, scale) K.circleScroller(t, art.smallFont, CIRCLE_TEXT, W / 2, H / 2, 190, 50, K.barTime(61), K.barTime(65)) K.strobe(t, "kicks", 12, 255, 255, 255, 70) local intro = 1 - K.smooth(61, 61.15, bar) @@ -1170,10 +1177,6 @@ local SCENES = { K.flash(255, 255, 255, math.floor(255 * intro)) end end, - leave = function(t) - spriteRotateAndScale(art.palm, 0, 1) - spriteRotateAndScale(art.singe, 0, 1) - end, }, -- Bars 65 to 68: low and fast over neon mountains toward the sun. { @@ -1340,20 +1343,20 @@ local SCENES = { draw = function(t, bar, k) local p = t - K.barTime(95) - K.flash(0, 0, 0, 255) + K.fill(0, 0, 0) if p < 0.6 then local hgt = (p < 0.25) and K.lerp(H, 3, K.easeIn(p / 0.25)) or 3 local wid = (p < 0.25) and W or K.lerp(W, 2, K.easeIn((p - 0.25) / 0.3)) - K.box(W / 2 - wid / 2, H / 2 - hgt / 2, W / 2 + wid / 2, H / 2 + hgt / 2, 255, 240, 255, 255) + K.under(nil, W / 2 - wid / 2, H / 2 - hgt / 2, W / 2 + wid / 2, H / 2 + hgt / 2, 255, 240, 255, 255) end end, }, } loadArt() -art.scrollFont = fontLoad("Singe/FreeSansBold.ttf", 44) -art.smallFont = fontLoad("Singe/FreeSansBold.ttf", 30) +art.scrollFont = K.font(44) +art.smallFont = K.font(30) art.fireworks = {} art.nextFirework = 0 for _, c in ipairs(FIREWORK_COLOURS) do @@ -1362,12 +1365,12 @@ for _, c in ipairs(FIREWORK_COLOURS) do emitterSetBlend(e, PARTICLE_ADD) emitterSetRate(e, 0) emitterSetLife(e, 0.8, 1.6) - emitterSetSpeed(e, 80, 260) + emitterSetSpeed(e, 80 * K.S, 260 * K.S) emitterSetSpread(e, 180) - emitterSetGravity(e, 0, 160) + emitterSetGravity(e, 0, 160 * K.S) emitterSetDrag(e, 1.2) - emitterSetSize(e, 8, 2) - emitterSetTrail(e, 6, 3) + emitterSetSize(e, 8 * K.S, 2 * K.S) + emitterSetTrail(e, 6, 3 * K.S) emitterSetColor(e, c[1], c[2], c[3], 255, c[4], c[5], c[6], 0) emitterSetMax(e, 800) emitterStart(e) @@ -1377,9 +1380,9 @@ art.crackle = emitterNew() emitterSetBlend(art.crackle, PARTICLE_ADD) emitterSetRate(art.crackle, 0) emitterSetLife(art.crackle, 0.2, 0.5) -emitterSetSpeed(art.crackle, 20, 90) +emitterSetSpeed(art.crackle, 20 * K.S, 90 * K.S) emitterSetSpread(art.crackle, 180) -emitterSetSize(art.crackle, 5, 1) +emitterSetSize(art.crackle, 5 * K.S, 1 * K.S) emitterSetColor(art.crackle, 255, 255, 255, 255, 255, 240, 200, 0) emitterSetMax(art.crackle, 300) emitterStart(art.crackle) diff --git a/docs/Forge.adoc b/docs/Forge.adoc deleted file mode 100644 index 0ba05ff8d..000000000 --- a/docs/Forge.adoc +++ /dev/null @@ -1,1928 +0,0 @@ -= Forge -Scott Duensing -:revnumber: 3.00 -:revdate: 2026 -:doctype: book -:toc: left -:toclevels: 3 -:sectnums: -:sectnumlevels: 3 -:title-logo-image: image:singeLogo.png[Singe, pdfwidth=2.6in, align=center] -:source-highlighter: rouge -:icons: font -:experimental: -:imagesdir: images - -[preface] -== About Forge - -Forge is Singe's authoring tool: a way to make a game by describing it -- -placing things, attaching behaviours, writing rules -- and having ordinary -Singe Lua written for you. It is itself a Singe game and is distributed on -its own, beside the engine rather than inside it. This document covers the -description format, the vocabulary, the compiler, and the editor. The engine -calls the generated code makes (`playerNew`, `collidePointRect`, -`onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe -Reference. - -== Tutorials - -Ten sittings, each ending with a game that plays, each needing only the one -before it. They use the *kit* -- pictures, sounds, a clip, and a model -- -that travels inside `Forge.game` and is named from a game as -`Forge/kit/hero.png`; `Forge/kit/README.txt` lists it. Every tutorial has a -scene in `testScripts/` that presses the same keys the text names and checks -the result against the description the tutorial ends with, which ships in -`Forge/tutorials/` and appears in the chooser, so a reader who loses the -thread can open the finished game and compare. The pictures come from those -scenes too. - -=== Tutorial 1: Ten Minutes - -image::tutorials/01-playing.png[The starter game, playing, 600] - -**What you will make.** Nothing new -- the game Forge starts you with, played, -changed, saved, and played again. Ten minutes, and you know where everything -is. - -**What you need.** Forge, started from the Singe menu. No files. - -**Steps.** - -. Start Forge. It opens on the *chooser*: a list of the descriptions in - its data directory (none yet), the samples and tutorials that travel inside - `Forge.game`, and *New game* at the bottom. The line under the list says - where `Forge.pdf` -- this book -- was put, the first time Forge ran. -+ -image::tutorials/01-chooser.png[The chooser, 600] - -. Press kbd:[Down] until *New game* is lit, then kbd:[Enter]. Forge writes a - starter description into its data directory and opens it. On the left is - the *panel*; everything to its right is the *canvas*, which is the game's - own screen at the game's own coordinates. A hero stands on a strip of - ground, and a readout sits in the corner. -+ -image::tutorials/01-entities.png[The entities panel, 600] - -. The panel is on *Entities* -- the things placed in this room, each named - `id (type)`. Under the list are the fields of whatever is selected; with - nothing selected, the room's own. The last line of that box is Forge's - *message*: it says what just happened, and while you type, what you are - typing. - -. Press kbd:[Tab]. The panel turns to *Types*: what an entity can be. The - starter has three -- `ground`, `hero`, `readout` -- each with a small - picture of its look. Below are the lit type's fields: its look, the - look's size and colour, its vars, and its behaviours. -+ -image::tutorials/01-types.png[The types panel, 600] - -. Press kbd:[Tab] again for *Rules*, the sheet that says what happens. Each - rule is a line; the lit one is opened to show what it is *on*, *when* it - applies, and what it does *then*. Two more kbd:[Tab]s pass *Tracks* and - *Dialogues*, empty for now, and come back round to *Entities*. -+ -image::tutorials/01-rules.png[The rules panel, 600] - -. Back on *Entities*, press the hero on the canvas, or its row in the list. - It gets a yellow frame, and the box under the list shows its fields: - `id`, `type`, `x`, `y`, and the rest. Press kbd:[Enter]. The message - says *type a value for id*, and the id shows selected in its row. Press - kbd:[Enter] again until it is *x* that is selected -- or click the `x` - row -- type `200`, and press kbd:[Enter]; the hero moves as you type. - Then kbd:[Esc] to leave the form. A field passed over with nothing typed - keeps its value, and the arrows put a caret into a value to change part - of it. - -. kbd:[Tab] to *Types*, kbd:[Down] to `hero` (or click it), and - kbd:[Enter] through the form the same way: `look.r` to `90`, `look.b` to `240` -- the hero turns - blue as you type -- and `platformer.speed` to `260`. kbd:[Esc] when the - last is in. -+ -image::tutorials/01-recoloured.png[The hero recoloured, 600] - -. Press kbd:[S]. The star after the title goes: the description is saved, - as a file you could open in a text editor. - -. Press kbd:[P]. The game is built and played: kbd:[Left] and kbd:[Right] - run, kbd:[Space] jumps, and it is a little faster than it was. kbd:[Esc] - ends the game, and Forge comes back on the same description. - -**What happened.** A game in Forge is a *description*: layers, types, rooms -of entities, and rules. The editor only edits that description, and -kbd:[P] compiles it into an ordinary Singe game and runs it. Nothing you saw -is special to the editor: the panels are the description's parts, the canvas -is the game's screen, and the file kbd:[S] wrote is the whole game. The -types and the rest are explained in -<<_describing_a_game_instead_of_writing_one>>; the keys are all in -<<_keys>>. - -**Try this.** - -* Make the hero jump higher: `platformer.jump` in the types panel. -* 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. 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::../forge/tutorials/01-tenMinutes.forge[] ----- - -=== Tutorial 2: A Platformer - -image::tutorials/02-playing.png[Coins, spikes, and a door, 600] - -**What you will make.** The starter game with something to do: coins to -collect, spikes that send the hero back, and a door to a second room. - -**What you need.** Tutorial 1, or its finished description -(`01-tenMinutes` in the chooser). From the kit: `coin.png`, `spike.png`, -`door.png`, `coin.wav`, and `hit.wav`. - -**Steps.** - -. Press kbd:[2] for the *Types* panel. (The digits kbd:[1] to kbd:[5] go - straight to a panel; kbd:[Tab] walks them in turn.) Press kbd:[A]: a - type called `thing` appears, lit, a plain grey box. - -. kbd:[Enter] opens its form on `name`. Type `coin`, kbd:[Enter]. The next - field is `look`: type `sprite`, kbd:[Enter]. The look's own fields - follow; kbd:[Enter] past `look.anchor` and `look.faces` to `look.file`, - which opens a list of the files under the game's directory and in the kit. - Type `coin` to narrow it, and kbd:[Enter] takes `Forge/kit/coin.png`. - kbd:[Enter] on to `behaviours`, type `trigger`, kbd:[Enter], then - kbd:[Esc]. The type's row now shows the coin. -+ -image::tutorials/02-coinType.png[The coin type, 600] - -. Press kbd:[1] for *Entities* and kbd:[A]: a coin of the lit type appears - in the middle of the canvas, selected. Drag it down to the ground. Press - kbd:[D] to duplicate it and drag the copy along; kbd:[D] again for a third, - dragged up where a jump will reach it. -+ -image::tutorials/02-coins.png[Three coins placed, 600] - -. The same way, make a type `spike` from `spike.png` and a type `door` from - `door.png`, each a `trigger`, and each with `look.anchor` set to `feet` so - that it stands on the ground where it is placed rather than being centred - there. Place one of each near the right edge, the door beyond the spikes. -+ -image::tutorials/02-placed.png[Spikes and a door, 600] - -. Press kbd:[3] for *Rules* and kbd:[N]: a new rule, on `frame`. - kbd:[Enter] and type its `note`, `Take a coin`, kbd:[Enter], kbd:[Esc]. - Press kbd:[E] to choose what it is on: type `enter` and kbd:[Enter]. - The rule's fields now include `a` and `b`, the trigger and what entered - it: kbd:[Enter] to `a`, type `coin`; kbd:[Enter] to `b`, type `hero`; - kbd:[Enter], kbd:[Esc]. - -. Press kbd:[T] for an action: type `addScore`, kbd:[Enter]. It appears - under the rule, lit, and kbd:[Enter] opens its `amount`: type `10`, - kbd:[Enter], kbd:[Esc]. kbd:[T] again for `destroy`; its `entity` is - already `self`, which in an `enter` rule is the trigger -- the coin. - kbd:[T] once more for `playSound`; kbd:[Enter] opens the file list, type - `coin.wav`, kbd:[Enter]. -+ -image::tutorials/02-rule.png[The coin rule, 600] - -. Two more rules the same way. `Spikes hurt`, on `enter` with `a` `spike` - and `b` `hero`: a `moveTo` with `entity` `hero`, `x` `200`, and `y` `380`, - and a `playSound` of `hit.wav`. `Through the door`, on `enter` with `a` - `door` and `b` `hero`: a `goTo` with `room` `cave`, `entity` `hero`, `x` - `60`, and `y` `380`. - -. The cave does not exist yet. Press kbd:[1] and then kbd:[N]: a second - room, empty, and the panel shows it (`room 2 of 2`). With nothing - selected -- click the game's name at the top of the panel, or an empty - spot on the canvas -- the form is the game's and then the room's: - kbd:[Enter] to `name`, type `cave`, - kbd:[Enter], kbd:[Esc]. Press kbd:[2], light `ground` with the arrows, - kbd:[1], kbd:[A], and drag it to the bottom; the same for a `readout` in - the corner and a `coin`. kbd:[PgUp] and kbd:[PgDn] move between rooms. -+ -image::tutorials/02-cave.png[The cave, 600] - -. kbd:[S], then kbd:[P]. Run into a coin: it goes, the score climbs, and - the clip plays. Run into the spikes and you are back at the start. Reach - the door and you are in the cave. - -**What happened.** A *type* is what a thing is: a look and a list of -behaviours. An *entity* is one placed in a room. The `trigger` behaviour -gives an instance a volume that reports what enters and leaves it, and the -`enter` *event* is what a rule listens for; its `a` and `b` say which two -types, and inside the rule `self` is the trigger and `other` is what came in. -Rooms are separate screens with their own entities, and `goTo` takes an -entity with it. Events and the rest are in <<_events>>; every behaviour and -action is in <<_the_vocabulary>>. - -**Try this.** - -* Make the spikes cost a point: an `addScore` of `-5` in the spike rule. -* Put a `door` in the cave that leads back, with a second `goTo`. -* Rename an entity in its form: the rules that name it change with it. - -**The description.** The rules tutorial 1 left are unchanged; what is new -is the three types, their entities, the three rules, and the second room. - -[source,lua] ----- -include::../forge/tutorials/02-platformer.forge[] ----- - -=== Tutorial 3: Sprites and Sound - -image::tutorials/03-playing.png[The hero from the sheet, 600] - -**What you will make.** The same game, with a hero drawn from a sprite -sheet who faces the way he runs, coins that spin, a jump that sounds, and -music. - -**What you need.** Tutorial 2. From the kit: `hero.png`, `jump.wav`, and -`loop.ogg`. - -**Steps.** - -. kbd:[2] for *Types*, and light `hero` with the arrows. kbd:[Enter] to - `look`, type `sprite`, kbd:[Enter]; on to `look.file`, and take - `Forge/kit/hero.png` from the list; on to `look.frames`, type `8`. The - sheet is eight columns: standing, four of walking, a jump, a fall, and a - hit, all facing right. The hero on the canvas is now the first of them. - -. kbd:[Enter] on to `behaviours`. It says `platformer`; type - `platformer, frames` -- a comma list, and a behaviour already there keeps - its values. Two new fields follow the platformer's: `frames.fps`, type - `10`; and `frames.states`, type `idle=1-1, walk=2-5, jump=6-6, fall=7-7` - -- each state's run of frames. kbd:[Enter], kbd:[Esc]. -+ -image::tutorials/03-heroType.png[The hero type, 600] - -. Light `coin`. Its `behaviours` become `trigger, spin`, and `spin.rate` - `180` -- degrees a second. -+ -image::tutorials/03-coinSpin.png[Coins that spin, 600] - -. kbd:[3] for *Rules*. kbd:[Down] to rule 3, `Jump, with ground - underfoot`, and once more onto its first part, `keyHeld`. Press - kbd:[Delete]: the part goes. kbd:[C] for a condition: type - `keyPressed`, kbd:[Enter]; kbd:[Enter] on its `key`, type `SPACE`, - kbd:[Enter], kbd:[Esc]. A press rather than a hold, so what comes next - happens once. kbd:[T], `playSound`, and `jump.wav` from the list. -+ -image::tutorials/03-jumpRule.png[The jump rule, 600] - -. kbd:[N] for a new rule, `note` `Music`. kbd:[E], and choose - `roomStart`: it fires as a room is entered. kbd:[T], `playMusic`, and - `loop.ogg`. -+ -image::tutorials/03-music.png[The music rule, 600] - -. kbd:[S], kbd:[P]. The hero walks, and turns to face the way he runs - though the sheet only has him facing right; he jumps and falls on the - frames for it; the coins turn; and the music loops. - -**What happened.** A `sprite` look with `frames` is a sheet of that many -columns, and the `frames` behaviour picks a column by the instance's *state* --- a var every instance has, which the `platformer` sets to `idle`, `walk`, -`jump`, or `fall` as it goes, and a rule can set to anything else with -`setState`. The platformer also sets the var `facing`, and a sprite look is -mirrored while it faces away from its art -- the look's `faces` says which -way that is, right unless told. `spin` turns the var `angle`, and a sprite -turns with it. Sound is an action, `playSound`, with a volume of its own if -you want one, and music is another; the `sound` behaviour plays a clip per -event instead, which suits a type that is hit often. Sheets, facing, and -states are in <<_instances_vars_and_states>> and -<<_the_long_tail_sprite_sheets_tiles_midi_water_soft_bodies>>. - -**Try this.** - -* Show the hit frame: in the spike rule, a `setState` of `hit` on the hero, - and a `wait` of `0.5` before the `moveTo`. Actions that wait run as a - sequence. -* Make the coins face the other way: `look.faces` `left` on the coin does - nothing, since a coin has no facing, but on the hero it mirrors everything. -* Turn the door on the canvas with its yellow handle, or type its `rz`. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/03-sprites.forge[] ----- - -=== Tutorial 4: A Shoot-Em-Up - -image::tutorials/04-playing.png[Rocks, 600] - -**What you will make.** A game from nothing: a ship on the arrow keys that -fires, rocks that fall from spawners and break up, lives, and a game over. - -**What you need.** Nothing but the kit: `ship.png`, `shot.png`, -`rock.png`, `shot.wav`, and `hit.wav`. - -**Steps.** - -. Open *New game* and clear the starter. kbd:[1], light `hero`, - kbd:[Delete]; the same for `ground`. kbd:[2], light `hero`, kbd:[Delete], - and `ground` -- a type can only go once nothing is placed of it. kbd:[3], - and kbd:[Delete] three times: the running and jumping rules go, leaving - the readout's. - -. kbd:[1], and with nothing selected the form is the game's own, then the - room's. kbd:[Enter] on `title`, type `Rocks`; on `vars`, type - `score=0, lives=3`; on `layers`, type `overlay`. The starter's `world2d` - layer goes with its gravity: nothing falls unless a behaviour says so. -+ -image::tutorials/04-cleared.png[The game's form, 600] - -. kbd:[2], kbd:[A], and the ship: `name` `ship`, `look` `sprite`, - `look.file` `ship.png`, `vars` `health=1`, and `behaviours` - `keys, mover, shooter, health, sound`. Then each behaviour's fields: - `keys.left` `LEFT`, `keys.right` `RIGHT`, `keys.up` `UP`, `keys.down` - `DOWN`, `keys.fire` `SPACE`; `mover.speed` `260` and `mover.clamp` `true`; - `shooter.spawns` `shot`, `shooter.rate` `0.15`, `shooter.offsetY` `-14`; - `health.max` `1` and `health.keep` `true`; `sound.hit` `hit.wav`. A file - field passed with kbd:[Enter] keeps what it has, `(none)` included. -+ -image::tutorials/04-ship.png[The ship type, 600] - -. Three more types. `shot`: a sprite of `shot.png`, behaviours - `projectile, sound`, `projectile.vy` `-520`, `projectile.life` `3`, and - `sound.spawn` `shot.wav`. `rock`: `rock.png`, vars `health=2`, - behaviours `drift, health, sound`, `drift.vy` `90`, `health.max` `2`, - `sound.death` `hit.wav`. `hive`: a look of `none` and a `spawner` with - `spawner.spawns` `rock`, `spawner.every` `0.6`, `spawner.max` `6`, and - `spawner.total` `12`. -+ -image::tutorials/04-types.png[The types, 600] - -. Place them: a `ship` at the bottom, and two `hive`s just above the top - edge, one over the ship. A `none` look is drawn on the canvas as an empty - box, so it can still be picked up. -+ -image::tutorials/04-placed.png[Placed, 600] - -. kbd:[3], and the rules. `A shot lands on a rock`, on `collision` with `a` - `shot` and `b` `rock`: a `destroy` (of `self`, the shot), a `damage` with - `entity` `other` and `amount` `1`, and an `addScore` of `10`. `A rock - dies`, on `death` with `type` `rock`: an `emit` with `count` `24` and a - colour of `255`, `120`, `60`, and an `addScore` of `50`. -+ -image::tutorials/04-hitRule.png[The hit rules, 600] - -. `A rock rams the ship`, on `collision` with `a` `rock` and `b` `ship`: - `destroy`, and `damage` of `other` by `1`. `The ship is lost`, on `death` - with `type` `ship`: `addVar` of `lives` by `-1`, a `flash` of `255`, - `60`, `60` for `0.3` seconds, a `setVar` of `health` to `1`, and a `moveTo` - back to `360`, `420` -- the ship keeps itself through its death, since - `health.keep` is set. `Out of lives`, on `frame`, when a `test` of - `lives <= 0`: `gameOver`. `A rock slips past the bottom`, on `frame` - with `each` `rock`, when a `test` of `self.y > 500`: `destroy`. And the - readout's `text` becomes `"score " .. score .. " lives " .. lives`. -+ -image::tutorials/04-rules.png[The rules, 600] - -. kbd:[S], kbd:[P]. Fly, fire, and the rocks break up in sparks. Let three - through onto the ship and the game is over; kbd:[Esc] brings Forge back. - -**What happened.** Without a `world2d` layer there is no physics, and the -behaviours move things themselves: `keys` reads a player's keys into the -vars `dx`, `dy`, and `fire`, `mover` moves by them, `shooter` spawns a type -while `fire` is set, `drift` moves steadily, and `projectile` flies until -it leaves the picture. `collision` still fires, from the boxes overlapping. -`health` gives an instance the var `health` and raises `death` at zero; -`spawner` makes a type every so often up to a limit. A rule with `each` -runs once per instance of a type, with that instance as `self`, and a -`test` is any expression over the vars. Expressions are in -<<_expressions>>. - -**Try this.** - -* Give the rocks a `spin` behaviour. -* A second `shooter` on the ship, `offsetX` `-10` and `10`, for twin guns. -* A `hud` layer instead of the readout: `layers` `overlay, hud`, and a - `hud.document` of your own RmlUi page with elements named for the vars in - `hud.bind`. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/04-shmup.forge[] ----- - -=== Tutorial 5: A Quick-Time Event - -image::tutorials/05-playing.png[A window answered, 600] - -**What you will make.** A game over video: the kit's clip plays, and at -two moments in it a prompt wants a key. In time scores; too late costs a -life. The clip loops, and the game ends when the lives are gone. - -**What you need.** The kit's `clip.mkv`: twelve seconds in which a door -opens at three, a target rises at six, and a hand reaches in at nine, with a -clock along the bottom. At 24 frames a second, a moment's frame is its -second times 24. - -**Steps.** - -. Open *New game* and clear it as in tutorial 4: the hero and ground - entities, then their types, then the three running and jumping rules. - -. kbd:[1], nothing selected, and the game's form: `title` `Moments`, - `vars` `score=0, lives=3`, and `layers` `disc, overlay`. A `disc` is the - video a game is played over, and `disc.file` is now a field: kbd:[Enter] - opens the list, take `Forge/kit/clip.mkv`. The canvas shows the clip's - first frame. -+ -image::tutorials/05-disc.png[The disc layer, 600] - -. Two text types. kbd:[2], kbd:[A], `name` `prompt`, `look` `text`, and - a colour of `255`, `220`, `80`; kbd:[1], kbd:[A], and drag it near the - top. Then `verdict`, `text`, `160`, `255`, `180`, placed under it. -+ -image::tutorials/05-prompts.png[The prompts, 600] - -. kbd:[3], and three rules for the first window, frames `72` to `120`, - when the door opens. `Window one is open`, on `frame`, when - `discBetween` with `from` `72` and `to` `120`: a `setText` of `prompt` to - `"PRESS!"`. `Window one answered in time`, on `frame`, when - `discBetween` `72` to `120`, `keyPressed` with `key` `SPACE`, and `once` - with `tag` `one`: a `setVar` of `hitone` to `true`, an `addScore` of - `250`, and a `setText` of `verdict` to `"HIT"`. `Window one closed - unanswered`, on `frameReached` with `frame` `120`, when a `test` of - `not hitone`: an `addVar` of `lives` by `-1`, `verdict` to `"MISS"`, and - `prompt` to `""`. -+ -image::tutorials/05-window.png[The first window, 600] - -. The same three for the second window, `150` to `200`, where the target - rises, wanting `UP` and setting `hittwo`. Then `The clip runs out: - round again`, on `frameReached` at `280`: `hitone` and `hittwo` back to - `false`, `verdict` to `""`, and a `discTo` with `frame` `1`. `Out of - lives`, on `frame`, when `lives <= 0`: `gameOver`. The readout's `text` - becomes `"score " .. score .. " lives " .. lives`. -+ -image::tutorials/05-rules.png[The rules, 600] - -. kbd:[S], kbd:[P]. The door opens and the prompt appears; kbd:[Space] in - time is a hit. Miss the target's window and a life goes. - -**What happened.** A `disc` layer plays a video underneath everything, and -the rules read its *frame*: `discBetween` is true while the disc is inside -a window, `frameReached` fires as it passes a frame, and `discTo` sends it -elsewhere -- the whole of a branching laserdisc game is windows and jumps. `once` -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.forge` in the chooser is one. In a -released game, `games.dat` names the video, as it does for any Singe game. - -**Try this.** - -* A third window for the hand at `216` to `264`. -* Show the frame: the readout's text with `.. " frame " .. frame`. -* Make a miss send the disc somewhere: a `discTo` in the closed rule. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/05-qte.forge[] ----- - -=== Tutorial 6: A Light Gun Game - -image::tutorials/06-playing.png[Draw, 600] - -**What you will make.** A light gun shooting gallery in miniature: the clip plays, a -target rises, and the player shoots it with the mouse or a light gun. The -hand that reaches in is not to be shot. Ammo runs out and is reloaded by a -shot off the picture. - -**What you need.** The kit's `clip.mkv`, `hit.wav`, and `shot.wav`. - -**Steps.** - -. Open *New game*, clear it as before, and give the game's form `title` - `Draw`, `vars` `score=0, misses=0`, `layers` `disc, overlay`, and - `disc.file` `Forge/kit/clip.mkv`. - -. kbd:[2], and three types with a look of `none`. `gun`: `vars` `ammo=6`, - `behaviours` `gun`, `gun.player` `1`, `gun.ammo` `6`, `gun.reload` - `offscreen`. `target`: `vars` `health=1`, `behaviours` `hitbox, health`, - `hitbox.track` `target`, `health.max` `1`. `hand`: `behaviours` - `hitbox`, `hitbox.track` `hand`. Place one of each anywhere; a hitbox's - place comes from its track, not from where it stands. -+ -image::tutorials/06-types.png[The types, 600] - -. kbd:[4] for *Tracks*, and kbd:[N]: a new track, keyed by frame. - kbd:[Enter], `name` `target`, kbd:[Enter], kbd:[Esc]. Along the bottom of - the canvas is the *timeline* with its cursor: kbd:[.] and kbd:[,] step it - a frame, kbd:[\]] and kbd:[\[] leap ten, and the canvas shows the clip at - that frame. Take it to `150`, where the target has risen, and press - kbd:[K]: a key at that frame, with a box in the middle of the canvas. - kbd:[Enter] through the key's form: `x` `405`, `y` `165`, `w` `90`, `h` - `90` -- or drag the box over the target and pull its corner. -+ -image::tutorials/06-firstKey.png[The first key, 600] - -. Another key at `210`, the same box, so the target stays hittable while it - is up; between keys the box slides from one to the next, and outside them - there is none. kbd:[N] for a second track, `hand`, with keys at `220` and - `260` of `480`, `157`, `240`, `105`. -+ -image::tutorials/06-tracks.png[Two tracks, 600] - -. kbd:[3], and the rules. `The target is hit`, on `hit` with `type` - `target`: `addScore` `100`, `damage` of `self` by `1`, and `playSound` - `hit.wav`. `The hand is hit`, on `hit` with `type` `hand`: `addScore` - `-200` and a red `flash`. `A shot at nothing`, on `miss`, when a `test` of - `not event.offscreen`: `addVar` `misses` by `1`, `playSound` `shot.wav`. - `Draw!`, on `frameReached` at `150`: a `say` of `"DRAW!"` for `1` second. - The readout's `text`: `"score " .. score .. " ammo " .. gun.ammo .. " misses " .. misses`. -+ -image::tutorials/06-rules.png[The rules, 600] - -. kbd:[S], kbd:[P]. Shoot the target as it rises; shoot the hand and pay - for it; when the ammo is gone, shoot off the edge of the picture to - reload. - -**What happened.** A `gun` behaviour turns a player's pointer -- the mouse, -or a light gun, which is a mouse to the engine -- into shots: the trigger -raises `hit` on the instance under it, with `self` the thing hit and `other` -the gun, or `miss` with `event.offscreen` saying whether the shot left the -picture. A `hitbox` behaviour takes its shape from a *track* of boxes keyed -by disc frame, which is how a thing in a video can be shot only while it is -there: the boxes are where the thing is in the picture, and the editor -shows the frame under them. Tracks are in <<_tracks>>. What a cabinet -with a light gun needs -- calibration, two guns, the recoil -- is in -<<_arcade_plumbing_branching_video_lives_credits_the_board>>. - -**Try this.** - -* A second player: `players` `2` in the game's form, a second `gun` with - `gun.player` `2`, and `hit` rules with `player` set. -* Shoot the door as it opens: a third track, and a type with `health.max` - `3` that takes three hits. -* Score by where the shot landed: a `target` behaviour with `zones` for a - 3D model is in tutorial 9; in 2D, two hitbox types on two tracks make a - bull and an outer ring. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/06-gun.forge[] ----- - -=== Tutorial 7: A Point-and-Click Adventure - -image::tutorials/07-playing.png[The yard, 600] - -**What you will make.** A room of the Sierra and LucasArts kind: a painted -yard, a hero who walks where you click, things to look at, take, open, and -talk to, a key that goes in the inventory, a guard with a conversation, and -a door to a second painted room. - -**What you need.** The kit's `yard.png`, `hall.png`, `hero.png`, `key.png`, -`door.png`, and `door.wav`. - -**Steps.** - -. Open *New game*, clear it, and fill the game's form: `title` - `The Yard`, `vars` `score=0, allowed=false`, `verbs` - `walk, look, take, use, open, talk`, and `layers` `overlay`. Then the - room's own fields, further down the same form: `name` `yard`, - `depthSort` `true`, and `scaleBy` `300:0.6, 460:1.0` -- things standing - at the back of the yard are drawn at six tenths, at the front full size, - and between in proportion. - -. kbd:[2], kbd:[A]: a type `backdrop`, a `sprite` of `yard.png`. kbd:[1], - kbd:[A], and it fills the canvas. Now the floor the hero may walk on. - Press kbd:[V]: a walk area is being drawn, the panel steps aside, and - every press on the canvas is a corner. Press the four corners of the - yard's floor -- left and right where the wall meets it, then the near - corners -- and kbd:[V] again to close it. A green outline shows it, and - the panel comes back. -+ -image::tutorials/07-walkArea.png[The walk area, 600] - -. The hero: a `sprite` of `hero.png` with `look.frames` `8` and - `look.anchor` `feet`, `behaviours` `walker, frames`, `walker.speed` `160`, - `frames.fps` `10`, and `frames.states` `idle=1-1, walk=2-5`. Place him at - the front left. A walker's state is `walk` or `idle` as it goes, and the - frames follow. - -. Three hotspots. `door`: `door.png`, `look.anchor` `feet`, `behaviours` - `hotspot`, `hotspot.name` `door`, and a place to walk to first, - `hotspot.walkX` `590` and `hotspot.walkY` `330`; place it in the doorway. - `key`: `key.png`, a hotspot named `key` with a walk point of `200`, - `400`, placed on the floor. `guard`: `hero.png` again, `frames` `8`, - `look.faces` `left` so he looks toward the hero, a hotspot `guard` at - `480`, `360`, placed by the door. -+ -image::tutorials/07-hotspots.png[The hotspots, 600] - -. kbd:[5] for *Dialogues*, and kbd:[A]: a dialogue called `talk` with one - node, `start`. kbd:[Enter], `name` `guard`. kbd:[Down] onto the node: - `who` `Guard`, `text` `Nobody passes.` kbd:[A] on a node adds a choice: - `text` `Why not?`, `next` `why`. kbd:[A] again: `Fine.`, with no `next`, - which ends the talk. kbd:[Up] to the dialogue's own row and kbd:[A] for - a second node: `name` `why`, `who` `Guard`, `text` - `The door is locked, and I have no key.` Its choices: `I have a key.` - with `when` `has("key")` and `next` `ok`, and kbd:[T] on that choice for - an action, `setVar` of `allowed` to `true` -- kbd:[Delete] on its `entity` - empties it, so the var is the game's; then `I see.` A third node `ok`: - `Then go ahead.` -+ -image::tutorials/07-dialogue.png[The dialogue, 600] - -. kbd:[3], and the rules. `A click on the floor walks there`, on `pressed` - with `switch` `SWITCH_BUTTON3` and `interrupt` `true`: a `walkToPointer` - of `hero` for `player` `1`. `Look at anything`, on `verb` with `verb` - `look`: a `say` of `"It is a " .. event.target .. "."` for `1` second. - `Take the key`, on `verb` `take` with `target` `key`: `walkToHotspot` of - `hero` (its `target` is `self`, the key), `give` `key`, `addScore` `1`, - `destroy`. `Talk to the guard`, on `verb` `talk` with `target` `guard`: - `walkToHotspot`, `face` of `hero` toward `self`, and `talk` with - `dialogue` `guard`. -+ -image::tutorials/07-verbRules.png[The verb rules, 600] - -. `The door, while the guard objects`, on `verb` `open` with `target` - `door`, when `not allowed`: `say` `"The guard shakes his head."`. - `The door opens`, the same event with `controls` `false`, when `allowed`: - `walkToHotspot`, `playSound` `door.wav`, a `fade` of `0.5` with `out` - `true`, a `goTo` of `hero` to `room` `hall` at `120`, `420`, a `fade` back - with `out` `false`, and `addScore` `1`. `Anything else`, on `verb` with - nothing filled in: `say` `"That does not work."`. Then the verb keys: - on `pressed` with `key` `L`, a `setVerb` of `look`; `T` for `take`, `O` - for `open`, `K` for `talk`. The readout's `text`: - `(sentence or "") .. " score " .. score`. - -. The hall. kbd:[1], kbd:[N] for a room, `name` `hall`, `depthSort` - `true`. Place a `backdrop` -- and, since the type's picture is the yard, - a second type `hall` from `hall.png` does better -- a walk area along its - floor, and a `readout`. kbd:[PgUp] goes back to the yard. -+ -image::tutorials/07-hall.png[The hall, 600] - -. kbd:[S], kbd:[P]. Click the floor and the hero walks; the sentence line - says what the pointer is over. kbd:[T] then a click on the key takes it. - kbd:[K] and the guard: with the key, the second choice opens, and the - guard relents. kbd:[O] and the door, and the yard fades to the hall. - -**What happened.** A room's *walk areas* become a navigation mesh, and a -`walker` finds its way across them; `depthSort` draws what is lower on the -screen in front, and `scaleBy` makes what is further up smaller, so a flat -picture reads as a floor. A `hotspot` is anything a *verb* can be used on: -the game's `verbs` are the sentence's first word, the pointer's hotspot its -second, and a click raises `verb` with both, the most specific rule winning --- `open` on `door` beats `open` on anything, and a rule with no `verb` at -all is the last resort. `give`, `take`, and `has` are the inventory; -`talk` runs a *dialogue*, a tree of what is said and what may be answered, -each choice with a `when` and actions of its own. Actions that take time --- `walkToHotspot`, `say`, `fade`, `talk` -- make a rule a *sequence* that -runs to its end while the rest of the game goes on, unless `controls` is -off. All of it is in -<<_adventures_rooms_walking_hotspots_verbs_and_talk>>. - -**Try this.** - -* A `use` rule: `verb` `use` with `item` `key` and `target` `door`, for - when the player tries the key on the lock. -* A parser: `layers` `overlay, parser`, and rules on `said` with a `verb` - and a `noun` -- the Sierra way, typed. -* A `saveGame` on a key, and a `loadGame` on another. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/07-adventure.forge[] ----- - -=== Tutorial 8: Into 3D - -image::tutorials/08-playing.png[The fox in the field, 600] - -**What you will make.** The platformer again, in a scene: a fox on the -engine's character controller, a floor and a ledge, a prize on the ledge, and -a camera that follows. - -**What you need.** The kit's `fox.glb` and `coin.wav`. - -**Steps.** - -. Open *New game*, clear it, and in the game's form set `title` `Field` - and `layers` `scene3d, overlay`. The canvas becomes the *viewport*: the - scene itself, built from the types' looks, under an editor camera. - `scene3d.fov` `55` narrows the view a little. -+ -image::tutorials/08-scene.png[A scene, 600] - -. kbd:[2], and three `mesh` types -- solid shapes with a colour. `floor`: - `look` `mesh`, `look.shape` `box`, `look.w` `30`, `look.h` `0.4`, - `look.d` `12`, a colour of `90`, `110`, `90`, and `behaviours` `solid`. - `ledge`: a box of `4`, `0.4`, `4` in `110`, `90`, `70`, also `solid`. - `prize`: `look.shape` `sphere`, `look.w` `0.6`, in `255`, `200`, `60`, - with `behaviours` `trigger`. Sizes are world units; a unit is about a - metre. -+ -image::tutorials/08-meshes.png[The meshes, 600] - -. The hero: `look` `model`, `look.file` `fox.glb`, `look.scale` `0.008` - (the fox is drawn in centimetres), and `look.w` `0.6`, `look.h` `0.9`, - `look.d` `0.6` for how big it counts as. `behaviours` - `keys, character, animator`: `keys.left` `LEFT` and so on for the four - arrows; `character.speed` `4`, `character.jump` `6.5`, - `character.radius` `0.3`, `character.height` `0.9`; `animator.idle` - `Survey` and `animator.walk` `Walk`, two of the fox's own clips. And a - `camera`: a look of `none`, `behaviours` `camera`, `camera.mode` - `follow`, `camera.target` `hero`, an offset of `camera.x` `0`, `camera.y` - `3`, `camera.z` `7`, `camera.lookY` `0.6`, and `camera.lag` `0.2`. -+ -image::tutorials/08-hero.png[The hero and the camera, 600] - -. Place them. kbd:[1], kbd:[A] puts an entity on the floor under the middle - of the viewport; its `x`, `y`, and `z` are typed, in units, to a tenth. - The `floor` at `0`, `-0.2`, `0`; the `ledge` at `6`, `1.2`, `0`; the - `prize` at `6`, `2`, `0`; the `hero` at `-4`, `0.5`, `0`; the `camera` - at `-4`, `3`, `7`. kbd:[J] and kbd:[L] orbit the editor camera round - what is selected, kbd:[Y] and kbd:[H] tilt it, kbd:[I] and kbd:[K] close - in and back off. An entity is picked by a press where it stands and - dragged across the floor; the selected one carries the *gizmo*, three - arms along the axes -- drag an arm's handle to move along that axis alone - -- and a yellow fourth that turns it. -+ -image::tutorials/08-viewport.png[The viewport, 600] - -. kbd:[3], and the rules. `Walking is a state the animator plays`, on - `frame` with `each` `hero`, when a `test` of - `self.dx != 0 or self.dy != 0`: a `setState` of `self` to `walk`. - `Standing still`, the same with `self.dx == 0 and self.dy == 0` and - `idle`. `Jump, with ground underfoot`, on `frame`, when `keyPressed` - `SPACE` and `onGround` of `hero`: `jump` `hero`. `The prize is taken`, - on `enter` with `a` `prize` and `b` `hero`: `destroy`, `addScore` `100`, - `playSound` `coin.wav`. `Fallen off the world`, on `frame`, when - `hero.y < -5`: a `moveTo` of `hero` back to `-4`, `0.5`, `0`. -+ -image::tutorials/08-rules.png[The rules, 600] - -. kbd:[S], kbd:[P]. The arrows walk the fox relative to the camera, which - follows; kbd:[Space] jumps it onto the ledge, and the prize goes. - -**What happened.** A `scene3d` layer makes the room a scene: positions are -world units in three axes, physics is Jolt's, and the picture is drawn from -whichever instance has the `camera` behaviour. `mesh` looks are shapes, -`model` looks are glTF files with their own animation clips, which an -`animator` plays by state. `character` is the engine's character -controller, moved by the vars `dx` and `dy` that `keys` sets, relative to -the camera; `trigger` and `enter` work as they did on the flat. The -viewport is the same scene under an editor camera, which is why what is -placed is what is seen. Scenes are in <<_games_in_the_scene>> and the -viewport in <<_the_3d_viewport>>. - -**Try this.** - -* A `light` type -- `look` `light`, `look.type` `point`, a colour and an - `intensity` -- placed above the ledge. -* `camera.mode` `orbit`, or `first` for the fox's own eyes. -* A `sky`: `scene3d.sky` with a picture of your own. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/08-scene.forge[] ----- - -=== Tutorial 9: A Rail Shooter - -image::tutorials/09-playing.png[The dead yard, 600] - -**What you will make.** House of the Dead in a yard: the camera rides a -rail and stops; at each stop zombies come out and walk at you; the gun on -the pointer drops them, ragdolls and all; when the stop is clear the rail -moves on. The yard is the painting from tutorial 7, standing in the scene, -with its pillar as an *occluder* -- a box that is not seen but hides what -walks behind it, so a zombie passes behind a pillar that is only paint. - -**What you need.** The kit's `fox.glb`, `yard.png`, and `hit.wav`. - -**Steps.** - -. Open *New game*, clear it, and in the game's form: `title` - `The Dead Yard`, `vars` `score=0, health=3`, `layers` `scene3d, overlay`, - `scene3d.fov` `60`. - -. The room's meshes. `floor`: a `mesh` box of `40`, `0.2`, `60` in `70`, - `75`, `90`, `solid`. `painting`: a box of `24`, `16`, `0.2` in white - (`255`, `255`, `255`) with `look.texture` `yard.png` and `look.unlit` - `true`, so the picture is shown as painted and not as lit. `pillar`: a - box of `1.2`, `8`, `1.2` with `look.occluder` `true` and no colour at - all. -+ -image::tutorials/09-room.png[The room's types, 600] - -. The rest. `camera`: a look of `none`, `behaviours` `camera`, - `camera.mode` `rail`, `camera.track` `rail`. `gun`: as in tutorial 6. - `zombie`: `look` `model`, `look.file` `fox.glb`, `look.scale` `0.01`, a - size of `0.8`, `0.9`, `1.4`, `vars` `health=2`, and `behaviours` - `health, seek, animator, target, timer`: `health.max` `2`, - `health.ragdoll` `true`, `health.linger` `2`; `seek.target` `camera`, - `seek.speed` `1.6`, `seek.stopAt` `2.2`; `animator.idle` `Survey`, - `animator.walk` `Walk`, `animator.attack` `Run`; `timer.name` `bite`, - `timer.after` `1.5`, `timer.start` `false`. -+ -image::tutorials/09-zombie.png[The zombie, 600] - -. Place the `floor` at `0`, `-0.1`, `-20`; the `painting` at `0`, `6`, - `-30`, standing across the far end; the `pillar` at `0`, `4`, `-22`, in - front of the painted one; the `camera` at `0`, `1.6`, `0`; the `gun` - anywhere. -+ -image::tutorials/09-placed.png[Placed, 600] - -. kbd:[4] for *Tracks*, kbd:[N]: in a scene a new track is a rail, keyed - by time. `name` `rail`. kbd:[K] puts a point where the editor camera - stands, looking where it looks; its form takes the rest. Four points: - `at` `0` at `0`, `1.6`, `0` looking at `0`, `1.2`, `-10`; `at` `4` at - `0`, `1.6`, `-6` looking at `0`, `1.2`, `-16` with `stop` `gate`; `at` - `10` at `2`, `1.6`, `-14` looking at `2`, `1.2`, `-24` with `stop` - `pillar`; and `at` `14` at `2`, `1.6`, `-18` looking at `2`, `1.2`, - `-28`. The rail is drawn through the viewport with its stops marked. -+ -image::tutorials/09-rail.png[The rail, 600] - -. kbd:[3], and the rules. `The gate: two come out of the dark`, on - `stopped` with `name` `gate`: two `spawn`s of `zombie`, at `-2`, `0`, - `-14` and `2`, `0`, `-15`. `The pillar: three more, one from behind - it`, on `stopped` `pillar`: spawns at `-1`, `0`, `-23`; `0`, `0`, - `-26`; and `4`, `0`, `-24`. `A zombie walks the moment it is made`, on - `spawn` with `type` `zombie`: `setState` `walk`. `A shot lands`, on - `hit` with `type` `zombie`: `damage` `1`, `addScore` `100`, `playSound` - `hit.wav`. -+ -image::tutorials/09-hitRule.png[The hit rules, 600] - -. `It reaches the camera and rears up`, on `arrived` with `type` `zombie`: - `setState` `attack` and a `timerStart` of `bite` after `1.5`. `The bite - lands unless it was killed in time`, on `timer` with `type` `zombie` and - `name` `bite`, when `inState` `attack`: `addVar` `health` by `-1` (its - `entity` emptied with kbd:[Delete]), a red `flash`, a `shake` of `0.3` - for `0.4`, and the timer started again. `The stop is clear: move on`, on - `frame`, when `waiting` with `entity` `camera` and a `test` of - `count("zombie") == 0`: `pathNext` `camera`. `Out of health`, on - `frame`, when `health <= 0` and `once` with `tag` `over` and `scope` - `game`: a `say` of `"YOU ARE DEAD"` and `gameOver`. `The end of the - rail`, on `railEnd`: `say` `"STAGE CLEAR"`. The readout's `text`: - `"score " .. score .. " health " .. health .. " ammo " .. gun.ammo`. -+ -image::tutorials/09-rules.png[The rules, 600] - -. kbd:[S], kbd:[P]. The camera glides to the gate; two foxes come at you. - Shoot them: two hits each, and they fall limp. The rail moves on to the - pillar, where one comes out from behind it. - -**What happened.** A `camera` in `rail` mode rides a track of points in -time, each with a place and a point to look at, and holds at a point with a -`stop`, raising `stopped` with its name; `waiting` is true while it holds, -`pathNext` sends it on, and `railEnd` fires at the last point. `seek` -walks an instance straight at a target and raises `arrived`; a `timer` -raises `timer` after its seconds, started by `timerStart`. In a scene the -gun casts a ray from the pointer, and what it meets is a `target` -- a -body the size of its look, with `zones` on named bones if a headshot should -count for more. `health.ragdoll` lets a dead model fall on its own bones -and `linger` before it goes. An `occluder` mesh writes depth and no -colour: anything behind it is hidden, and the painting behind it shows -through, which is how a 3D character walks behind a pillar that is only in -the picture -- the way a Grim Fandango room is built. Rails and the gun are -in <<_games_in_the_scene>>; painted rooms in -<<_adventures_rooms_walking_hotspots_verbs_and_talk>>. - -**Try this.** - -* Headshots: `target.zones` on the zombie, a list of boxes on named bones - typed as `name=head, bone=b_Head_05, w=0.5, h=0.5, d=0.5`, and a `hitPart` - condition of `head` in a second hit rule that does `2` damage. -* Fog: `scene3d.fog` `r=12, g=12, b=22, near=25, far=60`. -* A third stop, and a `light` type above it. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/09-rail.forge[] ----- - -=== Tutorial 10: Releasing Your Game - -image::tutorials/10-playing.png[Coin Run, 600] - -**What you will make.** The platformer from tutorial 3 as an arcade game: -lives, a coin slot, a continue, the score panel round the picture, the score -sent to the board -- and a folder with everything it needs, ready to be -packed into a `.game` and listed by the Singe menu on a machine that has -never seen Forge. - -**What you need.** Tutorial 3, or `03-sprites` from the chooser. - -**Steps.** - -. Open it, and in the game's form: `title` `Coin Run`, `vars` - `score=0, lives=3, credits=0`, and `layers` `world2d, bezel`. The - `bezel` is the arcade's score panel round the picture, showing the vars - `score`, `lives`, and `credits` as they change. -+ -image::tutorials/10-bezel.png[The bezel layer, 600] - -. kbd:[3], and onto rule 6, `Spikes hurt`, kbd:[Down] past its parts to the - last, and kbd:[T]: an `addVar` of `lives` by `-1`, its `entity` emptied. - Then `Out of lives`, on `frame`, when `lives <= 0` and `once` with `tag` - `over` and `scope` `game`: a `submitScore` with `board` `default`, a - `say` of `"GAME OVER"` for `2` seconds, and `gameOver`. `A coin`, on - `pressed` with `switch` `SWITCH_COIN1`: `credit`. `Continue`, on - `pressed` with `switch` `SWITCH_START1`, when - `credits > 0 and lives <= 0`: `addVar` `credits` by `-1`, `setVar` - `lives` to `3`, and `restart`. The readout's `text` gains - `.. " lives " .. lives`. -+ -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.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 - `Forge/kit/`. -+ -image::tutorials/10-released.png[Released, 600] - -. Pack it, as any Singe game is packed, from a shell in Forge's data - directory (the chooser said where it is, beside `Forge.pdf`): -+ -[source,text] ----- -Singe --pack CoinRun CoinRun.game ----- -+ -Copy the `.game` beside the other games, and the menu lists *Coin Run* -next time it starts. Nothing of Forge is in it but the runtime it -carries. A packed game left beside the descriptions is not mistaken for -one: the chooser can tell them apart. - -**What happened.** A released game is an ordinary Singe game: a script, -a `games.dat`, and its files. The compiled script finds its own folder and -loads the runtime and every named file from there first, so it runs wherever -it is put, packed or loose. `submitScore` queues the score for the master -service's board for this game, sent when the machine can reach it, and the -menu shows the board. `credit` counts a coin, and the bezel's `credits` -is what a cabinet's coin door shows. Releasing is in -<<_releasing_a_game>>; the board, the bezel, and what a cabinet needs are in -<<_arcade_plumbing_branching_video_lives_credits_the_board>>. - -**Try this.** - -* 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. -* A `hud` layer instead of the readout, with an RmlUi document of your own. - -**The description.** - -[source,lua] ----- -include::../forge/tutorials/10-release.forge[] ----- - -=== Recipes - -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.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 -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.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.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.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.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.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.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.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.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.forge`). `bezel` round any game, `credit` -on the coin switch, `restart` on start with credits, and `submitScore` at -the end; `branching` for a branching game of windows, moves, and jumps. - -*Anything else.* The `lua` action runs a line of Singe Lua in a rule, with -`self`, `other`, and `event` in scope, and the engine's whole API with them: -the way out when the vocabulary stops, and it is meant to be used. - - -== Describing a Game Instead of Writing One - -A game can be written as a *description* -- a table of types, rooms, and rules --- and compiled into an ordinary Singe game. `Forge/AuthorCompile.singe` does -the compiling and `Author.singe` is the runtime the result calls. **No part of -Forge ships with Singe** -- not the editor, not the compiler, not the runtime -- -so a game built with it carries its own copy of that runtime and stands entirely -on its own. Nothing is interpreted at run time: the rules become real Lua -functions, so a description costs nothing per frame on a Raspberry Pi, and the -game it produces can be opened, read, and edited by hand like any other. - -There is no notion of genre anywhere in it. A game declares which of the -engine's own layers it uses, and that is the only difference between a light -gun game, a platformer, and a quick-time event over video. - -=== The Nouns - -*Layers* are what the game draws through: `world2d` (physics in the XY plane, -drawn into the overlay), `scene3d` (the 3D scene, with a sun, a sky, and fog; -positions are world units), `overlay` (flat drawing), `disc` (the video the -game is played over), `hud` (an RmlUi document bound to the game's vars), and -`music`. A game lists the ones it wants. - -*Types* are what things are: a `look` (`box`, `sprite`, `text`, or `none` in -2D; `model`, `mesh`, `light`, `billboard`, or `text3d` in the scene), any -number of `behaviours`, and `vars` -- the state every instance starts with. -Everything placed or spawned is an instance of a type. - -*Rooms* are where things are. A room lists its `entities` -- a type, an id, a -position, and any vars that differ -- and its `tracks`. A room keeps its state -when it is left unless it says `reset = true`; vars declared on the game live -across rooms. - -*Rules* are a trigger, conditions, and actions. `on` names the event -- every -frame, a collision between two types, a shot landing, a death, a timer, a disc -frame passing -- and `each` scopes the rule to every instance of a type, with -`self` bound. All of a rule's conditions must hold for its actions to run; an -`any` group inside them is an OR. - -.A description, in full -[source,lua] ----- -return { - title = "One rule", - layers = { { kind = "world2d", gravity = 1500 } }, - vars = { score = 0 }, - types = { - ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, - behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, - behaviours = { { kind = "platformer", speed = 210, jump = 620 } } } - }, - rooms = { - { name = "start", - entities = { { type = "ground", id = "ground", x = 360, y = 440 }, - { type = "hero", id = "hero", x = 120, y = 380 } } } - }, - rules = { - { note = "Run right", - on = "frame", - when = { { "keyHeld", key = "RIGHT" } }, - act = { { "run", entity = "hero", direction = 1 } } } - } -} ----- - -Compile it and run what comes out: - -[source,lua] ----- -dofile("Forge/AuthorCompile.singe") -dofile(authorBuild("mygame.forge", singeGetDataPath() .. "mygame.singe")) ----- - -`testScripts/author/` holds the worked examples -- a platformer, a QTE over -video, a shoot-em-up, a light gun game over video, a rail shooter, a 3D -platformer, an adventure in 2D and in 3D, a racing game, a tower defence, -branching video, a first-person shooter, breakout, a maze, third-person -action, a space shooter, a rhythm game, and bowling, with two more for the -sprite, tile, MIDI, water, and soft-body pieces -- and -`testScripts/scene52.singe` onward compile and play them. - -=== Instances, Vars, and States - -A type's `vars` are what each instance starts with: `vars = { health = 3, -ammo = 6 }`. An entity in a room may override them (`vars = { health = 5 }`), -and rules read and change them: `self.health` in an expression, `setVar` and -`addVar` as actions. `state` is a var every instance has, `"idle"` to begin -with; `setState` changes it and `inState` tests it. - -Vars declared on the game -- `vars = { score = 0, lives = 3 }` -- belong to -no instance and are read by name: `score`, `lives`. `setVar` and `addVar` -with no entity change them, and `addScore` is `addVar` on `score`. - -Instances are made when a room is entered, by a `spawner` or `shooter` -behaviour, or by the `spawn` action, and go with `destroy`, with a `health` -that reaches zero, or with a `projectile` that leaves the picture. An -instance made at run time has an id of its own (`raider#7`); rules reach it as -`self` or `other`, or through `each`. - -=== Events - -A rule's `on` says what triggers it: - -[cols="1,3"] -|=== -| `frame` | every frame, the default -| `pressed`, `released` | a key or a switch, once per press; `key` or `switch` narrows it -| `collision` | an instance of type `a` began touching one of type `b`; `self` is `a`, `other` is `b` -| `hit`, `miss` | a gun's shot landed on `self` (`other` is the gun), or landed on nothing; `event.offscreen` says whether a miss left the picture -| `death`, `spawn` | `self`'s health reached zero; `self` was just made -| `timer` | a timer on `self` went off; `event.name` says which -| `frameReached` | the disc passed `frame` -| `enter`, `leave` | an instance of type `b` entered or left a trigger of type `a` -| `arrived` | a seeking instance reached its target -| `stopped`, `railEnd` | a rail camera reached a stop (`event.name`), or the end of its track -| `animationDone` | a clip that does not loop ended on `self` -| `roomStart`, `roomEnd`, `gameOver` | the game's own moments -|=== - -The event's parameters are written on the rule itself (`on = "collision", -a = "shot", b = "raider"`). `each = "raider"` runs a frame rule once per live -raider, and narrows an event rule to that type; `room = "hall"` keeps a rule -to one room. - -=== Expressions - -Anywhere a number goes, an expression may go instead, as a string: numbers, -strings, `+ - * / % ^ ..`, comparisons, `and`, `or`, `not`, and the names -below. Nothing else is allowed -- an expression is checked when the game is -built -- and the `lua` action is the way past it. - -[cols="1,2"] -|=== -| `self.health`, `other.x`, `event.frame` | the instance the rule is about, the other one, the event's own fields -| `hero.x` | a placed entity's field or var, by id -| `score`, `lives` | the game's vars -| `time`, `frame` | seconds since the game began; the disc frame -| `count("raider")` | live instances of a type -| `random(a, b)`, `distance(a, b)`, `has("key")` | a number in a range; between two instances; whether the inventory holds an item -| `abs`, `min`, `max`, `floor` | arithmetic -|=== - -A `test` condition takes one: `{ "test", expr = "count('raider') == 0" }`. - -=== Sequences - -Some actions take time. `say` shows a line and waits for it; `wait` waits. -A rule with one of them runs as a coroutine, its actions in order, each waiting -its turn -- a cut-scene is the same shape as `addScore`. While it runs the -same rule does not start again for the same instance unless the rule says -`interrupt = true`, and `controls = false` on the rule takes the keys away for -its duration. - -=== Tracks - -A track is keys by disc frame (`key = "frame"`) or by time (`key = "time"`), -and a room may hold any number. A track of `boxes` is a hitbox that moves -with the picture: - -[source,lua] ----- -tracks = { - { name = "bandit", key = "frame", - boxes = { { at = 120, x = 100, y = 200, w = 60, h = 100 }, - { at = 220, x = 400, y = 200, w = 60, h = 100 } } } -} ----- - -Between keys the box is interpolated; outside them there is none. A type with -`{ kind = "hitbox", track = "bandit" }` is hit where the track says, which is -how a light gun game over video is 2,750 rectangles no longer. - -A track of `points` is a rail for a camera: positions by time, each looking -at a point (`look = { x, y, z }`) or at an entity by id, and any point may be -a `stop` that holds the camera there until a `pathNext` action sends it on -- -which is what House of the Dead does at every doorway. - -[source,lua] ----- -{ name = "rail", key = "time", - points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.2, -10 } }, - { at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" } } } ----- - -=== Games in the Scene - -A game with a `scene3d` layer places its types in world units and draws them -through the engine's renderer. A `model` look is a glTF file with a scale and -a starting clip; `mesh` is a box, sphere, cylinder, or plane with a colour; -`light` and `billboard` are what they say. The 2D looks still draw on the -overlay over the scene, which is how a score readout works in both. - -The behaviours that live there: `character` is the engine's controller -moving by the `keys` vars relative to the camera; `camera` draws the scene -from the instance -- `fixed`, `follow` (an offset from a target), `orbit` -and `first` (the mouse looks), or `rail`; `seek` walks toward a target and -raises `arrived`; `animator` plays a clip per state and raises -`animationDone`; `target` gives a `gun` something to hit, with `zones` on -named bones so a shot says which part it landed on (`hitPart`); `health` -with `ragdoll` lets a model fall limp on death; `solid`, `body`, and -`trigger` are the physics. A gun in the scene casts a ray through the -pointer, so the same `hit` and `miss` rules serve a light gun over video and -a rail shooter. - -`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. - -=== Adventures: Rooms, Walking, Hotspots, Verbs, and Talk - -A point-and-click adventure -- Sierra's or LucasArts' -- is rooms that -remember themselves, a character that walks where the player clicks, hotspots -a verb is used on, an inventory, dialogue, and cut-scenes. All of it is the -same nouns. - -*Walking.* A 2D room declares `walk = { { x, y, x, y, ... }, ... }`, its -walkable floor as polygons in overlay coordinates; a 3D room declares -`navFrom = { "floor", "ledge" }`, the entities whose meshes are its floor. -Either is baked into a navigation mesh when the room is entered, and a type -with a `walker` behaviour walks it: `walkTo` sends it to a point (and waits), -`walkToPointer` to where the player clicked, `walkToHotspot` to a hotspot's -walk point; it raises `arrived`, and its state is `walk` or `idle` as it -goes. A painted room may say `depthSort = true`, so what is lower on the -picture is drawn over what is higher, and `scaleBy = { { y = 300, scale = 0.6 -}, { y = 460, scale = 1 } }`, so a character further up the picture is drawn -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 light sensor way of judging a shot -over video -- the picture under the gun agreeing with a sensor spot across -two frames -- is Forge's `lightSensor` behaviour, with `pictureAt` as the -condition that reads the picture. - -*A quick-time-event game.* The `qte` behaviour plays a laserdisc -quick-time-event game from tables under `qte` in the description: an -attract loop, credits, levels of scenes with moves to answer, death clips, -continues, and the game's settings. The port converter writes those tables -from an existing game's script, and the game's own script and add-ons run -inside the behaviour, so a port keeps its own scoring and level order. - -`qte.options.style` picks how the game plays: - -* `windows`, the default: each move is answered in a window of frames. - Moves can be compound -- sequences of two or three inputs, circles from - any start, mashes of a direction or a button, a direction with a second - button. -* `held`: moves are judged by what's held together (combos, three kinds of - mash, holds, sequences, paths, timed and yes/no branches), with tilt, a - life bar, a die-and-retry count, a new-game menu, and a level select. -* `segments`: levels of segments, a move being an input, a mash, or a pair - of inputs in a window, with a map between levels, running the game's own - level, move, death-clip, and order functions as they are. -* `checkpoints`: segments with checkpoints, a time-stop choice of three, a - tally by deaths, and a life per quota. -* `trophies`: segmented levels judged the windows way, with a difficulty - select, a level-clear screen, and trophies. - -Where two styles differ on the same screen (whether a game over after a -continue plays on from the continue clip, whether a death after the hints -counts as a wrong move, and so on), the difference is a named option in -`qte.options`, and each style sets its own default. A description can ask -for either behaviour whatever its style. Each port is compared with its -original move for move, by the traces both print on the same inputs; -FORGE.md section 14 has how, and where each title stands. - -*A hand-written game.* A game that was a program of its own rather than -a framework's tables -- a set of light gun titles, a typing edition of one -of them, and a prototype in their shape -- is ported as a vocabulary of its own beside -its description (see -<>): 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.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, -walkY = 330 }` is something a verb can be used on: its look's box, or a -`polygon` of x,y pairs, in 2D; a box the size of its look, met by the ray -through the pointer, in 3D. The name is what the sentence line shows. - -*Verbs.* A game with `verbs = { "walk", "look", "take", "use", "open", "talk" -}` builds a sentence line from the current verb, the hotspot under the -pointer, and the item in hand (`sentence`, `hover`, `verb`, and `item` are -game vars, so a text look or a HUD element can show them). A click on a -hotspot raises `verb` with `verb`, `target`, and `item`, and **the most -specific rule wins**: `verb = "use", item = "key", target = "door"` beats -`verb = "use", target = "door"` beats `verb = "use"` beats a rule that names -nothing, which is the "That doesn't work" every adventure needs. A rule with -conditions ranks above one without among the same parameters, so "open the -door while the guard objects" and "open the door" can share their parameters -and differ in a `test`. `setVerb`, `nextVerb`, and `useItem` choose; the -first verb in the list is what a click on the floor means, and the `pressed` -event carries the pointer's `x` and `y` for a `walkToPointer` rule. - -*The parser.* A `parser` layer takes typing at a prompt: `words = { look = -{ "look", "examine", "l" }, door = { "door", "gate" } }` says what the game -knows, and a typed line raises `said` with its `verb`, `noun`, and `second` -as the words table knows them -- or `event.unknown`, the first word it did -not -- with the same most-specific-wins rule. A game may offer both a verb -line and a parser; the rules do not care which was used. - -*Inventory.* `give` and `take` keep a list in the `inventory` var; -`has("key")` tests it in an expression and `has` as a condition. - -*Dialogue.* The game's `dialogues` are trees: - -[source,lua] ----- -dialogues = { - guard = { - start = "hello", - nodes = { - hello = { who = "Guard", text = "Nobody passes.", - choices = { { text = "Why not?", next = "why" }, { text = "Fine." } } }, - why = { who = "Guard", text = "The door is locked.", - choices = { { text = "I have a key.", when = 'has("key")', - act = { { "setVar", name = "allowed", value = true } }, next = "ok" }, - { text = "I see." } } }, - ok = { who = "Guard", text = "Then go ahead." } - } - } -} ----- - -`talk` runs one and waits for it: each node's line is said, its choices -(those whose `when` holds, and not those already chosen once with `once`) are -offered and picked with the number keys or a click, the choice's `act` runs, -and `next` names the node after. With a HUD, the choices go in the element -called `choices`. - -*Cut-scenes and rooms.* A rule with `controls = false` takes the keys away -while it runs; `fade` (out, then back with `out = false`) and `goTo` change -the picture and the room, and `goTo` with `at = "arrival"` puts the carried -entity where the entity of that id stands in the new room, so the same rule -serves a painted room and a modelled one. Sequences survive a room change. - -*Saving.* `saveGame` keeps the whole game -- the room, every visited room's -instances and their vars, the game's vars, the inventory -- in a numbered -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.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. - -=== Vehicles, Crowds, Turrets, and Physics Toys - -`vehicle` puts a type on the engine's vehicle physics -- a car, a motorcycle, -a tank, or a boat -- with wheels hung at the corners its look's size gives, -driven by the `keys` vars (`dy` throttle, up is forward; `dx` steering), and -`self.speed` says how fast. `racer` drives a vehicle round a track of -`points` on its own, steering toward the next and easing off in bends, which -is every opponent in a racing game. `camera` in `follow` mode with a `lag` -is what sits behind the car. - -`walker` with `follow = "hero"` keeps an instance after an entity (or the -camera) across the navigation mesh, asking again every so often -- a crowd of -enemies in a shooter, a companion. `patrol` walks a list of points in turn, -looping or stopping, and raises `patrolEnd` at the last: a creep's lane, a -guard's round. `turret` fires at the nearest instance of a type within range, -no faster than its rate, spawning a projectile aimed at it (or doing the -damage itself), and `spawnAtPointer` makes an instance where the player -clicked -- on the floor the ray meets, in 3D -- which is how turrets are -placed. - -`body` is a thing physics moves (mass, bounce, friction); `thrust` pushes a -body along its nose by `dy` and turns it by `dx` (a hovercraft, a ship); -`joint` hangs a body from another's, or from the world, by a hinge, a ball, -or a slider through a point along an axis; and `push` shoves a body, or a -ragdoll's bone. - -`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. - -=== Arcade Plumbing: Branching Video, Lives, Credits, the Board - -A `branches` track is a branching laserdisc game: 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`. 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.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. - -=== The Long Tail: Sprite Sheets, Tiles, MIDI, Water, Soft Bodies - -`distance(a, b)` takes instances or ids (`distance(self, "hero")`) and -measures through the scene in 3D. A rule with `each` on an event that has no -instance of its own -- a key, a room, the disc -- runs once per instance of -the type, as a frame rule does, which is how "a swing of the sword hits every -enemy in reach" is one rule. A `solid` with `moving = true` is a body that -follows wherever the instance is moved: a paddle, a lift. - -A `sprite` look turns with the var `angle` (degrees, clockwise), which an -entity's `rz` starts and which rules, a `spin` behaviour, or a `projectile` -with `aim` change -- a turning image is a whole one, not a sheet. `spin` -turns a 3D instance about Y the same way. A `particles` look is a steady -stream from the instance -- a fire, smoke, a thruster, rain -- with a colour, -a rate, a size, a speed, and a life, and the `emit` action is a burst from any -instance. - -A `sprite` look with `frames` is a sheet of that many columns, and a `frames` -behaviour steps it: `states = "idle=1-1, walk=2-5"` gives each state its run -of frames, played at `fps`, so a walker's walk cycle follows the state the -`walker` (or a rule) sets. Art need only face one way: the look's `faces` -says which (`right` unless said), and while the var `facing` -- which the -`walker` and the `mover` set as they go -- is the other way, the image is -mirrored. A sheet drawn facing both ways names the other way's frames for -the state with `Left` or `Right` on the end (`walkLeft=6-9`), and those are -used instead of the mirror; `faces = "none"` never mirrors, for a top-down -sprite that turns rather than flips. A sheet entity with an `rz` (or a var -`angle`) draws its frame turned. A `grid` look draws a map of tiles from a sheet -- -`columns` tiles wide, each `tile` pixels square -- from rows of tile numbers -(`map = "1 2 1; 2 1 2"`, `0` for nothing), which is a maze or a level in a -string. A game that opened a MIDI port hears `midi` events with -`event.pitch` and `event.velocity` for every note struck. - -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.forge` and `pool.forge` are the worked examples; -scenes 70 and 71 play them. - -=== The Way Out - -The `lua` action takes a line of Lua and emits it as it stands. It is there on -purpose: when a rule needs something the vocabulary cannot say, that rule drops -to Lua and the rest of the game is unaffected. A description is a convenience, -not a cage, and the compiled output is a normal game you can stop describing -and start editing whenever it suits you. - -== The Vocabulary - -Conditions, actions, behaviours, looks, layers, and events are not built into -the compiler. Each is an entry in the `AUTHOR` table in `Author.singe` -declaring its parameters and the Lua it emits, so a new kind of game is a set -of entries rather than a new release. The tables below are generated from -that manifest. - -The parameter types: `number` (a number or an expression), `expression`, -`string`, `boolean`, `entity` (`"self"`, `"other"`, or an entity's id), -`type`, `scancode` (a key name from `SCANCODE`), `switch` (a `SWITCH_*` name), -`file`, `state`, `track`, `room`, and `lua`. A behaviour's parameters are -plain values in the description; key and switch names among them are resolved -when the game runs. An action or condition may also carry `defaults`, the -value a parameter takes when the description leaves it out (a `shake` of -`0.3` for `0.4` seconds), and `optional`, the parameters that mean something -by their absence (a `goTo` with no `x`); a parameter with neither, left -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`. - -A word that turns out to be general belongs in the framework rather than -beside one game. `lightSensor`, which judges a shot by the picture under the -gun, began as five light gun games' own vocabulary and is now one of the -behaviours above, with `pictureAt` beside it as a condition any rule can ask; -those five name no vocabulary at all any more. The test scene's `bob` is the -working example instead, and the rule to apply is the same one: a vocabulary -is for a kind of thing this game needs and nothing else does. The moment two -games would want it, move it. - -[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 -its own directory beside the games, appears in the menu like one, and packs to -`Forge.game`; the build does that itself (the `forge` target), with this manual -inside. Nothing in it is a preview: the canvas is the same overlay at the same -coordinates the game will be played in, so what is placed is what is seen. - -Started from the menu it opens on a chooser: the descriptions in its data -directory, the samples inside `Forge.game` and any dropped into the `Forge` -directory itself (those are opened as a copy, since inside a `.game` they are -read only), and *New game*, which writes -a starter -- ground, a hero that runs and jumps, a score readout -- and opens -it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into -that data directory, and the chooser says where it is. `Esc` in the editor -closes the description (twice, when it has unsaved changes) and `Esc` on the -chooser leaves Forge. `P` plays: the description is saved, compiled beside a -copy of the runtime, and handed to the engine with `scriptPush`; when the game -ends Forge comes back on the same file. - -A script can drive the editor instead, which is how the test scenes do it: - -[source,lua] ----- -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") -forgeBegin("mygame.forge") - -function onOverlayUpdate() - local x, y = mouseGetPosition(0) - - forgeDraw(x, y) - - return OVERLAY_UPDATED -end ----- - -The panel is an RmlUi document; the canvas beside it is drawn into the overlay -and picked with `collidePointRect`. The two compose because the engine offers -a button to the GUI first and passes on what it did not use, while pointer -motion is never consumed at all -- so point `forgePress`, `forgeDrag`, and -`forgeRelease` at the mouse callbacks and clicks on the panel will not reach -the canvas. The panel slides: drag the tab on its outer edge, or use the -arrow keys, so an entity that lives underneath it is never permanently out of -reach. - -=== The Four Panels - -`Tab` cycles the panel through the room's *entities*, the *types*, the -*rules*, the room's *tracks*, and the *dialogues*; the digits `1` to `5` go -straight to one. Whatever is selected is edited the same way: `Enter` walks -its fields and each is typed; `Enter` again moves to the next, keeping what -was typed and leaving alone a field passed over with nothing typed; `Esc` -puts the value back and leaves the form; `Delete` while typing empties the -field, which for an action's `entity` means the game itself. A typed number -comes back a number, because `100` and `"100"` compile to different source. -The last line of the box under the list is Forge's message: what just -happened. A value is typed in its own row: it starts selected whole, so -typing replaces it, and kbd:[Left], kbd:[Right], kbd:[Home], and kbd:[End] -put a caret into it to edit it instead; kbd:[Backspace] takes out the -character before the caret, or the whole selected value, and kbd:[Delete] -the character after it -- or, on the value still selected whole, empties -the field for good. Shifted keys type what -they say -- a capital, a quote. The game's name at the top of the panel, -or a press on an empty spot of the canvas, selects nothing, which is the -game's and the room's own form. The panel's list and its fields box each -scroll, with a bar and the wheel, when there is more than fits, and the -chosen row and the field being edited are kept in view. The mouse does the -same work: a click on a row of the list selects what it names -- an entity, a -type, a rule or one of its parts, a track or a key, a dialogue's node or -choice -- a click on a field in the box below starts typing that field, and -a click on a picker's row takes it. - -*Entities*: `A` adds one of the selected type in the middle of the canvas, -`D` duplicates the selected one, `Delete` removes it, and dragging moves it. -Its fields are `id`, `type`, `x`, `y`, `z`, `scale` -- and in a 3D room -`rx`, `ry`, `rz`, its turn about each axis in degrees -- and each var its type -declares, overridable here. A scale scales the look, the body, and how big -the instance counts as for touching. Renaming an entity renames it in every rule that talks about -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`, `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, <>) --- 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 -`name=head, bone=b_Head_05, w=0.5, h=0.5, d=0.5; name=body, bone=b_Spine, w=0.8` -(one record per semicolon), a map of lists such as `parser.words` as -`look=look|examine|l, take=take|get`, and a list of words such as -`parser.ignore` as `the; a; an` -- then the room's `name`, `reset`, `depthSort`, `walk` areas (polygons of -x,y pairs, separated by semicolons, drawn on the canvas), `navFrom`, and -`scaleBy` (as `300:0.6, 460:1`). Typing a layer, or a behaviour, away and -back keeps its values. - -A field whose values are a fixed set opens a *dropdown* under its own row -instead of a prompt: a file (a look's image or model, a sound, a -HUD document, a layer's disc) lists the files under the game's directory -and in the kit (`Forge/kit/`); a flag lists `true` and `false`; a key -lists the scancodes and a switch the switches; a type's `look` lists the -looks and a rule's `on` the events, each with its line of help; a word -parameter lists the words it takes, as the vocabulary tables say (`anchor` -is `feet`, a camera's `mode` is `fixed`, `follow`, `first`, `orbit`, or -`rail`); and a type, a room, an entity, a state, a track, or a dialogue's -node lists the ones the game has. The list opens on the value the field -has (a value the list does not know is offered as it is), or on `(none)` -when it has none, so `Enter` straight away keeps things as they are: -`Enter` on a row takes it and moves on to the next field, as `Enter` on a -typed value does, and a click takes it and stays. `(none)` leaves the -field empty -- the default, or the game itself for an action's entity. -Typing narrows the list to the names -that contain what was typed -- an exact name first, then names that begin -with it -- and `Backspace` widens it again; a long list shows a window of -rows around the chosen one, and says how many lie past it. A dropdown -opens upward when there is no room below its row. The pickers -that add a condition, an action, or an event (`C`, `T`, `E`) take the -panel's list over instead, since the whole manifest is being chosen from. Where the game's own names are only -suggestions -- a rule may name a type, a room, or an entity that is made -later, and a state can be any word -- what was typed is offered at the top -of the list, so `Enter` takes a new word as readily as a known one. `Esc` -on a picker leaves the field to be typed. - -*Types*: `A` adds one, `D` duplicates, `Delete` removes one no room still -places. Its fields are `name`, `look` and what that look takes (`look.w`, -`look.file`, ...), `vars` typed as `health=3, ammo=6`, `behaviours` typed as a -list of names (`keys, mover, shooter`), and each behaviour's own parameters as -`mover.speed` and so on. The fields come from the manifest, so a new look or -behaviour is editable the moment it is declared; renaming a type renames it in -every entity and rule. - -In a 2D room, `V` draws a polygon corner by corner on the canvas: with the -selected entity's type carrying a `hotspot` behaviour it becomes that hotspot's -outline, otherwise it is added to the room's walk areas. The panel steps -aside while the corners are pressed. `V` or `Enter` closes it, `Esc` drops -it. - -*Rules*: the selected rule opens in place with its conditions and actions -under it, because a rule only means anything whole. `N` adds a rule, `E` picks -what it is on (the event's own parameters then appear as fields on the rule), -`C` adds a condition, `O` a condition in the rule's `any` group, `T` an action --- each picked from the vocabulary with its help beside it -- and `[` and `]` -move the rule up and down the sheet, since rules run in order. `Delete` -removes the selected condition or action, or the rule itself. - -*Tracks*: the room's tracks and their keys. The canvas shows the frame of the -disc layer's video under the cursor and every track's box there; `,` and `.` -step the cursor a frame, `[` and `]` leap ten. `N` adds a track, `K` a key at -the cursor -- taking the box interpolated there, or the nearest key's -- and a -key is moved by dragging its box on the canvas or by typing. `Delete` removes -the selected key, or the track. In a 3D room a new track is a rail of points, -drawn through the scene with its stops marked, and `K` puts a point where the -editor camera stands, looking where it looks: stand where the player should -and press it. `W` adds a *waves* track, whose keys are spawns: `at`, the -`type` made, `count` of them, `every` so many seconds apart, and where -- -`from`, an entity or a comma-separated list of entities taken in turn, or -`x`, `y`, `z`. A waves track keyed by `stop` instead of `frame` or `time` -ties its waves to a rail camera's stops: `at` names the stop. A wave -begins once per visit to the room, and each instance it makes raises `spawn` -as any other. - -*Dialogues*: an outline of every dialogue, the selected one's nodes under it, -the selected node's choices under that, and a choice's actions under that. -`A` adds at the level selected -- a dialogue, a node in it, a choice in the -node -- `T` adds an action to the selected choice from the vocabulary, and -`Delete` removes what is selected. A dialogue's `name` and `start`, a node's -`name`, `who`, `text`, `seconds`, and `next`, and a choice's `text`, `when`, -`next`, and `once` are typed like everything else. - -`P` plays from the room on screen: the built game begins in it, with the -other rooms following in order. `X` releases the game into a folder named -for it in Forge's data directory, with everything it needs (see -<<_releasing_a_game>>). - -On the 2D canvas the selected entity carries the yellow turn handle alone: -an arm the way its `rz` points, past its box, dragged round the entity to -turn it, and a sheet entity shows its first frame turned the same way. In -the types panel each type's picture stands beside its name -- the sprite -itself (one frame of a sheet), `Aa` for a text, or a swatch of the box's -colour -- so a type can be told from its neighbours without opening it. -`testScripts/scene82.singe` drives both. - -=== The 3D Viewport - -A room in a game with a `scene3d` layer is shown as the scene itself, built -from the types' looks exactly as the runtime builds them, under an editor -camera that orbits whatever is selected: `J` and `L` orbit, `Y` and `H` tilt, -`I` and `K` close in and back off. Nothing steps -- no behaviours, no physics --- so it is the description that is on screen, not a running game. An entity -is picked by where it projects and dragged across the floor it stands on, and -the selected one carries a gizmo: three arms along the world axes, red for -X, green for Y, blue for Z, each ending in a handle that drags the entity -along that axis alone -- the pointer's travel along the arm, as a fraction of -the arm, is the distance moved -- and a fourth, yellow, the way the entity -faces, whose handle dragged round the entity turns it about Y. `testScripts/scene63.singe` and `scene80` -drive it. - -=== Keys - -[cols="1,4"] -|=== -| `Tab`, `1` to `5` | entities, types, rules, tracks, dialogues -| Up, Down | move through the list, and through the parts of the open rule or track -| Left, Right | slide the panel; while typing, move the caret (with `Home` and `End`) -| `PgUp`, `PgDn` | the previous or next room -| `Enter` | type a value for whatever is selected; again for its next value -| `Delete` | while typing, take out the character after the caret, or empty the field while its value is still selected whole -| `Esc` | put the value back and leave the form; otherwise close the description -| `A`, `D`, `Delete` | add, duplicate, delete an entity or a type -| `N` | a new rule, a new track, or (in the entities) a new room -| `W` | a new waves track (in the tracks) -| `E` | pick what the rule is on -| `C`, `O`, `T` | add a condition, an any-of condition, an action -| `[`, `]` | move the rule up or down; in the tracks, leap the cursor -| `,`, `.` | step the cursor -| `V` | draw a walk area, or the selected hotspot's outline, corner by corner -| `K` | a key at the cursor; in a 3D room, a rail point where the camera stands -| `J`, `L`, `Y`, `H`, `I`, `K` | in a 3D room: orbit, tilt, and zoom the editor camera -| `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history -| `S`, `B`, `P`, `X`, `Shift+P` | save, build, play, release, publish -|=== - -From a script the same work is `forgeEntityAdd`, `forgeEntityDuplicate`, -`forgeEntityDelete`, `forgeEntitySet`, `forgeTypeAdd`, `forgeTypeSet`, -`forgeRuleNew`, `forgeRuleOn`, `forgeRuleAdd("when"|"any"|"act", name)`, -`forgePartSet`, `forgePartDelete`, `forgeRuleMove`, `forgeTrackNew`, -`forgeKeyAdd`, `forgeCursorTo`, `forgeUndo`, and `forgeRedo`. - -A description survives the round trip: loading one, saving it, and loading it -again compiles to the same game, byte for byte. The editor depends on that -and `testScripts/scene54.singe` asserts it. - -=== Releasing a Game - -`X` in the editor, or `forgeExport(folder, name)` from a script, writes -everything a finished game needs into a directory of its own: the compiled -script, a `games.dat` so the menu lists it (its names begin with the folder's -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, 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 -for every named file there first, so the release runs wherever it is put. -`--pack` turns that directory into a `.game` like any other, and it runs on a -machine that has never had Forge on it. - -[#credits] -==== Credits - -Say who made the game. The game's form (nothing selected, entities panel) has a -row of `credits` fields: developer, publisher, year, platform, genre, creator, -source, and description. They land in the release's `games.dat` under the keys -the menu shows in its details panel, so whoever made the game is named -wherever it is played. Two more fields hold what the others have no room for: -`credits.notes`, lines separated by `|` that go into a `CREDITS.txt` beside the -game, and `credits.licenses`, files that travel with the release like any other -file the description names. - -[source,lua] ----- -credits = { - developer = "Somebody Else", - year = 1983, - notes = { "Original game: Somebody Else", "Forge version: You" }, - licenses = { "COPYING.txt" } -}, ----- - -If your game is based on someone else's, this is where they get their due, and -if their licence says it has to ship with the game, `licenses` is how it does. - -`Shift+P` goes one step further: it exports, packs, and sends the package to the -master service for review, in one press. It is not in the catalogue until it -has been reviewed, and Forge says which version number it was given. Press -again after a change and a later version goes up on its own, without taking an -earlier one away from anybody who already has it. The account is the machine's, -signed in through the menu's Online Account tool; Forge signs nothing in -itself, and says so if the machine is not signed in. Every description has a -permanent `GAME_ID` from the first time it is saved, which is what a game is -known by, so a game published once can be updated for ever. - -Every built game finds its own directory to load that runtime from, using -`debug.getinfo` rather than `DIR`: `DIR` is the directory of the script the -engine was *launched* with, so a game reached by `dofile` -- a test, a -launcher, a preview -- would otherwise look beside the caller. - -`forgeBuild(path)` compiles what is on screen (and puts the runtime beside it, -so the result plays); what `authorCheck` has to say about the description -- -a type that is not declared, an expression that does not parse, a field a -look, a behaviour, or an event does not take (a misspelling, nine times in -ten) -- goes to the console and the panel. diff --git a/docs/ForgeVocabulary.adoc b/docs/ForgeVocabulary.adoc deleted file mode 100644 index ab08b5cef..000000000 --- a/docs/ForgeVocabulary.adoc +++ /dev/null @@ -1,721 +0,0 @@ -// Generated by util/forgeVocabulary.lua from forge/Author.singe. Do not edit. - -=== Layers - -[cols="1,3,2"] -|=== -| Name | What it does | Parameters - -| `bezel` -| The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change. -| `twin` (boolean) - -| `disc` -| The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs. -| `file` (file), `start` (number) - -| `hud` -| An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change. -| `bind` (table), `document` (file) - -| `music` -| A track that plays from the start, looping. -| `file` (file), `volume` (number) - -| `overlay` -| Flat drawing over everything: sprites, scores, prompts. -| -- - -| `parser` -| A text parser: a prompt the player types at, and words the game knows. What is typed raises the said event. -| `ignore` (table), `prompt` (string), `words` (table) - -| `scene3d` -| The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units. -| `ambient` (table), `exposure` (number), `fog` (table), `fov` (number), `gravity` (number), `sky` (file), `sun` (table) - -| `world2d` -| A flat world with gravity: physics in the XY plane, drawn into the overlay. -| `gravity` (number) - -|=== - -=== Looks - -[cols="1,3,2"] -|=== -| Name | What it does | Parameters - -| `billboard` -| A sprite in the scene, always facing the camera: a tree, a puff, a health bar. -| `d` (number), `file` (file), `h` (number), `height` (number), `w` (number) - -| `box` -| A filled rectangle, centred on the instance -- or standing on it, with anchor feet. -| `anchor` (feet), `b` (number), `g` (number), `h` (number), `r` (number), `w` (number) - -| `grid` -| A map of tiles from a sheet, its top left at the instance: columns is the sheet's width in tiles, tile the tile's size, map the rows of tile numbers (1 is the first; 0 is empty), rows separated by semicolons. -| `columns` (number), `file` (file), `map` (string), `tile` (number) - -| `light` -| A point or spot light, or a sun, on the instance. -| `b` (number), `g` (number), `intensity` (number), `r` (number), `range` (number), `shadow` (boolean), `type` (point, spot, sun) - -| `mesh` -| A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting. -| `b` (number), `d` (number), `g` (number), `h` (number), `metallic` (number), `occluder` (boolean), `r` (number), `roughness` (number), `shape` (box, sphere, cylinder, plane), `texture` (file), `unlit` (boolean), `w` (number) - -| `model` -| A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as. -| `clip` (string), `d` (number), `file` (file), `h` (number), `rx` (number), `ry` (number), `rz` (number), `scale` (number), `w` (number) - -| `none` -| Nothing drawn: a trigger, a spawner, a hitbox over video, a camera. -| `d` (number), `h` (number), `w` (number) - -| `particles` -| A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them. -| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number) - -| `qteHud` -| The HUD of a quick-time-event game: what its qte behaviour's state says a player sees (the score and the record, the lives or the life bar, and the rest), drawn from the game's own pictures and font as the game lays them out. -| -- - -| `sprite` -| An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both. The var copies draws it that many times in a row, step pixels apart (a row of bullets, of hearts): negative steps go left. -| `anchor` (feet), `faces` (right, left, none), `file` (file), `frames` (number), `step` (number) - -| `text` -| 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. -| `height` (number), `text` (string) - -|=== - -=== Behaviours - -[cols="1,3,2"] -|=== -| Name | What it does | Parameters - -| `animator` -| A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends. -| `attack` (string), `dead` (string), `fade` (number), `hit` (string), `idle` (string), `loop` (string), `walk` (string) - -| `body` -| A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them. -| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (box, sphere) - -| `branching` -| Branch windows over the disc: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed. -| `track` (track) - -| `camera` -| The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops. -| `eye` (number), `lag` (number), `lookX` (number), `lookY` (number), `lookZ` (number), `mode` (fixed, follow, first, orbit, rail), `target` (string), `track` (track), `x` (number), `y` (number), `z` (number) - -| `character` -| Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera. -| `height` (number), `jump` (number), `radius` (number), `speed` (number) - -| `drift` -| Moves steadily, for a cloud, a platform, or a target. -| `vx` (number), `vy` (number) - -| `frames` -| Steps a sprite look's frame: a range of frames per state, as "idle=1-1, walk=2-5", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look. -| `fps` (number), `states` (string) - -| `gun` -| The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun. -| `aim` (centre), `ammo` (number), `player` (number), `reload` (offscreen), `trigger` (switch) - -| `health` -| The var health; the damage action lowers it, and at zero the instance raises death and is destroyed unless keep is set. With ragdoll, a model falls limp instead and goes after linger seconds. -| `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. A track with step = true keeps a box only on the frames it keys. -| `track` (track) - -| `hotspot` -| Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do. -| `name` (string), `polygon` (table), `walkX` (number), `walkY` (number), `walkZ` (number) - -| `joint` -| Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis. -| `ax` (number), `ay` (number), `az` (number), `dx` (number), `dy` (number), `dz` (number), `other` (string), `type` (hinge, ball, slider) - -| `keys` -| 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) - -| `lightSensor` -| Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead. -| `high` (number), `left` (number), `low` (number), `player` (number), `top` (number), `trigger` (switch), `x` (number), `y` (number) - -| `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) - -| `patrol` -| Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last. -| `loop` (boolean), `points` (table) - -| `platformer` -| 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 from the tables under game.qte: an attract loop, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the settings, and the game's own add-ons. qte.options.style says how its moves are judged and its levels run: windows (the default: a move answered in a window, compound moves too), held (moves judged by what is held together: combos, mashes, holds, sequences, paths, timed and yes/no branches, tilt, a life bar, die and retry, a new-game menu, a level select), segments (levels of segments, a map between them, the game's own level functions run as they are), checkpoints (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota), or trophies (segmented levels judged the windows way, a difficulty select, a level-clear screen, trophies). The port converter writes the tables from an existing game's script. -| -- - -| `racer` -| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is. -| `reach` (number), `throttle` (number), `track` (track) - -| `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) - -| `shooter` -| Spawns a type from an offset while the var fire is set (or always, with auto), no faster than the rate. -| `auto` (boolean), `offsetX` (number), `offsetY` (number), `rate` (number), `spawns` (type) - -| `soft` -| A mesh look made soft: a cloth, or a body inflated to a pressure. -| `mass` (number), `pressure` (number), `stiffness` (number), `type` (cloth, body) - -| `solid` -| Immovable ground or a wall, in a world2d or a scene3d room. With moving, a body that follows wherever the instance is moved -- a paddle, a lift. -| `moving` (boolean) - -| `sound` -| 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. -| `every` (number), `max` (number), `pick` (turn, random), `points` (table), `spawns` (type), `total` (number) - -| `spin` -| Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second. -| `rate` (number) - -| `target` -| 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. -| `zones` (table) - -| `thrust` -| 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) - -| `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) - -| `trigger` -| A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize. -| -- - -| `turret` -| Fires at the nearest instance of a type within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself. -| `damage` (number), `range` (number), `rate` (number), `spawns` (type), `speed` (number), `targets` (type) - -| `vehicle` -| A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none. -| `mass` (number), `radius` (number), `suspension` (number), `thrust` (number), `torque` (number), `type` (car, motorcycle, tank, boat), `wheelX` (number), `wheelZ` (number), `width` (number) - -| `walker` -| Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes. -| `every` (number), `follow` (string), `radius` (number), `speed` (number), `stopAt` (number) - -| `water` -| Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy. -| `density` (number), `drag` (number) - -|=== - -=== Events - -[cols="1,3,2"] -|=== -| Name | What it does | Parameters - -| `animationDone` -| A clip that does not loop ended on self; event.state says which state it was for. -| `state` (state), `type` (type) - -| `arrived` -| A seeking instance reached its target. -| `type` (type) - -| `branchMissed` -| The window closed without the move and the disc went to the fail frame. -| -- - -| `branchOpen` -| A branch's window opened; event.move says what it wants, event.mash how many times, event.hold for how many frames. -| -- - -| `branchTaken` -| The move was made in time and the disc went to the success frame. -| -- - -| `collision` -| An instance of type a began touching one of type b (self is a, other is b). -| `a` (type), `b` (type) - -| `death` -| self's health reached zero. -| `type` (type) - -| `enter` -| An instance of type b entered a trigger of type a (self is the trigger, other what entered). -| `a` (type), `b` (type) - -| `frame` -| Every frame. -| -- - -| `frameReached` -| The disc passed a frame. -| `frame` (number) - -| `gameOver` -| The game ended. -| -- - -| `hit` -| A gun shot landed on self; other is the gun. -| `player` (number), `type` (type) - -| `leave` -| An instance of type b left a trigger of type a. -| `a` (type), `b` (type) - -| `midi` -| 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.) -| `pitch` (number) - -| `miss` -| A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen. -| `player` (number) - -| `patrolEnd` -| A patrolling instance reached the last of its points. -| `type` (type) - -| `pressed` -| A key or a switch went down. -| `key` (scancode), `player` (number), `switch` (switch) - -| `railEnd` -| A rail camera reached the end of its track. -| -- - -| `released` -| A key or a switch came up. -| `key` (scancode), `player` (number), `switch` (switch) - -| `roomEnd` -| A room is about to be left. -| `room` (room) - -| `roomStart` -| A room has been entered. -| `room` (room) - -| `said` -| 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. -| `noun` (string), `second` (string), `verb` (string) - -| `soundDone` -| A clip that playSound or a sound behaviour started has ended; event.file says which, and self is the instance that played it. -| `file` (file), `type` (type) - -| `spawn` -| self has just been made. -| `type` (type) - -| `stopped` -| A rail camera reached a stop; event.name says which. pathNext moves it on. -| `name` (string) - -| `timer` -| A timer on self went off; event.name says which. -| `name` (string), `type` (type) - -| `verb` -| A verb was used on a hotspot (self), with an item or not: "use key on door". The most specific rule wins. -| `item` (string), `target` (string), `verb` (string) - -|=== - -=== Conditions - -[cols="1,3,2"] -|=== -| Name | What it does | Parameters - -| `below` -| An instance has fallen past a line -- a pit, or the bottom of the screen. -| `entity` (entity), `y` (number) - -| `chance` -| True with a probability, 0 to 1. -| `p` (number) - -| `discBetween` -| The disc is between two frames -- the window a QTE is answered in. -| `from` (number), `to` (number) - -| `every` -| True once every so many seconds, for a rule that fires steadily. -| `seconds` (number), `tag` (string) - -| `has` -| The inventory holds an item. -| `item` (string) - -| `hitPart` -| The shot landed on a named zone of the target: head, weakspot. -| `part` (string) - -| `hover` -| A player's pointer is over the named hotspot. -| `player` (number), `target` (string) - -| `inState` -| An instance's state is this. -| `entity` (entity), `state` (state) - -| `keyHeld` -| A key is down. -| `key` (scancode) - -| `keyPressed` -| A key went down this frame. -| `key` (scancode) - -| `onGround` -| A platformer has ground under it. -| `entity` (entity) - -| `onScreen` -| An instance's box is at least partly on the overlay. -| `entity` (entity) - -| `once` -| Only the first time this rule would run -- per instance and per room unless scope is "game". -| `scope` (string), `tag` (string) - -| `pictureAt` -| The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing. -| `high` (number), `is` (string), `low` (number), `x` (number), `y` (number) - -| `pointerIn` -| A player's pointer is over an instance. -| `entity` (entity), `player` (number) - -| `pointerOffscreen` -| A player's pointer is off the picture, which is how a light gun reloads. -| `player` (number) - -| `switchHeld` -| A pad, gun, or mouse switch is down. -| `switch` (switch) - -| `test` -| An expression is true: self.health <= 0, count("zombie") == 0, score > 1000. -| `expr` (expression) - -| `timeBetween` -| The game is between two moments, in seconds. -| `from` (number), `to` (number) - -| `touching` -| Two instances overlap. -| `entity` (entity), `other` (entity) - -| `waiting` -| A rail camera is held at a stop. -| `entity` (entity) - -|=== - -=== Actions - -[cols="1,3,2,1"] -|=== -| Name | What it does | Parameters | Waits - -| `addScore` -| Add to the score. -| `amount` (number) -| - -| `addVar` -| Add to a var on an instance, or on the game with no entity. -| `amount` (number), `entity` (entity), `name` (string) -| - -| `cameraCut` -| Draw the scene from another camera instance. -| `entity` (entity) -| - -| `credit` -| A coin: one more credit. -| -- -| - -| `damage` -| Take from an instance's health; at zero it dies. -| `amount` (number), `entity` (entity) -| - -| `destroy` -| Remove an instance. -| `entity` (entity) -| - -| `die` -| A death: a message, then the game starts over. -| `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) -| - -| `emit` -| A burst of particles at an instance: an explosion, sparks, a puff. -| `b` (number), `count` (number), `entity` (entity), `g` (number), `r` (number), `speed` (number) -| - -| `face` -| Turn a walker to face left or right, or toward an instance. -| `direction` (string), `entity` (entity), `target` (entity) -| - -| `fade` -| Fade the picture to black, or back (with out false), and wait. -| `out` (boolean), `seconds` (number) -| yes - -| `flash` -| Flash the screen a colour. -| `b` (number), `g` (number), `r` (number), `seconds` (number) -| - -| `gameOver` -| End the game: rules stop, the gameOver event fires, restart begins again. -| -- -| - -| `give` -| Put an item in the inventory. -| `item` (string) -| - -| `goTo` -| Go to a room, taking the named entity along and placing it at a point there -- or where the entity called at stands in that room. -| `at` (string), `entity` (entity), `room` (room), `x` (number), `y` (number), `z` (number) -| - -| `jump` -| Ask a platformer to jump. -| `entity` (entity) -| - -| `loadGame` -| Bring a slot back. -| `slot` (number) -| - -| `lookAt` -| Turn an instance toward another. -| `entity` (entity), `target` (entity) -| - -| `lua` -| Anything the vocabulary cannot say. The way out, and it is meant to be used. -| `code` (lua) -| - -| `moveTo` -| Put an instance somewhere. -| `entity` (entity), `x` (number), `y` (number), `z` (number) -| - -| `nextVerb` -| Cycle to the next verb in the game's list. -| -- -| - -| `pathNext` -| Send a rail camera on from its stop. -| `entity` (entity) -| - -| `playAnimation` -| Play a model's clip, once or looping. -| `clip` (string), `entity` (entity), `loop` (boolean) -| - -| `playMusic` -| Play a track, looping. -| `file` (file) -| - -| `playSound` -| Play a clip, at a volume of its own (0 to 100) under the game's. -| `file` (file), `volume` (number) -| - -| `push` -| Shove a body, or a ragdoll's bone, along a direction. -| `bone` (string), `entity` (entity), `x` (number), `y` (number), `z` (number) -| - -| `reload` -| Fill a gun's ammo. -| `entity` (entity) -| - -| `restart` -| Begin the game again from its first room. -| -- -| - -| `run` -| Drive a platformer left (-1) or right (1) this frame. -| `direction` (number), `entity` (entity) -| - -| `saveGame` -| Keep the whole game -- room, positions, vars, inventory -- in a numbered slot. -| `slot` (number) -| - -| `say` -| Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn. -| `seconds` (number), `text` (expression) -| yes - -| `setState` -| Change an instance's state. -| `entity` (entity), `state` (state) -| - -| `setText` -| Change what a text instance says. -| `entity` (entity), `text` (expression) -| - -| `setVar` -| Set a var on an instance, or on the game with no entity. -| `entity` (entity), `name` (string), `value` (expression) -| - -| `setVerb` -| Choose the verb the next click uses. -| `verb` (string) -| - -| `shake` -| Shake the camera for a moment. -| `amount` (number), `seconds` (number) -| - -| `show` -| Show or hide an instance. -| `entity` (entity), `visible` (boolean) -| - -| `spawn` -| Make an instance of a type at a point. -| `type` (type), `x` (number), `y` (number), `z` (number) -| - -| `spawnAtPointer` -| Make an instance of a type where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D. -| `player` (number), `type` (type) -| - -| `stopMusic` -| Stop the track that is playing. -| -- -| - -| `submitScore` -| Send the score to the master service's board for this game, queued until it can go. -| `board` (string) -| - -| `take` -| Take an item out of the inventory. -| `item` (string) -| - -| `talk` -| Run a dialogue from the game's dialogues, and wait for it to end. -| `dialogue` (string) -| yes - -| `timerStart` -| Start a named timer on an instance. -| `after` (number), `entity` (entity), `name` (string) -| - -| `useItem` -| Choose an inventory item for the next click: "use key on ...". -| `item` (string) -| - -| `wait` -| Wait some seconds before the next action. -| `seconds` (number) -| yes - -| `walkTo` -| Send a walker to a point, and wait until it gets there. -| `entity` (entity), `x` (number), `y` (number), `z` (number) -| yes - -| `walkToHotspot` -| Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait. -| `entity` (entity), `target` (entity) -| yes - -| `walkToPointer` -| Send a walker where the player's pointer is, and wait. -| `entity` (entity), `player` (number) -| yes - -|=== - diff --git a/docs/Learn.adoc b/docs/Learn.adoc index 038874ad3..cb8082584 100644 --- a/docs/Learn.adoc +++ b/docs/Learn.adoc @@ -102,11 +102,11 @@ one changed file name. === This Is Not the Forge Book -Singe has a second book, _Forge_, which teaches you to make a game by -describing it instead of programming it. That is a different road to a -different place, for someone who does not want to write code at all. If you -came here to learn to program, you are in the right place. You do not need to -read the Forge book, and this one does not build on it. +Forge, a separate tool with a book of its own, makes a game by describing it +instead of programming it. That is a different road to a different place, for +someone who does not want to write code at all. If you came here to learn to +program, you are in the right place. You do not need Forge or its book, and +this one does not build on it. The _Singe Reference_ is the reference: every function, every argument, every return value, and the programming model behind them. It is not a tutorial and diff --git a/docs/Manual.adoc b/docs/Manual.adoc index 5c765673d..a87dba6da 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -765,7 +765,7 @@ The window and how the picture fills it. | `-r`, `--rotate=DEGREES` | Turn the whole presentation clockwise by `0`, `90`, `180` or `270` degrees, for a cabinet whose panel is mounted on its side. The video, the overlay, the GUIs, the 3D scene and the particles all turn together, a quarter turn swaps the shape of the picture so a portrait window is filled, and mouse and light gun positions are turned back so a click still lands where it is drawn. Only the four right angles are accepted. A script may change it with `vldpSetRotate`. | `--screen=N` | Open the window on display `N`, counting from `1` as Hypseus counts them. A number larger than the number of displays lists the displays that are there and exits. Default: the primary display. | `-u`, `--stretch` | Stretch the video to fill the window instead of keeping its aspect ratio. -| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. A size larger than the display -- asked for here or by a game's `games.dat` -- is shrunk to fit it, keeping its shape, and the trace says so. +| `-x`, `--xresolution=VALUE` | Window width. The height is taken from the matching entry of the built in resolution table when omitted. A size larger than the display -- asked for here or by a game's `games.dat` -- is shrunk to fit it, keeping its shape, and the trace says so. Run headless, with `SDL_VIDEODRIVER=offscreen`, there is no display to fit, and the window keeps the size asked for. | `-y`, `--yresolution=VALUE` | Window height. |=== @@ -1369,9 +1369,7 @@ since together they are larger than the engine. * *Learn to Program with Singe* teaches programming from nothing, in thirty lessons, to somebody who has never written code. It comes with the scripts and the small art kit the lessons use. -* *Forge* is the authoring tool that makes a game from a description rather - than from code. Its book has ten tutorials and the vocabulary reference. -The download holds the three books as PDF and HTML, the lesson files, and the -licence. Forge itself is a separate download, since it is a Singe game in its -own right. +The download holds the two books as PDF and HTML, the lesson files, and the +licence. Forge, the authoring tool that makes a game from a description rather +than from code, is a separate project with its own download and its own book. diff --git a/docs/Reference.adoc b/docs/Reference.adoc index aa2291978..ee6f9ecd8 100644 --- a/docs/Reference.adoc +++ b/docs/Reference.adoc @@ -2430,9 +2430,7 @@ die. A game can be written as a *description* -- a table of layers, entities and rules -- and compiled into an ordinary Singe game by Forge, the authoring -tool. Forge is distributed on its own, beside the engine, and has its own -manual: `docs/Forge.adoc` in the source tree, built to `Forge.html` and -`Forge.pdf` beside this one. +tool. Forge is a separate project with its own download and its own book. [[migrating]] @@ -14312,7 +14310,7 @@ end result = singePack(directory, database) ---- -Packs the game in `directory` into the single-file database `database`, exactly as `singe --pack` does from the command line, and returns `true` when it succeeded. The directory has to be a game's own folder: one that contains no `Singe/` support folder, no launcher, and nothing else the packer refuses, and the reasons for a refusal are printed to the console as they are for the command line. This is what lets an authoring tool release a game without leaving the engine; Forge's Publish presses it after an export. +Packs the game in `directory` into the single-file database `database`, exactly as `singe --pack` does from the command line, and returns `true` when it succeeded. The directory has to be a game's own folder: one that contains no `Singe/` support folder, no launcher, and nothing else the packer refuses, and the reasons for a refusal are printed to the console as they are for the command line. This is what lets an authoring tool release a game without leaving the engine. *Parameters:* @@ -14545,7 +14543,7 @@ singeScreenshot() singeScreenshot(name) ---- -Requests a screenshot of the whole window, letterbox included, taken after the next frame is drawn and saved as a PNG in the game's data directory (see `singeGetDataPath`). Without a name, files are named `singe000.png` upward; each script run scans from zero for the first free name and later shots continue past the last one saved. With a name the shot goes to that file, `.png` added when the name has no extension, replacing any file of that name; a name with a directory in it aborts the script. Naming the shot is for scripts that produce pictures on purpose, such as the ones that illustrate the Forge tutorials, so a rerun replaces the picture rather than adding one. The call forces a redraw so the shot is taken even while the display is idle. The switch mapped to `INPUT_SCREENSHOT` in `controls.cfg` does the same thing. +Requests a screenshot of the whole window, letterbox included, taken after the next frame is drawn and saved as a PNG in the game's data directory (see `singeGetDataPath`). Without a name, files are named `singe000.png` upward; each script run scans from zero for the first free name and later shots continue past the last one saved. With a name the shot goes to that file, `.png` added when the name has no extension, replacing any file of that name; a name with a directory in it aborts the script. Naming the shot is for scripts that produce pictures on purpose, such as the ones that illustrate a book's tutorials, so a rerun replaces the picture rather than adding one. The call forces a redraw so the shot is taken even while the display is idle. The switch mapped to `INPUT_SCREENSHOT` in `controls.cfg` does the same thing. *Since:* 1.x *See also:* <> diff --git a/docs/images/tutorials/01-chooser.png b/docs/images/tutorials/01-chooser.png deleted file mode 100644 index 265096e4b..000000000 --- a/docs/images/tutorials/01-chooser.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c028493859e6552a7b08f19beb280553d9dff4975b707730a08ecfdbc6d39180 -size 35431 diff --git a/docs/images/tutorials/01-entities.png b/docs/images/tutorials/01-entities.png deleted file mode 100644 index e06ddf578..000000000 --- a/docs/images/tutorials/01-entities.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:72b99ef2fdbb3abe118261b258c34f4bcdb6963909fcf23ac45edb0ca6e35933 -size 16685 diff --git a/docs/images/tutorials/01-moved.png b/docs/images/tutorials/01-moved.png deleted file mode 100644 index 126958f5f..000000000 --- a/docs/images/tutorials/01-moved.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0ec267d81fccbc97b0d1d9d19ec99b2a753da244386032d0acaacbb45e9074f3 -size 11564 diff --git a/docs/images/tutorials/01-playing.png b/docs/images/tutorials/01-playing.png deleted file mode 100644 index f096bf5a3..000000000 --- a/docs/images/tutorials/01-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2ea66dd60665e0028256361daf0cb98ba39a0b36ac9d01b0ea3a46b6ae2725d4 -size 2798 diff --git a/docs/images/tutorials/01-recoloured.png b/docs/images/tutorials/01-recoloured.png deleted file mode 100644 index 8a1534c09..000000000 --- a/docs/images/tutorials/01-recoloured.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c09fadd6444ff287a1ccc4f745dc421d5348647a93ed00caa898ff7e1b8590bd -size 15569 diff --git a/docs/images/tutorials/01-rules.png b/docs/images/tutorials/01-rules.png deleted file mode 100644 index 7c23c94e0..000000000 --- a/docs/images/tutorials/01-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f72fe849d79d868da4bc2f1e961ba264614c78a4d8f425065a6e3140a3673159 -size 17191 diff --git a/docs/images/tutorials/01-saved.png b/docs/images/tutorials/01-saved.png deleted file mode 100644 index 6d0bb060c..000000000 --- a/docs/images/tutorials/01-saved.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:695263893b3a514231ae855af7ec1a70aa268a4081641d1f45b351903d2f7fa8 -size 2765 diff --git a/docs/images/tutorials/01-types.png b/docs/images/tutorials/01-types.png deleted file mode 100644 index 43becfc74..000000000 --- a/docs/images/tutorials/01-types.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:89d5bcf43e50dc8fb542511e01f71c0e8d9d56456cf2e9fd9ed82280360a306a -size 12874 diff --git a/docs/images/tutorials/02-cave.png b/docs/images/tutorials/02-cave.png deleted file mode 100644 index db346217a..000000000 --- a/docs/images/tutorials/02-cave.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6b90a9099a42e7a2bc47d85eff1c3166b233568737d7eaf33e9c1edf1043f99 -size 11386 diff --git a/docs/images/tutorials/02-coinType.png b/docs/images/tutorials/02-coinType.png deleted file mode 100644 index 3fb146720..000000000 --- a/docs/images/tutorials/02-coinType.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:705ffd60e70776524306167cea83f69c01a74819d9fea53ea16d08d276fb1901 -size 12634 diff --git a/docs/images/tutorials/02-coins.png b/docs/images/tutorials/02-coins.png deleted file mode 100644 index efba558e6..000000000 --- a/docs/images/tutorials/02-coins.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c90cd154c2fe5b5eded1757a5088d5b76613a69513998b32a4b7bf33d3202e39 -size 14340 diff --git a/docs/images/tutorials/02-placed.png b/docs/images/tutorials/02-placed.png deleted file mode 100644 index bdf7987b1..000000000 --- a/docs/images/tutorials/02-placed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eb7c046008db042e4762b3e59b39b99f18ce4c2bed47b712c816e1c882d4bd33 -size 15727 diff --git a/docs/images/tutorials/02-playing.png b/docs/images/tutorials/02-playing.png deleted file mode 100644 index 6ff2ad306..000000000 --- a/docs/images/tutorials/02-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1dc173bdc619298119d47e2f19f808379e220e0e4456ffaba82bc7b059d33485 -size 3739 diff --git a/docs/images/tutorials/02-rule.png b/docs/images/tutorials/02-rule.png deleted file mode 100644 index c632cb387..000000000 --- a/docs/images/tutorials/02-rule.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f0102401e69465330cd36f95e28291304fe89292d78b72a7af3aadbeb19becb2 -size 19033 diff --git a/docs/images/tutorials/02-rules.png b/docs/images/tutorials/02-rules.png deleted file mode 100644 index ca902633f..000000000 --- a/docs/images/tutorials/02-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66017dfafa69948a4b53449e35f124acf4cbd723fe20cde107d53b047539ce0b -size 18113 diff --git a/docs/images/tutorials/03-coinSpin.png b/docs/images/tutorials/03-coinSpin.png deleted file mode 100644 index 2199fb1ac..000000000 --- a/docs/images/tutorials/03-coinSpin.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:59f1bd0f049ca7e89c880c981efa59745feecbaf229696f0e0d949f4c96febe1 -size 16785 diff --git a/docs/images/tutorials/03-heroType.png b/docs/images/tutorials/03-heroType.png deleted file mode 100644 index db2c9ec0f..000000000 --- a/docs/images/tutorials/03-heroType.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8e961f7c473d259cd70481155f0b1295fd58179d1f5f6d77db1b7c3ffad5cf64 -size 17913 diff --git a/docs/images/tutorials/03-jumpRule.png b/docs/images/tutorials/03-jumpRule.png deleted file mode 100644 index 3cd6c3318..000000000 --- a/docs/images/tutorials/03-jumpRule.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b66a966da8ffdd28c71d94e885df96c5cd9be90aeeb2a681e67d978cce20ab30 -size 20675 diff --git a/docs/images/tutorials/03-music.png b/docs/images/tutorials/03-music.png deleted file mode 100644 index 2adf9a229..000000000 --- a/docs/images/tutorials/03-music.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:895d07d995a11e3972d142aebfb61ea24879518089b949524f39daa0611fbc57 -size 19334 diff --git a/docs/images/tutorials/03-playing.png b/docs/images/tutorials/03-playing.png deleted file mode 100644 index aa8ae34e8..000000000 --- a/docs/images/tutorials/03-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8648885c982b68256fbc0e8d8587a7b6565b40f23015fd69ae28278f13dffab7 -size 4283 diff --git a/docs/images/tutorials/04-cleared.png b/docs/images/tutorials/04-cleared.png deleted file mode 100644 index aec5b3bb3..000000000 --- a/docs/images/tutorials/04-cleared.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:caaa77ea90c065fac9eeb0bacd46f988309e065120e875298589097388454c10 -size 14101 diff --git a/docs/images/tutorials/04-hitRule.png b/docs/images/tutorials/04-hitRule.png deleted file mode 100644 index 4a69ffedf..000000000 --- a/docs/images/tutorials/04-hitRule.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49344adc0cb26b04c9c89364d91158674c049fb6a7a6bac4a68371cf3d40349c -size 15521 diff --git a/docs/images/tutorials/04-placed.png b/docs/images/tutorials/04-placed.png deleted file mode 100644 index c6c0f9c8e..000000000 --- a/docs/images/tutorials/04-placed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4da81686590dd2d7f879a6a2828ec9f667dc5e749c58ce79883384803ba39e97 -size 13359 diff --git a/docs/images/tutorials/04-playing.png b/docs/images/tutorials/04-playing.png deleted file mode 100644 index 649133e05..000000000 --- a/docs/images/tutorials/04-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:501158b363cafd5dde42320a0c6324ca3205dcced2e5f7cdbb15303a12ffe969 -size 7814 diff --git a/docs/images/tutorials/04-rules.png b/docs/images/tutorials/04-rules.png deleted file mode 100644 index 8e9f5f9c9..000000000 --- a/docs/images/tutorials/04-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:399821a2a36e5ac136847001ed3c26705815d9d1ed8358a13da7d20ebb9d8cf2 -size 21865 diff --git a/docs/images/tutorials/04-ship.png b/docs/images/tutorials/04-ship.png deleted file mode 100644 index 6d4d4befb..000000000 --- a/docs/images/tutorials/04-ship.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bc1de6397df2334eee95b92e48c70b1cdb2b1156fba1febe9e6db97010172884 -size 21221 diff --git a/docs/images/tutorials/04-types.png b/docs/images/tutorials/04-types.png deleted file mode 100644 index b5aa1c7ce..000000000 --- a/docs/images/tutorials/04-types.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e1e57311d37586d59e6f157bac532976cbf5ba5bfb091c0b700fdbb6cda745b1 -size 15872 diff --git a/docs/images/tutorials/05-disc.png b/docs/images/tutorials/05-disc.png deleted file mode 100644 index 8518b8e4a..000000000 --- a/docs/images/tutorials/05-disc.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b903cd121a50f315b57a23425b3c9f437fecdbcd3c604deca825ed50d31d65b3 -size 18508 diff --git a/docs/images/tutorials/05-playing.png b/docs/images/tutorials/05-playing.png deleted file mode 100644 index 0913de59e..000000000 --- a/docs/images/tutorials/05-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ff6be9544b1ba3f2f612d7f791dcdd1b5a8531247e6167ae2910fe4eab1411f9 -size 7003 diff --git a/docs/images/tutorials/05-prompts.png b/docs/images/tutorials/05-prompts.png deleted file mode 100644 index 1e66eb525..000000000 --- a/docs/images/tutorials/05-prompts.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d21beac9dd1fa2595b5293ca43bec2dc783a5e35bd7f3c4162120addaba3b27f -size 13023 diff --git a/docs/images/tutorials/05-rules.png b/docs/images/tutorials/05-rules.png deleted file mode 100644 index e76deb20d..000000000 --- a/docs/images/tutorials/05-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f6893c6e75e2e59e9e346cbb7d82dc0b4c90b67f61579940229841e9f1a367fb -size 23124 diff --git a/docs/images/tutorials/05-window.png b/docs/images/tutorials/05-window.png deleted file mode 100644 index 08faf0d88..000000000 --- a/docs/images/tutorials/05-window.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:915bdfcfe0ecb969ffd8d89dee385f0c8613ff154dd3b2a396cd13c06c1e0025 -size 21636 diff --git a/docs/images/tutorials/06-firstKey.png b/docs/images/tutorials/06-firstKey.png deleted file mode 100644 index 14600e3c8..000000000 --- a/docs/images/tutorials/06-firstKey.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ec28599720a324952caa090e52421ce701f09a39d9953ac1bd523e2f528c470 -size 16097 diff --git a/docs/images/tutorials/06-playing.png b/docs/images/tutorials/06-playing.png deleted file mode 100644 index a6fd624d7..000000000 --- a/docs/images/tutorials/06-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:309c3445be3c5b06f909f5677ff477072f13cdd0d58b8d313a2f89cfaeda7175 -size 11652 diff --git a/docs/images/tutorials/06-rules.png b/docs/images/tutorials/06-rules.png deleted file mode 100644 index e8db33eda..000000000 --- a/docs/images/tutorials/06-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7830a60a15932805a1f49bc60195b48fc7b837445bc9aca4b51a56cf44b271a1 -size 24216 diff --git a/docs/images/tutorials/06-tracks.png b/docs/images/tutorials/06-tracks.png deleted file mode 100644 index e9256b846..000000000 --- a/docs/images/tutorials/06-tracks.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:08de1d007822ae9084cde3bec5d0c08e03c6de49bd25703f96e6f4df5a41ef74 -size 19079 diff --git a/docs/images/tutorials/06-types.png b/docs/images/tutorials/06-types.png deleted file mode 100644 index ef87ab308..000000000 --- a/docs/images/tutorials/06-types.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c193b467e147ff4abf42d14acfef8691a0851a60492b084c053d142edc8e6804 -size 12488 diff --git a/docs/images/tutorials/07-dialogue.png b/docs/images/tutorials/07-dialogue.png deleted file mode 100644 index 408e9a9c4..000000000 --- a/docs/images/tutorials/07-dialogue.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:915555d16cb7e516158ac6443f1893470fcf17b1b9ac163cd3f8944115f32a5d -size 23619 diff --git a/docs/images/tutorials/07-hall.png b/docs/images/tutorials/07-hall.png deleted file mode 100644 index 2f767a7b6..000000000 --- a/docs/images/tutorials/07-hall.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c31d1732673a505541ac754986d150121c3ce33a14a0d6999ad77b732fe27b0e -size 19172 diff --git a/docs/images/tutorials/07-hotspots.png b/docs/images/tutorials/07-hotspots.png deleted file mode 100644 index d245740d7..000000000 --- a/docs/images/tutorials/07-hotspots.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bc5033bb6ba89287e2a064b772860fa8dfae9bab4b873edbcf4f680dace5743a -size 22025 diff --git a/docs/images/tutorials/07-playing.png b/docs/images/tutorials/07-playing.png deleted file mode 100644 index 1c0091819..000000000 --- a/docs/images/tutorials/07-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f86151f4a19647e47a24125093a269c1cb35b9b30178b40fe1296d8d8de0668d -size 12644 diff --git a/docs/images/tutorials/07-verbRules.png b/docs/images/tutorials/07-verbRules.png deleted file mode 100644 index 79dc52d23..000000000 --- a/docs/images/tutorials/07-verbRules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bc7d958be89ec657c6faf42c5b53cc05eea807e961a66e3480d131903d47f422 -size 25808 diff --git a/docs/images/tutorials/07-walkArea.png b/docs/images/tutorials/07-walkArea.png deleted file mode 100644 index 41e703522..000000000 --- a/docs/images/tutorials/07-walkArea.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5d65271b1c29a5b1a2da7bca06b0336f2ed8f5bb86e8f470f75dfe259d03ec47 -size 20184 diff --git a/docs/images/tutorials/08-hero.png b/docs/images/tutorials/08-hero.png deleted file mode 100644 index 15a76f16d..000000000 --- a/docs/images/tutorials/08-hero.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:986a96057ba84f22281d3ccc171b421800bb957f122755cdb56719486cfe2a3c -size 29331 diff --git a/docs/images/tutorials/08-meshes.png b/docs/images/tutorials/08-meshes.png deleted file mode 100644 index 0d3fb6e7e..000000000 --- a/docs/images/tutorials/08-meshes.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9ccba18dd3f0765563b49e46312bb9f46c62f1464cd4c0ce95857bdf5327d8ff -size 30334 diff --git a/docs/images/tutorials/08-playing.png b/docs/images/tutorials/08-playing.png deleted file mode 100644 index 8a472e49d..000000000 --- a/docs/images/tutorials/08-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a17ace2deb1ddb0f320b9ffa79c2f17e744651975cd0942549b1cc2036457621 -size 22681 diff --git a/docs/images/tutorials/08-rules.png b/docs/images/tutorials/08-rules.png deleted file mode 100644 index 4682288c1..000000000 --- a/docs/images/tutorials/08-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9cf674b29abc1d1056d52b8ab41f2197d148b40331c3edacdf16d665f23aabdc -size 31335 diff --git a/docs/images/tutorials/08-scene.png b/docs/images/tutorials/08-scene.png deleted file mode 100644 index dabcf2d4b..000000000 --- a/docs/images/tutorials/08-scene.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6e09a6c14ab9babdfe1effb437a29494416ea82b96c5daf4b5af6602b1d748dc -size 32808 diff --git a/docs/images/tutorials/08-viewport.png b/docs/images/tutorials/08-viewport.png deleted file mode 100644 index 7d5e48c2a..000000000 --- a/docs/images/tutorials/08-viewport.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a67b57ee3821c1a02810eadfecfbc112171c4b31539b063145f150db12c1a7e1 -size 27881 diff --git a/docs/images/tutorials/09-hitRule.png b/docs/images/tutorials/09-hitRule.png deleted file mode 100644 index e788c1b1e..000000000 --- a/docs/images/tutorials/09-hitRule.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b092d640bac349b7faa191582123a3411f40e71c88c52f55d38dbfe04ccb4093 -size 39825 diff --git a/docs/images/tutorials/09-placed.png b/docs/images/tutorials/09-placed.png deleted file mode 100644 index d13f8ee3e..000000000 --- a/docs/images/tutorials/09-placed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b64b5c3063c58fbcb39ef57f88a4fe43c1a0e46acade411c2ff65dbd827d9e5c -size 31931 diff --git a/docs/images/tutorials/09-playing.png b/docs/images/tutorials/09-playing.png deleted file mode 100644 index 696ec8586..000000000 --- a/docs/images/tutorials/09-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8dad7bf1d0f75a7e1ec049b59acb62ed73be7d87dd1a44e31f0aa32d0432abc0 -size 18131 diff --git a/docs/images/tutorials/09-rail.png b/docs/images/tutorials/09-rail.png deleted file mode 100644 index 11e52a83b..000000000 --- a/docs/images/tutorials/09-rail.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cf6370f808983ca01e57c5902f29e4d3304d3c106b5977ec2f715dbf89d9c575 -size 31045 diff --git a/docs/images/tutorials/09-room.png b/docs/images/tutorials/09-room.png deleted file mode 100644 index c2bcff697..000000000 --- a/docs/images/tutorials/09-room.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d98365c22d6c466b2ef755d3c0dbcbb9796eb27b7f76b6b75ce10a89032d6fd8 -size 30509 diff --git a/docs/images/tutorials/09-rules.png b/docs/images/tutorials/09-rules.png deleted file mode 100644 index 991bc561a..000000000 --- a/docs/images/tutorials/09-rules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:99fb0221b45047385dc3d83c99fe951d59c2ebb5110c347d36dbbfda37c53df5 -size 42166 diff --git a/docs/images/tutorials/09-zombie.png b/docs/images/tutorials/09-zombie.png deleted file mode 100644 index e698ef1a2..000000000 --- a/docs/images/tutorials/09-zombie.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fc3d14d6f7072d6bd477ace84b7509c2d4294346b8e5a77bf8bef5bd66e779b6 -size 29458 diff --git a/docs/images/tutorials/10-arcadeRules.png b/docs/images/tutorials/10-arcadeRules.png deleted file mode 100644 index 9a7ba37e9..000000000 --- a/docs/images/tutorials/10-arcadeRules.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ab03d1d0915333d23424fa8694da35e579caf0d6e5ed31f54e4cef36ccd9a9ba -size 19257 diff --git a/docs/images/tutorials/10-bezel.png b/docs/images/tutorials/10-bezel.png deleted file mode 100644 index 3284f7bf3..000000000 --- a/docs/images/tutorials/10-bezel.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4380b3a5397761d3930323a9ced5c2f9e11b95368d15c8a502fe11cd99daefb7 -size 20793 diff --git a/docs/images/tutorials/10-playing.png b/docs/images/tutorials/10-playing.png deleted file mode 100644 index 9e7bd6431..000000000 --- a/docs/images/tutorials/10-playing.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d1da8eaaa842f7f3b25b8201a21ee18c56f5442de268c8bb665ab3b433f67521 -size 7444 diff --git a/docs/images/tutorials/10-released.png b/docs/images/tutorials/10-released.png deleted file mode 100644 index 27788fd27..000000000 --- a/docs/images/tutorials/10-released.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6f3b9183e7e40777eb24935ccf908db229d5ff80b97b997087b83ea09061350e -size 19262 diff --git a/docs/lessons/31-cabinet.adoc b/docs/lessons/31-cabinet.adoc index 70c17c844..136deb9ae 100644 --- a/docs/lessons/31-cabinet.adoc +++ b/docs/lessons/31-cabinet.adoc @@ -706,8 +706,9 @@ along, doing real work with the same functions you have. Working out how somebody else's program does something is a skill of its own, and those are a kind place to practise it. -And there is a second book, _Forge_, for describing a game rather than writing -one. It is a different road. You do not need it, but it is there. +And there is Forge, a separate tool with a book of its own, for describing a +game rather than writing one. It is a different road. You do not need it, but +it is there. The next game is yours. Make the small one first, finish it, and put it in front of somebody. Finishing is the part nobody tells you is the hard part, and diff --git a/forge/Author.singe b/forge/Author.singe deleted file mode 100644 index 4046ff035..000000000 --- a/forge/Author.singe +++ /dev/null @@ -1,14807 +0,0 @@ ---[[ - * - * Singe 3 - * Copyright (C) 2006-2026 Scott Duensing - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 3 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA - * 02110-1301, USA. - * - * ---]] - - - --- The runtime an authored game is compiled against (FORGE.md). --- --- A game made with Forge is not interpreted: Forge/AuthorCompile.singe turns its description into --- ordinary Lua, and this file is the library that Lua calls. Rules become real functions, so --- nothing walks a table every frame, and the result can be opened in a Lua editor and edited by hand --- like any other game. --- --- The nouns (FORGE.md section 3): --- --- layers which of the engine's own layers the game uses. A genre is only a choice of layers. --- types what a thing is: a look, behaviours, and vars. Everything placed or spawned is --- an instance of a type. --- rooms where things are. A room keeps its state when it is left unless it says reset. --- rules a trigger, conditions, and actions. Actions that take time run as a coroutine. --- --- AUTHOR is also the manifest the compiler and the editor read: every layer, look, behaviour, --- condition, action, and event declares its parameters and the Lua it emits, so a new kind of --- game is a set of entries here rather than a new release of anything. --- --- The manifest's emit functions receive their parameters already as Lua source fragments -- the --- compiler has turned a number into a number, an expression into Lua, an entity name into a lookup --- -- so an entry only concatenates. The parameter types (FORGE.md section 3): --- --- number a number, or an expression that makes one --- expression any expression --- string text, quoted --- boolean true or false --- entity "self", "other", or an entity id --- type a type's name --- scancode a key, from SCANCODE --- switch a switch, from SWITCH_* --- file a file name --- state a state name --- track a track's name --- room a room's name --- lua Lua, as written - -AUTHOR = { - layers = {}, - looks = {}, - behaviours = {}, - conditions = {}, - actions = {}, - events = {} -} - -AUTHOR_GAME = nil -- The description handed to authorBegin. -AUTHOR_VARS = {} -- Game scope vars: score, lives, flags, whatever the game declared. -AUTHOR_LIVE = {} -- Instances in the current room, in draw order. -AUTHOR_BY_ID = {} -- Live instances by id. -AUTHOR_BY_NODE = {} -- Live instances by node, for what physics and rays report. -AUTHOR_3D = false -- Whether the game has a scene3d layer. -AUTHOR_MOUSE = { dx = 0, dy = 0 } -- Relative motion this frame, for a camera that looks. -AUTHOR_ROOM = nil -- The current room's table. -AUTHOR_STARTED = 0 -- singeGetTicks() when the game began. -AUTHOR_CONTROLS = true -- False while a cut-scene has the controls. -AUTHOR_FONT = nil -AUTHOR_FONT_FILE = "Singe/FreeSansBold.ttf" -- Forge's own font, for text a game gives no font -AUTHOR_FONT_POINTS = 18 - -local GRAVITY_DEFAULT = 1400 -- Overlay units a second squared; a 480 tall screen wants about this. -local PLAYER_RADIUS = 0.5 -- Fractions of an entity's box, for the capsule a platformer stands in. -local MOUSE_LEFT = nil -- Filled from SWITCH_BUTTON3 once Framework is in: the gun's trigger. -local SAY_SECONDS_MIN = 1.5 -- A line stays up at least this long, -local SAY_PER_CHAR = 0.05 -- plus this per character. -local SOUND_MAX_VOLUME = 63 -- soundPlay's loudest; Forge volumes are 0 to 100 of it. -local PLATFORMER_STATES = { idle = true, walk = true, jump = true, fall = true } -- The states a platformer sets itself. -local AUTHOR_GROUND = { r = 18, g = 18, b = 26 } -- Behind a flat game with no disc: the editor's own dark. -local SPAWN_MARGIN = 40 -- How far off the overlay a projectile may go before it is dropped. -local FRAME_CAP = 0.1 -- The longest step a frame may take, so a stall does not teleport. -local RAY_REACH = 500 -- How far a gun's ray goes, in world units. -local FAR_AWAY = 300 -- A 3D projectile past this from the origin is dropped. -local LOOK_SPEED = 0.2 -- Degrees of turn per pixel of mouse motion. -local PITCH_LIMIT = 80 -- Degrees a looking camera may tilt. -local models = {} -- Loaded models by file. -local meshes = {} -- Built primitives by description. -local cameraNode = nil -- The node the scene is drawn from, when a camera behaviour has one. -local NAV_SCALE = 0.01 -- Overlay pixels to navigation units in a 2D room: a 720 wide room is 7.2 across. -local NAV_RADIUS_2D = 0.08 -- Agents in a 2D room, in navigation units: eight pixels. -local NAV_HEIGHT_2D = 0.5 -local WALK_TIMEOUT = 12 -- Seconds a walkTo waits before giving up on an unreachable point. -local FADE_DEFAULT = 0.6 -- Seconds a fade takes when the rule does not say. -local PICTURE_BRIGHT = 127 -- A light sensor's thresholds: how near white on every channel -local PICTURE_DARK = 20 -- reads as bright, how near black reads as dark, between them unknown. -local CHOICE_KEYS = { "MAIN_1", "MAIN_2", "MAIN_3", "MAIN_4", "MAIN_5", "MAIN_6", "MAIN_7", "MAIN_8", "MAIN_9" } -local dialogues = {} -- Compiled dialogues by name, from authorDialogues. -local talking = nil -- The dialogue on screen: { lines, choices, chosen }. -local fadeLevel = 0 -- 0 clear, 1 black. -local parserLayer = nil -- The parser layer, when the game has one. -AUTHOR_TYPING = "" -- What has been typed at the parser's prompt. -local bezel = nil -- The score bezel layer, when the game has one. -local results = nil -- The results card after gameOver: { score, best }. -local masterReady = false -- Whether the master service's script has been loaded. -local BEST_KEY = "forge.best" - -local rules = {} -- Compiled rules by event name. -local rulesReady = false -- authorRules has been called; before it, events are kept for it. -local pending = {} -- Events raised before the rules arrived: the first room's start. -local pairsToTest = {} -- Kind pairs the collision rules ask about. -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. -local pressedNow = {} -- Keys and switches that went down this frame. -local lastTime = 0 -local lastFrame = -1 -local nextSerial = 1 -local hud = nil -- { gui, document, bind } -local sayLine = nil -- { text, until } -local flashLine = nil -- { r, g, b, until, seconds } -local gameOver = false -local music = nil - - --- ===== Small helpers ========================================================================== - --- Seconds since the game started. Every rule that talks about time uses this, so a game is --- reproducible under --deterministic rather than depending on how fast the machine draws. -function authorTime() - return (singeGetTicks() - AUTHOR_STARTED) / 1000.0 -end - - -function authorEntity(id) - return AUTHOR_BY_ID[id] -end - - --- Every live instance of a type, in draw order. -function authorEach(typeName) - local list = {} - - for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive and (instance.type == typeName) then - list[#list + 1] = instance - end - end - - return list -end - - -function authorCount(typeName) - return #authorEach(typeName) -end - - --- Where an instance is, in overlay coordinates. Everything is a node, whether or not the scene is --- drawing: nodes exist without a GPU, which is what lets a 2D game run on a machine with none. -function authorPosition(instance) - local x, y, z = nodeGetPosition(instance.node) - - return x, y, z -end - - -function authorX(instance) - local x = nodeGetPosition(instance.node) - - return x -end - - -function authorY(instance) - local _, y = nodeGetPosition(instance.node) - - return y -end - - --- An instance's field, for expressions: x, y, z, id, type, state, or one of its vars. -function authorField(instance, name) - if instance == nil then - return nil - end - if name == "x" then - return authorX(instance) - elseif name == "y" then - return authorY(instance) - elseif name == "z" then - local _, _, z = nodeGetPosition(instance.node) - - return z - elseif (name == "id") or (name == "type") then - return instance[name] - end - - return instance.vars[name] -end - - --- The distance between two instances -- either may be given by id -- across the picture in 2D --- and through the scene in 3D. -function authorDistance(a, b) - if type(a) == "string" then - a = AUTHOR_BY_ID[a] - end - if type(b) == "string" then - b = AUTHOR_BY_ID[b] - end - if (a == nil) or (b == nil) then - return math.huge - end - local ax, ay, az = authorPosition(a) - local bx, by, bz = authorPosition(b) - - if AUTHOR_3D then - return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2) - end - - return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2) -end - - -function authorRandom(low, high) - if high == nil then - return math.random() * (low or 1) - end - - return low + math.random() * (high - low) -end - - -function authorHas(item) - for _, held in ipairs(AUTHOR_VARS.inventory or {}) do - if held == item then - return true - end - end - - return false -end - - --- How big an instance is, from its look and its own scale. Every look answers. -function authorSize(instance) - local look = AUTHOR.looks[instance.look.kind] - local scale = instance.scale or 1 - local w, h, d - - if look and look.size then - w, h, d = look.size(instance) - else - w, h, d = instance.look.w or 0, instance.look.h or 0, instance.look.d or 0 - end - - return w * scale, h * scale, (d or 0) * scale -end - - --- The look's own size, before the entity's scale: what a body on the node wants, since bodies --- take the node's scale themselves. -function authorUnscaledSize(instance) - local w, h, d = authorSize(instance) - local scale = instance.scale or 1 - - return w / scale, h / scale, d / scale -end - - --- An instance's box in overlay coordinates, centred on it, or standing on it when its look says --- anchor feet, and scaled with depth in a room that scales. -function authorBounds(instance) - local x, y = authorPosition(instance) - local w, h = authorSize(instance) - local scale = authorDepthScaleOf(instance) - - w = w * scale - h = h * scale - if instance.look.anchor == "feet" then - return x - w / 2, y - h, w, h - end - - return x - w / 2, y - h / 2, w, h -end - - --- How big a thing at a height on the picture is drawn, in a room whose scaleBy says things --- further up the picture are further away. 1 everywhere else. --- Where an instance sorts in a room that sorts by depth: its y, except that a line of text is a --- readout and stays in front of everything. -function authorDepthY(instance) - if instance.look.kind == "text" then - return math.huge - end - - return authorY(instance) -end - - --- The room's depth scale for an instance: what stands on the floor (anchor feet) shrinks with --- distance; a backdrop, a readout, or a thing floating in the air is drawn as it is. -function authorDepthScaleOf(instance) - if instance.look.anchor ~= "feet" then - return 1 - end - - return authorDepthScale(select(2, authorPosition(instance))) -end - - -function authorDepthScale(y) - local table_ = AUTHOR_ROOM and AUTHOR_ROOM.scaleBy or nil - - if (table_ == nil) or (#table_ < 2) then - return 1 - end - local before, after - - for _, key in ipairs(table_) do - if (key.y <= y) and ((before == nil) or (key.y > before.y)) then - before = key - end - if (key.y >= y) and ((after == nil) or (key.y < after.y)) then - after = key - end - end - before = before or after - after = after or before - if (before == after) or (after.y == before.y) then - return before.scale - end - - return before.scale + (after.scale - before.scale) * (y - before.y) / (after.y - before.y) -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. 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 - - return name -end - - --- A switch named the same way. -function authorSwitchValue(name) - if type(name) == "string" then - return _G[name] - end - - return name -end - - --- 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 folder .. name - end - end - end - end - - return 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) - - if sprites[key] == nil then - if (frames or 1) > 1 then - sprites[key] = spriteLoadFrames(frames, authorFile(file)) - else - sprites[key] = spriteLoad(authorFile(file)) - end - end - - return sprites[key] -end - - -local function soundFor(file) - if sounds[file] == nil then - sounds[file] = soundLoad(authorFile(file)) - end - - return sounds[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. - -AUTHOR.layers.world2d = { - help = "A flat world with gravity: physics in the XY plane, drawn into the overlay.", - params = { gravity = "number" }, - begin = function(layer) - physicsSetEnabled(true) - physicsSet2D(true) - physicsSetGravity(0, layer.gravity or GRAVITY_DEFAULT, 0) - end -} - -AUTHOR.layers.overlay = { - help = "Flat drawing over everything: sprites, scores, prompts.", - params = {}, - begin = function() end -} - -AUTHOR.layers.disc = { - help = "The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs.", - params = { file = "file", start = "number" }, - begin = function(layer) - discPlay() - if layer.start then - discSkipToFrame(layer.start) - end - end -} - -AUTHOR.layers.hud = { - help = "An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change.", - params = { document = "file", bind = "table" }, - begin = function(layer) - local gui = guiNew(overlayGetWidth(), overlayGetHeight()) - - hud = { gui = gui, document = guiLoad(gui, authorFile(layer.document)), bind = layer.bind or {}, shown = {} } - end -} - -AUTHOR.layers.music = { - help = "A track that plays from the start, looping.", - params = { file = "file", volume = "number" }, - begin = function(layer) - music = musicLoad(authorFile(layer.file)) - if layer.volume then - musicSetVolume(layer.volume) - end - musicPlay(music, -1) - end -} - - -AUTHOR.layers.scene3d = { - help = "The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units.", - params = { gravity = "number", sky = "file", ambient = "table", fog = "table", sun = "table", fov = "number", exposure = "number" }, - begin = function(layer) - local ok, err = pcall(sceneEnable, true) - - if not ok then - debugPrint("Author: the 3D scene could not start: " .. tostring(err)) - end - AUTHOR_3D = true - physicsSetEnabled(true) - physicsSet2D(false) - physicsSetGravity(0, layer.gravity or -9.8, 0) - if layer.sky then - sceneSetSky(authorFile(layer.sky)) - end - if layer.ambient then - sceneSetAmbient(layer.ambient.r or 40, layer.ambient.g or 40, layer.ambient.b or 50) - end - if layer.fog then - sceneSetFog(layer.fog.r or 0, layer.fog.g or 0, layer.fog.b or 0, layer.fog.near or 30, layer.fog.far or 80) - end - if layer.exposure then - sceneSetExposure(layer.exposure) - end - if layer.sun ~= false then - local sun = layer.sun or {} - local light = lightNew(LIGHT_DIRECTIONAL) - - nodeSetPosition(light, sun.x or -5, sun.y or 10, sun.z or 6) - nodeLookAt(light, 0, 0, 0) - lightSetIntensity(light, sun.intensity or 1.5) - lightSetShadow(light, sun.shadow ~= false) - end - cameraSetPerspective(layer.fov or 60, 0.1, 500) - end -} - - -AUTHOR.layers.bezel = { - help = "The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change.", - params = { twin = "boolean" }, - begin = function(layer) - bezel = { twin = layer.twin, shown = {} } - scoreBezelEnable(true) - if layer.twin then - scoreBezelTwinScoreOn(true) - end - end -} - -AUTHOR.layers.parser = { - help = "A text parser: a prompt the player types at, and words the game knows. What is typed raises the said event.", - params = { prompt = "string", words = "table", ignore = "table" }, - begin = function(layer) - parserLayer = layer - keyboardSetMode(MODE_FULL) - end -} - - --- ===== Looks ================================================================================== --- --- How an instance is drawn. A look draws itself and says how big it is; it never knows which --- layer it is on. The 3D looks build nodes under the instance's own and draw nothing themselves: --- the scene draws them. - -AUTHOR.looks.box = { - help = "A filled rectangle, centred on the instance -- or standing on it, with anchor feet.", - params = { w = "number", h = "number", r = "number", g = "number", b = "number", anchor = "string" }, - choices = { anchor = { "feet" } }, - size = function(instance) return instance.look.w or 20, instance.look.h or 20 end, - draw = function(instance) - local look = instance.look - local x, y = authorPosition(instance) - local w, h = authorSize(instance) - - local scale = authorDepthScaleOf(instance) - local top = (look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2) - - colorForeground(look.r or 255, look.g or 255, look.b or 255, 255) - -- overlayBox outlines; a solid block is the outline drawn every row, which is cheap at - -- these sizes and saves the game needing an image for a placeholder. - for row = math.floor(top), math.floor(top + h * scale) do - overlayLine(x - w * scale / 2, row, x + w * scale / 2, row) - end - end -} - -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. 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) - end, - size = function(instance) - if instance.sprite == nil then - return 0, 0 - end - if (instance.look.frames or 1) > 1 then - return spriteFrameWidth(instance.sprite), spriteFrameHeight(instance.sprite) - end - - return spriteGetWidth(instance.sprite), spriteGetHeight(instance.sprite) - end, - draw = function(instance) - local x, y = authorPosition(instance) - local w, h = authorSize(instance) - local scale = authorDepthScaleOf(instance) * (instance.scale or 1) - local top = (instance.look.anchor == "feet") and (y - h * scale) or (y - h * scale / 2) - local frames = instance.look.frames or 1 - local frame = instance.vars.frame or 1 - local angle = instance.vars.angle or 0 - local mirror = authorMirrored(instance) - - if (angle ~= 0) or mirror then - -- A turned or mirrored sprite gets an image of its own, so it does not turn every other - -- instance drawn from the same file, and the image is rebuilt only when something - -- about it changes. - local own = instance.ownSprite - - if own == nil then - own = (frames > 1) and spriteLoadFrames(frames, authorFile(instance.look.file)) or spriteLoad(authorFile(instance.look.file)) - instance.ownSprite = own - end - spriteFlip(own, mirror, false) - if frames > 1 then - if (instance.ownAngle ~= angle) or (instance.ownFrame ~= frame) then - spriteRotateFrame(own, angle, frame) - instance.ownAngle = angle - instance.ownFrame = frame - end - spriteDrawRotatedFrame(own, x, top + h * scale / 2, scale) - else - spriteRotate(own, angle) - spriteScale(own, scale) - spriteDraw(own, x, top + h * scale / 2, true) - end - elseif (frames > 1) or (scale ~= 1) then - spriteDrawFrame(instance.sprite, x - w * scale / 2, top, frame, scale) - else - 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 -} - -AUTHOR.looks.particles = { - help = "A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.", - params = { r = "number", g = "number", b = "number", rate = "number", size = "number", speed = "number", life = "number", spread = "number", gravity = "number" }, - load = function(instance) - local look = instance.look - - instance.emitter = AUTHOR_3D and emitterNew(instance.node) or emitterNew() - emitterSetColor(instance.emitter, look.r or 255, look.g or 200, look.b or 80, 255, look.r or 255, look.g or 200, look.b or 80, 0) - emitterSetRate(instance.emitter, look.rate or 40) - emitterSetLife(instance.emitter, (look.life or 1) * 0.6, look.life or 1) - emitterSetSpread(instance.emitter, look.spread or 30) - if look.size then - emitterSetSize(instance.emitter, look.size, look.size * 0.2) - end - if look.speed then - emitterSetSpeed(instance.emitter, look.speed * 0.6, look.speed) - end - if look.gravity then - emitterSetGravity(instance.emitter, 0, look.gravity, 0) - end - emitterStart(instance.emitter) - end, - size = function(instance) return instance.look.size or 16, instance.look.size or 16, instance.look.size or 16 end, - draw = function(instance) - if not AUTHOR_3D then - local x, y = authorPosition(instance) - - emitterSetPosition(instance.emitter, x, y) - end - end -} - -AUTHOR.looks.text = { - 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 - - 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 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 -} - -AUTHOR.looks.none = { - help = "Nothing drawn: a trigger, a spawner, a hitbox over video, a camera.", - params = { w = "number", h = "number", d = "number" }, - size = function(instance) return instance.look.w or 0, instance.look.h or 0, instance.look.d or 0 end, - draw = function() end -} - - -AUTHOR.looks.qteHud = { - help = "The HUD of a quick-time-event game: what its qte behaviour's state says a player sees (the score and the record, the lives or the life bar, and the rest), drawn from the game's own pictures and font as the game lays them out.", - params = {}, - size = function() return 0, 0, 0 end, - draw = function(instance) - if instance.q then - authorQteHud(instance.q) - end - end -} - - -local function modelFor(file) - if models[file] == nil then - models[file] = modelLoad(authorFile(file)) - end - - return models[file] -end - - --- Every node under a model instance is the instance's, so a ray or a contact that reports a mesh --- node or a bone finds its way back without asking the engine for parents (which would end the --- game on a node a rule had already deleted). -local function claimNodes(instance, node) - AUTHOR_BY_NODE[node] = instance - instance.nodes[#instance.nodes + 1] = node - for _, child in ipairs(nodeGetChildren(node) or {}) do - claimNodes(instance, child) - end -end - - -AUTHOR.looks.model = { - help = "A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as.", - params = { file = "file", scale = "number", clip = "string", rx = "number", ry = "number", rz = "number", w = "number", h = "number", d = "number" }, - load = function(instance) - local look = instance.look - - instance.model = modelInstance(modelFor(look.file)) - claimNodes(instance, instance.model) - nodeSetParent(instance.model, instance.node) - nodeSetScale(instance.model, look.scale or 1) - nodeSetRotation(instance.model, look.rx or 0, look.ry or 0, look.rz or 0) - if look.clip then - animationPlay(instance.model, look.clip, true) - end - end, - size = function(instance) return instance.look.w or 1, instance.look.h or 1, instance.look.d or 1 end, - draw = function() end -} - - --- A primitive mesh, shared by every instance that asks for the same one. -local function meshFor(look) - local key = table.concat({ look.shape or "box", look.w or 1, look.h or 1, look.d or 1 }, "/") - - if meshes[key] == nil then - if look.shape == "sphere" then - meshes[key] = meshSphere((look.w or 1) / 2, 24) - elseif look.shape == "cylinder" then - meshes[key] = meshCylinder((look.w or 1) / 2, look.h or 1, 24) - elseif look.shape == "plane" then - meshes[key] = meshPlane(look.w or 1, look.d or 1) - else - meshes[key] = meshBox(look.w or 1, look.h or 1, look.d or 1) - end - end - - return meshes[key] -end - - -AUTHOR.looks.mesh = { - help = "A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting.", - params = { shape = "string", w = "number", h = "number", d = "number", r = "number", g = "number", b = "number", texture = "file", unlit = "boolean", metallic = "number", roughness = "number", occluder = "boolean" }, - choices = { shape = { "box", "sphere", "cylinder", "plane" } }, - load = function(instance) - local look = instance.look - local material = materialNew() - - materialSetColor(material, look.r or 200, look.g or 200, look.b or 200) - -- A KTX2 texture goes straight on; any other picture is loaded as a sprite first, which is - -- what materialSetTexture takes for those. - if look.texture and look.texture ~= "" then - if look.texture:lower():match("%.ktx2$") then - materialSetTexture(material, authorFile(look.texture)) - else - materialSetTexture(material, spriteFor(look.texture)) - end - end - if look.unlit then - materialSetUnlit(material, true) - end - if look.metallic then - materialSetMetallic(material, look.metallic) - end - if look.roughness then - materialSetRoughness(material, look.roughness) - end - -- The editor's preview keeps an occluder visible, so it can be placed; the game hides it. - if look.occluder and not instance.preview then - materialSetOccluder(material, true) - end - instance.material = material - nodeSetMesh(instance.node, meshFor(look), material) - end, - size = function(instance) - local look = instance.look - - if look.shape == "plane" then - return look.w or 1, 0.01, look.d or 1 - end - - return look.w or 1, (look.shape == "sphere") and (look.w or 1) or (look.h or 1), (look.shape == "sphere") and (look.w or 1) or (look.d or 1) - end, - draw = function() end -} - -AUTHOR.looks.light = { - help = "A point or spot light, or a sun, on the instance.", - params = { type = "string", r = "number", g = "number", b = "number", intensity = "number", range = "number", shadow = "boolean" }, - choices = { type = { "point", "spot", "sun" } }, - load = function(instance) - local look = instance.look - local kind = (look.type == "spot") and LIGHT_SPOT or ((look.type == "sun") and LIGHT_DIRECTIONAL or LIGHT_POINT) - local light = lightNew(kind, instance.node) - - lightSetColor(light, look.r or 255, look.g or 255, look.b or 255) - lightSetIntensity(light, look.intensity or 1) - if look.range then - lightSetRange(light, look.range) - end - if look.shadow then - lightSetShadow(light, true) - end - instance.light = light - end, - size = function() return 0.5, 0.5, 0.5 end, - draw = function() end -} - -AUTHOR.looks.billboard = { - help = "A sprite in the scene, always facing the camera: a tree, a puff, a health bar.", - params = { file = "file", height = "number", w = "number", h = "number", d = "number" }, - load = function(instance) - instance.sprite = spriteFor(instance.look.file) - nodeSetSprite(instance.node, instance.sprite, instance.look.height or 1) - nodeSetBillboard(instance.node, BILLBOARD_Y) - end, - size = function(instance) return instance.look.w or 1, instance.look.h or instance.look.height or 1, instance.look.d or 1 end, - draw = function() end -} - -AUTHOR.looks.text3d = { - help = "A line of text standing in the scene. The var text is what it says.", - params = { text = "string", height = "number" }, - load = function(instance) - instance.vars.text = instance.vars.text or instance.look.text or "" - instance.shown = instance.vars.text - nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5) - nodeSetBillboard(instance.node, BILLBOARD_Y) - end, - size = function(instance) return 1, instance.look.height or 0.5, 0.1 end, - draw = function(instance) - if instance.shown ~= instance.vars.text then - instance.shown = instance.vars.text - nodeSetText(instance.node, instance.vars.text, instance.look.height or 0.5) - end - end -} - - -AUTHOR.looks.grid = { - help = "A map of tiles from a sheet, its top left at the instance: columns is the sheet's width in tiles, tile the tile's size, map the rows of tile numbers (1 is the first; 0 is empty), rows separated by semicolons.", - params = { file = "file", tile = "number", columns = "number", map = "string" }, - load = function(instance) - local look = instance.look - - instance.sprite = spriteFor(look.file) - instance.rows = {} - for row in string.gmatch(tostring(look.map or ""), "[^;]+") do - local cells = {} - - for number in string.gmatch(row, "%d+") do - cells[#cells + 1] = tonumber(number) - end - instance.rows[#instance.rows + 1] = cells - end - end, - size = function(instance) - local tile = instance.look.tile or 32 - local width = 0 - - for _, row in ipairs(instance.rows or {}) do - width = math.max(width, #row) - end - - return width * tile, #(instance.rows or {}) * tile - end, - draw = function(instance) - local look = instance.look - local tile = look.tile or 32 - local columns = look.columns or 1 - local x, y = authorPosition(instance) - - for r, row in ipairs(instance.rows) do - for c, cell in ipairs(row) do - if cell > 0 then - local sx = ((cell - 1) % columns) * tile - local sy = math.floor((cell - 1) / columns) * tile - - spriteDrawGrid(instance.sprite, x + (c - 1) * tile, y + (r - 1) * tile, sx, sy, tile, tile) - end - end - end - end -} - - --- ===== Behaviours ============================================================================= --- --- Each is a bundle over engine calls that already exist and are tested. A platformer is a --- checkbox over the character controller, not a reimplementation of one. attach runs once when --- the instance is made, step every frame with the seconds since the last one, and on(event) when --- something happens to the instance. - -AUTHOR.behaviours.solid = { - help = "Immovable ground or a wall, in a world2d or a scene3d room. With moving, a body that follows wherever the instance is moved -- a paddle, a lift.", - params = { moving = "boolean" }, - attach = function(instance, params) - local w, h, d = authorUnscaledSize(instance) - local kind = params.moving and BODY_KINEMATIC or BODY_STATIC - - if AUTHOR_3D then - bodyNew(instance.node, kind, SHAPE_BOX, w, h, d) - else - bodyNew(instance.node, kind, SHAPE_BOX, w, h, w) - end - instance.hasBody = true - end -} - -AUTHOR.behaviours.body = { - help = "A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them.", - params = { shape = "string", mass = "number", bounce = "number", friction = "number", buoyancy = "number" }, - choices = { shape = { "box", "sphere" } }, - attach = function(instance, params) - local w, h, d = authorUnscaledSize(instance) - - if params.shape == "sphere" then - bodyNew(instance.node, BODY_DYNAMIC, SHAPE_SPHERE, w / 2) - elseif AUTHOR_3D then - bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d) - else - bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, w) - end - if params.mass then - bodySetMass(instance.node, params.mass) - end - if params.bounce then - bodySetBounce(instance.node, params.bounce) - end - if params.friction then - bodySetFriction(instance.node, params.friction) - end - if params.buoyancy then - bodySetBuoyancy(instance.node, params.buoyancy) - end - instance.hasBody = true - end -} - -AUTHOR.behaviours.trigger = { - help = "A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize.", - params = {}, - attach = function(instance) - local w, h, d = authorUnscaledSize(instance) - - if AUTHOR_3D then - bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, d) - else - bodyNew(instance.node, BODY_KINEMATIC, SHAPE_BOX, w, h, w) - end - bodySetTrigger(instance.node, true) - instance.hasBody = true - end -} - -AUTHOR.behaviours.character = { - help = "Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera.", - params = { speed = "number", jump = "number", radius = "number", height = "number" }, - attach = function(instance, params) - local w, h = authorSize(instance) - - instance.speed = params.speed or 4 - instance.jump = params.jump or 6 - instance.player = true - playerNew(instance.node, SHAPE_CAPSULE, params.radius or (w * PLAYER_RADIUS), params.height or h) - end, - step = function(instance) - local dx = instance.vars.dx or 0 - local dy = instance.vars.dy or 0 - local fx, fz, rx, rz = authorCameraAxes() - local vx = (rx * dx - fx * dy) * instance.speed - local vz = (rz * dx - fz * dy) * instance.speed - - playerMove(instance.node, vx, vz) - if ((dx ~= 0) or (dy ~= 0)) and instance.model then - local x, y, z = authorPosition(instance) - - nodeLookAt(instance.model, x - vx, y, z - vz) - end - end -} - -AUTHOR.behaviours.seek = { - help = "Moves straight toward a target -- an entity's id, or \"camera\" -- and raises arrived within stopAt of it.", - params = { target = "string", speed = "number", stopAt = "number", fly = "boolean" }, - attach = function(instance, params) - instance.seek = params - end, - step = function(instance, dt) - local p = instance.seek - local tx, ty, tz = authorTargetPosition(p.target) - - if tx == nil then - return - end - local x, y, z = authorPosition(instance) - local dx, dy, dz = tx - x, ty - y, tz - z - - if not p.fly then - dy = 0 - end - local far = math.sqrt(dx * dx + dy * dy + dz * dz) - - if far <= (p.stopAt or 1) then - if not instance.arrived then - instance.arrived = true - authorEvent("arrived", { self = instance }) - end - return - end - instance.arrived = false - local step = math.min((p.speed or 1) * dt, far) - - nodeSetPosition(instance.node, x + dx / far * step, y + dy / far * step, z + dz / far * step) - if instance.model then - nodeLookAt(instance.model, x - dx, y, z - dz) - end - end -} - -AUTHOR.behaviours.animator = { - help = "A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends.", - params = { idle = "string", walk = "string", attack = "string", hit = "string", dead = "string", fade = "number", loop = "string" }, - attach = function(instance, params) - instance.animator = params - instance.playing = nil - end, - step = function(instance) - local p = instance.animator - local state = instance.vars.state - local clip = p[state] - - if instance.model == nil then - return - end - if (clip ~= nil) and (clip ~= instance.playing) then - local loops = (state == "idle") or (state == "walk") or (state == "run") or ((p.loop or ""):find(state, 1, true) ~= nil) - - animationPlay(instance.model, clip, loops, 1, p.fade or 0.15) - instance.playing = clip - instance.watch = not loops - elseif instance.watch and not animationIsPlaying(instance.model) then - instance.watch = false - authorEvent("animationDone", { self = instance, state = state }) - end - end -} - -AUTHOR.behaviours.camera = { - help = "The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops.", - params = { mode = "string", target = "string", x = "number", y = "number", z = "number", lookX = "number", lookY = "number", lookZ = "number", track = "track", lag = "number", eye = "number" }, - choices = { mode = { "fixed", "follow", "first", "orbit", "rail" } }, - attach = function(instance, params) - instance.camera = params - instance.yaw = 0 - instance.pitch = 0 - cameraNode = instance.node - cameraSet(instance.node) - if params.mode == "rail" then - instance.rail = { t = 0, waiting = nil, passed = {} } - end - if (params.mode == "fixed") or (params.mode == nil) then - nodeLookAt(instance.node, params.lookX or 0, params.lookY or 0, params.lookZ or 0) - end - end, - step = function(instance, dt) - local p = instance.camera - - if p.mode == "follow" then - local tx, ty, tz = authorTargetPosition(p.target) - - if tx then - local x, y, z = authorPosition(instance) - local k = (p.lag and p.lag > 0) and math.min(1, dt / p.lag) or 1 - - nodeSetPosition(instance.node, x + (tx + (p.x or 0) - x) * k, y + (ty + (p.y or 3) - y) * k, z + (tz + (p.z or 6) - z) * k) - nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz) - end - elseif p.mode == "orbit" then - local tx, ty, tz = authorTargetPosition(p.target) - - if tx then - instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED - instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch + AUTHOR_MOUSE.dy * LOOK_SPEED)) - local far = p.z or 6 - local yaw = math.rad(instance.yaw) - local pitch = math.rad(instance.pitch) - - nodeSetPosition(instance.node, tx + math.sin(yaw) * math.cos(pitch) * far, ty + (p.y or 1) + math.sin(pitch) * far, tz + math.cos(yaw) * math.cos(pitch) * far) - nodeLookAt(instance.node, tx, ty + (p.lookY or 0.5), tz) - end - elseif p.mode == "first" then - local target = authorTargetInstance(p.target) - - if target then - local tx, ty, tz = authorPosition(target) - - instance.yaw = instance.yaw - AUTHOR_MOUSE.dx * LOOK_SPEED - instance.pitch = math.max(-PITCH_LIMIT, math.min(PITCH_LIMIT, instance.pitch - AUTHOR_MOUSE.dy * LOOK_SPEED)) - nodeSetPosition(instance.node, tx, ty + (p.eye or 0.7), tz) - nodeSetRotation(instance.node, instance.pitch, instance.yaw, 0) - end - elseif p.mode == "rail" then - authorRailStep(instance, dt) - end - end -} - -AUTHOR.behaviours.platformer = { - help = "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.", - params = { speed = "number", jump = "number" }, - attach = function(instance, params) - local w, h = authorSize(instance) - - instance.speed = params.speed or 200 - instance.jump = params.jump or 500 - instance.player = true - -- A player stands on its node, so the node is the feet; the look is drawn to match. - instance.look.anchor = "feet" - playerNew(instance.node, SHAPE_CAPSULE, w * PLAYER_RADIUS, h) - end, - step = function(instance) - local drive = instance.vars.drive - local state = instance.vars.state - - -- The rules say which way; this clears it each frame so releasing a key stops the run. - playerMove(instance.node, drive * instance.speed) - instance.vars.drive = 0 - -- Which way it faces and what it is doing, for a sprite look and a frames behaviour: a - -- state a rule set (hit, dead) is left alone until the rule sets another. - if drive ~= 0 then - instance.vars.facing = (drive < 0) and "left" or "right" - end - if (state == nil) or PLATFORMER_STATES[state] then - if not playerIsOnGround(instance.node) then - local _, vy = playerGetVelocity(instance.node) - - instance.vars.state = (vy < 0) and "jump" or "fall" - else - instance.vars.state = (drive ~= 0) and "walk" or "idle" - end - end - end -} - -AUTHOR.behaviours.drift = { - help = "Moves steadily, for a cloud, a platform, or a target.", - params = { vx = "number", vy = "number" }, - attach = function(instance, params) - instance.vars.vx = instance.vars.vx or params.vx or 0 - instance.vars.vy = instance.vars.vy or params.vy or 0 - end, - step = function(instance, dt) - local x, y, z = authorPosition(instance) - - nodeSetPosition(instance.node, x + instance.vars.vx * dt, y + instance.vars.vy * dt, z) - end -} - -AUTHOR.behaviours.keys = { - help = "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.", - params = { player = "number", left = "scancode", right = "scancode", up = "scancode", down = "scancode", fire = "scancode" }, - attach = function(instance, params) - instance.keys = { - 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 - end, - step = function(instance) - local k = instance.keys - local dx = 0 - local dy = 0 - - if AUTHOR_CONTROLS then - if k.left and authorKeyHeld(k.left) then dx = dx - 1 end - if k.right and authorKeyHeld(k.right) then dx = dx + 1 end - if k.up and authorKeyHeld(k.up) then dy = dy - 1 end - if k.down and authorKeyHeld(k.down) then dy = dy + 1 end - end - instance.vars.dx = dx - instance.vars.dy = dy - instance.vars.fire = AUTHOR_CONTROLS and (k.fire ~= nil) and authorKeyHeld(k.fire) - end -} - -AUTHOR.behaviours.mover = { - help = "Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.", - params = { speed = "number", clamp = "boolean" }, - attach = function(instance, params) - instance.moveSpeed = params.speed or 200 - instance.clamp = params.clamp - end, - step = function(instance, dt) - local x, y, z = authorPosition(instance) - local w, h = authorSize(instance) - - x = x + (instance.vars.dx or 0) * instance.moveSpeed * dt - y = y + (instance.vars.dy or 0) * instance.moveSpeed * dt - if (instance.vars.dx or 0) ~= 0 then - instance.vars.facing = (instance.vars.dx < 0) and "left" or "right" - end - if instance.clamp then - x = math.max(w / 2, math.min(overlayGetWidth() - w / 2, x)) - y = math.max(h / 2, math.min(overlayGetHeight() - h / 2, y)) - end - nodeSetPosition(instance.node, x, y, z) - end -} - -AUTHOR.behaviours.shooter = { - help = "Spawns a type from an offset while the var fire is set (or always, with auto), no faster than the rate.", - params = { spawns = "type", rate = "number", offsetX = "number", offsetY = "number", auto = "boolean" }, - attach = function(instance, params) - instance.shooter = params - instance.lastShot = -math.huge - end, - step = function(instance) - local p = instance.shooter - - if (p.auto or instance.vars.fire) and (authorTime() - instance.lastShot >= (p.rate or 0.2)) then - local x, y = authorPosition(instance) - - instance.lastShot = authorTime() - authorSpawn(p.spawns, x + (p.offsetX or 0), y + (p.offsetY or 0), 0, { owner = instance.id }) - end - end -} - -AUTHOR.behaviours.projectile = { - help = "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.", - params = { vx = "number", vy = "number", vz = "number", life = "number", aim = "boolean" }, - attach = function(instance, params) - instance.vars.vx = instance.vars.vx or params.vx or 0 - instance.vars.vy = instance.vars.vy or params.vy or (AUTHOR_3D and 0 or -400) - instance.vars.vz = instance.vars.vz or params.vz or 0 - instance.dies = authorTime() + (params.life or 5) - if params.aim and not AUTHOR_3D then - instance.vars.angle = math.deg(math.atan(instance.vars.vx, -instance.vars.vy)) - end - end, - step = function(instance, dt) - local x, y, z = authorPosition(instance) - - x = x + instance.vars.vx * dt - y = y + instance.vars.vy * dt - z = z + instance.vars.vz * dt - nodeSetPosition(instance.node, x, y, z) - if authorTime() > instance.dies then - authorDestroy(instance) - elseif AUTHOR_3D then - if (math.abs(x) > FAR_AWAY) or (math.abs(y) > FAR_AWAY) or (math.abs(z) > FAR_AWAY) then - authorDestroy(instance) - end - elseif (x < -SPAWN_MARGIN) or (y < -SPAWN_MARGIN) or (x > overlayGetWidth() + SPAWN_MARGIN) or (y > overlayGetHeight() + SPAWN_MARGIN) then - authorDestroy(instance) - end - end -} - -AUTHOR.behaviours.health = { - help = "The var health; the damage action lowers it, and at zero the instance raises death and is destroyed unless keep is set. With ragdoll, a model falls limp instead and goes after linger seconds.", - params = { max = "number", keep = "boolean", ragdoll = "boolean", linger = "number" }, - attach = function(instance, params) - instance.vars.health = instance.vars.health or params.max or 1 - instance.keepDead = params.keep or params.ragdoll - instance.linger = params.linger or 4 - if params.ragdoll and instance.model then - ragdollNew(instance.model) - instance.ragdoll = true - end - end, - on = function(instance, event, data) - if (event == "death") and instance.ragdoll then - ragdollActivate(instance.model) - authorTimerStart(instance, "gone", instance.linger) - instance.isTarget = false - elseif (event == "timer") and (data.name == "gone") then - authorDestroy(instance) - end - end -} - -AUTHOR.behaviours.timer = { - help = "Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.", - params = { name = "string", after = "number", every = "number", start = "boolean" }, - attach = function(instance, params) - if params.start ~= false then - authorTimerStart(instance, params.name, params.after or params.every, params.every) - end - end -} - -AUTHOR.behaviours.spawner = { - help = "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.", - params = { spawns = "type", every = "number", max = "number", total = "number", points = "table", pick = "string" }, - choices = { pick = { "turn", "random" } }, - attach = function(instance, params) - instance.spawner = params - instance.nextAt = authorTime() + (params.every or 1) - instance.vars.made = 0 -- A var, so a rule can ask how many it has made. - instance.point = 0 - end, - step = function(instance) - local p = instance.spawner - - if (authorTime() < instance.nextAt) or (p.total and instance.vars.made >= p.total) then - return - end - if p.max and (authorCount(p.spawns) >= p.max) then - return - end - local x, y, z = authorPosition(instance) - - if p.points and #p.points > 0 then - local at - - if p.pick == "random" then - at = math.random(#p.points) - else - instance.point = (instance.point % #p.points) + 1 - at = instance.point - end - x = p.points[at].x or p.points[at][1] - y = p.points[at].y or p.points[at][2] - z = p.points[at].z or p.points[at][3] or z - end - instance.nextAt = authorTime() + (p.every or 1) - instance.vars.made = instance.vars.made + 1 - authorSpawn(p.spawns, x, y, z, { spawner = instance.id }) - end -} - -AUTHOR.behaviours.sound = { - 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 - authorPlaySound(params.spawn, params.volume, instance) - end - end, - on = function(instance, event) - local file = instance.sounds[event] - - if file and (event ~= "spawn") then - authorPlaySound(file, instance.sounds.volume, instance) - end - end -} - -AUTHOR.behaviours.gun = { - help = "The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun.", - params = { player = "number", trigger = "switch", ammo = "number", reload = "string", aim = "string" }, - choices = { reload = { "offscreen" }, aim = { "centre" } }, - attach = function(instance, params) - instance.gun = params - 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" }, - attach = function(instance, params) - instance.isTarget = true - if AUTHOR_3D and not instance.hasBody then - -- The body sits on a child node raised by half the height, since a model stands on its - -- origin and a box centred on the feet would be half underground. - local w, h, d = authorSize(instance) - local box = nodeNew() - - nodeSetParent(box, instance.node) - nodeSetPosition(box, 0, h / 2, 0) - nodeSetName(box, "body") - bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d) - bodySetTrigger(box, true) - AUTHOR_BY_NODE[box] = instance - instance.nodes[#instance.nodes + 1] = box - end - for _, zone in ipairs(params.zones or {}) do - local bone = instance.model and nodeFind(zone.bone, instance.model) or nil - - if bone then - local box = nodeNew() - - nodeSetParent(box, bone) - nodeSetName(box, zone.name) - AUTHOR_BY_NODE[box] = instance - instance.nodes[#instance.nodes + 1] = box - instance.zones = instance.zones or {} - instance.zones[#instance.zones + 1] = { node = box, name = zone.name, reach = math.max(zone.w or 0.3, zone.h or 0.3, zone.d or 0.3) / 2 } - else - debugPrint("Author: " .. instance.id .. " has no bone called '" .. tostring(zone.bone) .. "' for zone " .. tostring(zone.name)) - end - end - end -} - -AUTHOR.behaviours.lightSensor = { - help = "Judges a shot by the picture rather than by a hitbox, the way a home laserdisc gun does: a spot on the picture (x, y) flashes bright or dark on the frames a target is shown, and a shot is good when the picture under the gun changes the way the spot does over the two frames after the trigger. A shot into the spot's own corner (left, top and beyond) is not judged, so the spot cannot be shot. Raises fire on the trigger, then hit or miss. For a game whose disc carries the targets in its own picture; a game with hitboxes wants gun and target instead.", - params = { x = "number", y = "number", left = "number", top = "number", high = "number", low = "number", trigger = "switch", player = "number" }, - defaults = { high = PICTURE_BRIGHT, low = PICTURE_DARK, trigger = "SWITCH_BUTTON3", player = 1 }, - attach = function(instance, params) - instance.sensor = params - instance.hearsAll = true -- The trigger comes as a press about nothing in particular. - instance.trigger = authorSwitchValue(params.trigger) - instance.vars.player = params.player or 1 - instance.pulled = 0 - instance.spotWas = nil - instance.gunWas = nil - end, - step = function(instance) - local p = instance.sensor - local spotState = authorPictureState(p.x or 0, p.y or 0, p.high, p.low) - - if (instance.pulled > 0) and (instance.spotWas ~= nil) and (spotState ~= instance.spotWas) then - -- The picture changed frame: a sample. A shot into the spot's corner is not judged. - local gx, gy = authorPointer(instance.vars.player) - local gunState = "unknown" - - if (gx < (p.left or 0)) or (gy < (p.top or 0)) then - gunState = authorPictureState(gx, gy, p.high, p.low) - end - if instance.pulled == 1 then - -- The second sample decides, when both samples of both spots were readable and the - -- gun's changed between them: good when it changed the way the spot did. - if (gunState ~= "unknown") and (spotState ~= "unknown") and (instance.gunWas ~= "unknown") and (instance.spotWas ~= "unknown") and (instance.gunWas ~= gunState) then - if gunState == spotState then - authorEvent("hit", { self = instance, player = instance.vars.player, x = gx, y = gy }) - else - authorEvent("miss", { self = instance, player = instance.vars.player, offscreen = false, x = gx, y = gy }) - end - end - else - instance.gunWas = gunState - end - instance.pulled = instance.pulled - 1 - end - instance.spotWas = spotState - end, - on = function(instance, name, event) - -- The trigger: the shot is judged over the next two frames of the picture. - if (name == "pressed") and (event.switch == instance.trigger) then - instance.pulled = 2 - instance.gunWas = nil - authorBehaviourEvent(instance, "fire") - end - end -} - -AUTHOR.behaviours.walker = { - help = "Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.", - params = { speed = "number", radius = "number", follow = "string", every = "number", stopAt = "number" }, - attach = function(instance, params) - instance.walker = params - instance.repath = 0 - end, - step = function(instance) - local nav = authorRoomNav() - - if nav == nil then - return - end - if instance.agent == nil then - local x, y, z = authorPosition(instance) - - if AUTHOR_3D then - instance.navNode = instance.node - else - instance.navNode = nodeNew() - nodeSetPosition(instance.navNode, x * NAV_SCALE, 0, y * NAV_SCALE) - end - instance.agent = navAgentNew(nav.nav, instance.navNode, instance.walker.radius or nav.radius, nav.height, (instance.walker.speed or 120) * nav.scale) - AUTHOR_AGENTS[instance.agent] = instance - end - if not AUTHOR_3D then - local nx, _, nz = nodeGetPosition(instance.navNode) - - nodeSetPosition(instance.node, nx / NAV_SCALE, nz / NAV_SCALE, 0) - end - -- Following: the target's position is asked for again every so often, and the walk ends - -- within stopAt of it, where a seek would. - if instance.walker.follow and (authorTime() >= instance.repath) then - local tx, ty, tz = authorTargetPosition(instance.walker.follow) - - instance.repath = authorTime() + (instance.walker.every or 0.5) - if tx then - local x, y, z = authorPosition(instance) - local far = AUTHOR_3D and math.sqrt((tx - x) ^ 2 + (tz - z) ^ 2) or math.sqrt((tx - x) ^ 2 + (ty - y) ^ 2) - - if far > (instance.walker.stopAt or 1.5) then - authorWalkStart(instance, tx, ty, tz) - elseif instance.walking then - navAgentStop(instance.agent) - instance.walking = false - instance.vars.state = "idle" - authorEvent("arrived", { self = instance }) - end - end - end - if instance.walking then - if navAgentIsArrived(instance.agent) then - instance.walking = false - instance.vars.state = "idle" - authorEvent("arrived", { self = instance }) - else - local vx, vy, vz = navAgentGetVelocity(instance.agent) - - if (vx ~= 0) or (vz ~= 0) then - instance.vars.state = "walk" - if instance.model then - local x, y, z = authorPosition(instance) - - nodeLookAt(instance.model, x - vx, y, z - vz) - end - instance.vars.facing = (vx < 0) and "left" or "right" - end - end - end - end -} - -AUTHOR.behaviours.patrol = { - help = "Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last.", - params = { points = "table", loop = "boolean" }, - attach = function(instance, params) - instance.patrol = params - instance.leg = 0 - end, - step = function(instance) - local p = instance.patrol - - if (instance.agent == nil) or instance.walking or (p.points == nil) or (#p.points == 0) then - return - end - if instance.leg >= #p.points then - if p.loop then - instance.leg = 0 - elseif not instance.patrolDone then - instance.patrolDone = true - authorEvent("patrolEnd", { self = instance }) - return - else - return - end - end - instance.leg = instance.leg + 1 - local point = p.points[instance.leg] - - authorWalkStart(instance, point.x or point[1], point.y or point[2] or 0, point.z or point[3] or 0) - end -} - -AUTHOR.behaviours.vehicle = { - help = "A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none.", - params = { type = "string", wheelX = "number", wheelZ = "number", radius = "number", width = "number", suspension = "number", torque = "number", mass = "number", thrust = "number" }, - choices = { type = { "car", "motorcycle", "tank", "boat" } }, - attach = function(instance, params) - local w, h, d = authorSize(instance) - local kind = ({ car = VEHICLE_CAR, motorcycle = VEHICLE_MOTORCYCLE, tank = VEHICLE_TANK, boat = VEHICLE_BOAT })[params.type or "car"] or VEHICLE_CAR - - if not instance.hasBody then - bodyNew(instance.node, BODY_DYNAMIC, SHAPE_BOX, w, h, d) - instance.hasBody = true - end - bodySetMass(instance.node, params.mass or 800) - vehicleNew(instance.node, kind) - if kind ~= VEHICLE_BOAT then - local wx = params.wheelX or (w / 2) - local wz = params.wheelZ or (d / 2 - 0.2) - local radius = params.radius or 0.35 - local wheels = (kind == VEHICLE_MOTORCYCLE) and { { 0, -wz }, { 0, wz } } or { { -wx, -wz }, { wx, -wz }, { -wx, wz }, { wx, wz } } - - for _, at in ipairs(wheels) do - local wheel = nodeNew() - - nodeSetParent(wheel, instance.node) - nodeSetPosition(wheel, at[1], -h / 2, at[2]) - instance.nodes[#instance.nodes + 1] = wheel - vehicleAddWheel(instance.node, wheel, radius, params.width or 0.2, params.suspension or 0.3) - end - if params.torque then - vehicleSetEngine(instance.node, params.torque, 6000) - end - elseif params.thrust then - vehicleSetThrust(instance.node, params.thrust, 0, -0.2, d / 2) - end - instance.vehicle = true - end, - step = function(instance) - local dx = instance.vars.dx or 0 - local dy = instance.vars.dy or 0 - - vehicleDrive(instance.node, -dy, dx, instance.vars.brake or 0) - instance.vars.speed = vehicleGetSpeed(instance.node) - end -} - -AUTHOR.behaviours.racer = { - help = "Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.", - params = { track = "track", throttle = "number", reach = "number" }, - attach = function(instance, params) - instance.racer = params - instance.leg = 1 - end, - step = function(instance) - local track = authorTrackNamed(instance.racer.track or "") - local points = track and track.points or {} - - if (#points == 0) or not instance.vehicle then - return - end - local point = points[instance.leg] - local x, y, z = authorPosition(instance) - local dx, dz = (point.x or 0) - x, (point.z or 0) - z - local far = math.sqrt(dx * dx + dz * dz) - - if far < (instance.racer.reach or 3) then - instance.leg = (instance.leg % #points) + 1 - return - end - -- The chassis' heading from its yaw; the steering is the signed angle to the point. - local _, yaw, _ = nodeGetRotation(instance.node) - local heading = math.rad(yaw) - local fx, fz = -math.sin(heading), -math.cos(heading) - local cross = fx * dz - fz * dx - local dot = fx * dx + fz * dz - local angle = math.atan(cross, dot) - - instance.vars.dx = math.max(-1, math.min(1, -angle * 2)) - instance.vars.dy = -(instance.racer.throttle or 0.6) * ((dot > 0) and 1 or 0.3) - end -} - -AUTHOR.behaviours.thrust = { - help = "A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.", - params = { force = "number", turn = "number" }, - attach = function(instance, params) - instance.thrust = params - end, - step = function(instance) - if not instance.hasBody then - return - end - local _, yaw, _ = nodeGetRotation(instance.node) - local heading = math.rad(yaw) - local push = -(instance.vars.dy or 0) * (instance.thrust.force or 500) - - bodyApplyForce(instance.node, -math.sin(heading) * push, 0, -math.cos(heading) * push) - bodySetAngularVelocity(instance.node, 0, -(instance.vars.dx or 0) * (instance.thrust.turn or 1.5), 0) - end -} - -AUTHOR.behaviours.joint = { - help = "Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis.", - params = { type = "string", other = "string", ax = "number", ay = "number", az = "number", dx = "number", dy = "number", dz = "number" }, - choices = { type = { "hinge", "ball", "slider" } }, - attach = function(instance, params) - instance.jointWanted = params - end, - step = function(instance) - -- Made on the first frame, once every body of the room exists. - local p = instance.jointWanted - - if p == nil then - return - end - instance.jointWanted = nil - local other = p.other and AUTHOR_BY_ID[p.other] or nil - local node = other and other.node or -1 - local x, y, z = authorPosition(instance) - - if p.type == "ball" then - instance.joint = jointBall(instance.node, node, p.ax or x, p.ay or y, p.az or z) - elseif p.type == "slider" then - instance.joint = jointSlider(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 1, p.dy or 0, p.dz or 0) - else - instance.joint = jointHinge(instance.node, node, p.ax or x, p.ay or y, p.az or z, p.dx or 0, p.dy or 1, p.dz or 0) - end - end -} - -AUTHOR.behaviours.turret = { - help = "Fires at the nearest instance of a type within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.", - params = { targets = "type", range = "number", rate = "number", spawns = "type", damage = "number", speed = "number" }, - attach = function(instance, params) - instance.turret = params - instance.lastShot = -math.huge - end, - step = function(instance) - local p = instance.turret - - if authorTime() - instance.lastShot < (p.rate or 1) then - return - end - local nearest, best = nil, p.range or 6 - - for _, target in ipairs(authorEach(p.targets or "")) do - local far = authorDistance3D(instance, target) - - if far < best then - nearest, best = target, far - end - end - if nearest == nil then - return - end - instance.lastShot = authorTime() - if p.spawns then - local x, y, z = authorPosition(instance) - local tx, ty, tz = authorPosition(nearest) - local speed = p.speed or 12 - local dx, dy, dz = tx - x, ty - y, tz - z - local far = math.max(0.001, math.sqrt(dx * dx + dy * dy + dz * dz)) - - authorSpawn(p.spawns, x, y, z, { vars = { vx = dx / far * speed, vy = dy / far * speed, vz = dz / far * speed }, owner = instance.id }) - else - authorDamage(nearest, p.damage or 1) - end - authorBehaviourEvent(instance, "fire") - end -} - -AUTHOR.behaviours.branching = { - help = "Branch windows over the disc: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. A branch with mash wants the move that many times inside the window; one with hold wants it held that many frames. Raises branchOpen, branchTaken, and branchMissed.", - params = { track = "track" }, - attach = function(instance, params) - instance.branching = params - instance.branchAt = nil - end, - step = function(instance) - local track = authorTrackNamed(instance.branching.track or "") - local frame = discGetFrame() - - if track == nil then - return - end - for _, branch in ipairs(track.branches or {}) do - if (frame >= branch.from) and (frame < branch.to) then - if instance.branchAt ~= branch then - instance.branchAt = branch - instance.decided = false - 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 = 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 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) - end - authorEvent("branchTaken", { self = instance, move = branch.move }) - end - end - elseif (instance.branchAt == branch) and (frame >= branch.to) and not instance.decided then - instance.decided = true - if branch.fail then - discSkipToFrame(branch.fail) - end - authorEvent("branchMissed", { self = instance, move = branch.move }) - end - end - end -} - -AUTHOR.behaviours.spin = { - help = "Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second.", - params = { rate = "number" }, - attach = function(instance, params) - instance.spinRate = params.rate or 90 - instance.vars.angle = instance.vars.angle or 0 - end, - step = function(instance, dt) - if AUTHOR_3D then - local rx, ry, rz = nodeGetRotation(instance.node) - - nodeSetRotation(instance.node, rx, (ry + instance.spinRate * dt) % 360, rz) - else - instance.vars.angle = (instance.vars.angle + instance.spinRate * dt) % 360 - end - end -} - -AUTHOR.behaviours.frames = { - help = "Steps a sprite look's frame: a range of frames per state, as \"idle=1-1, walk=2-5\", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look.", - params = { fps = "number", states = "string" }, - attach = function(instance, params) - instance.frameRanges = {} - for state, from, to in string.gmatch(tostring(params.states or ""), "(%w+)%s*=%s*(%d+)%s*-%s*(%d+)") do - instance.frameRanges[state] = { tonumber(from), tonumber(to) } - end - instance.fps = params.fps or 8 - instance.frameClock = 0 - instance.vars.frame = instance.vars.frame or 1 - end, - step = function(instance, dt) - local state = instance.vars.state or "idle" - local range = instance.frameRanges[state] - local facing = instance.vars.facing - local faced = facing and instance.frameRanges[state .. facing:sub(1, 1):upper() .. facing:sub(2)] - - -- Frames drawn the way the instance faces are used over the plain ones, and the look is - -- told, so it does not mirror them as well. - instance.drawnFacing = faced and facing or nil - range = faced or range or instance.frameRanges.idle - if range == nil then - return - end - instance.frameClock = instance.frameClock + dt * instance.fps - local count = range[2] - range[1] + 1 - local frame = range[1] + (math.floor(instance.frameClock) % count) - - instance.vars.frame = frame - end -} - -AUTHOR.behaviours.water = { - help = "Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy.", - params = { density = "number", drag = "number" }, - attach = function(instance, params) - local w, h, d = authorSize(instance) - - bodyNew(instance.node, BODY_STATIC, SHAPE_BOX, w, h, d) - bodySetWater(instance.node, params.density or 1, params.drag or 0.5) - instance.hasBody = true - end -} - -AUTHOR.behaviours.soft = { - help = "A mesh look made soft: a cloth, or a body inflated to a pressure.", - params = { type = "string", pressure = "number", stiffness = "number", mass = "number" }, - choices = { type = { "cloth", "body" } }, - attach = function(instance, params) - softNew(instance.node, (params.type == "cloth") and SOFT_CLOTH or SOFT_BODY) - if params.pressure then - softSetPressure(instance.node, params.pressure) - end - if params.stiffness then - softSetStiffness(instance.node, params.stiffness) - end - if params.mass then - softSetMass(instance.node, params.mass) - end - instance.soft = true - end -} - -AUTHOR.behaviours.hotspot = { - help = "Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do.", - params = { name = "string", polygon = "table", walkX = "number", walkY = "number", walkZ = "number" }, - attach = function(instance, params) - instance.hotspot = params - instance.polygon = authorNumbers(params.polygon) - if AUTHOR_3D and not instance.hasBody then - local w, h, d = authorSize(instance) - local box = nodeNew() - - nodeSetParent(box, instance.node) - nodeSetPosition(box, 0, h / 2, 0) - bodyNew(box, BODY_KINEMATIC, SHAPE_BOX, w, h, d) - bodySetTrigger(box, true) - AUTHOR_BY_NODE[box] = instance - instance.nodes[#instance.nodes + 1] = box - end - end -} - -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. 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 - instance.isTarget = true - end -} - - --- ===== Events ================================================================================= --- --- What a rule can be triggered by. filter names the parameters a rule may give to narrow it; --- the runtime matches them against the event's context. - -AUTHOR.events.frame = { help = "Every frame.", filter = {} } -AUTHOR.events.roomStart = { help = "A room has been entered.", filter = { room = "room" } } -AUTHOR.events.roomEnd = { help = "A room is about to be left.", filter = { room = "room" } } -AUTHOR.events.pressed = { help = "A key or a switch went down.", filter = { key = "scancode", switch = "switch", player = "number" } } -AUTHOR.events.released = { help = "A key or a switch came up.", filter = { key = "scancode", switch = "switch", player = "number" } } -AUTHOR.events.collision = { help = "An instance of type a began touching one of type b (self is a, other is b).", filter = { a = "type", b = "type" } } -AUTHOR.events.hit = { help = "A gun shot landed on self; other is the gun.", filter = { type = "type", player = "number" } } -AUTHOR.events.miss = { help = "A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen.", filter = { player = "number" } } -AUTHOR.events.death = { help = "self's health reached zero.", filter = { type = "type" } } -AUTHOR.events.spawn = { help = "self has just been made.", filter = { type = "type" } } -AUTHOR.events.timer = { help = "A timer on self went off; event.name says which.", filter = { name = "string", type = "type" } } -AUTHOR.events.frameReached = { help = "The disc passed a frame.", filter = { frame = "number" } } -AUTHOR.events.gameOver = { help = "The game ended.", filter = {} } -AUTHOR.events.enter = { help = "An instance of type b entered a trigger of type a (self is the trigger, other what entered).", filter = { a = "type", b = "type" } } -AUTHOR.events.leave = { help = "An instance of type b left a trigger of type a.", filter = { a = "type", b = "type" } } -AUTHOR.events.arrived = { help = "A seeking instance reached its target.", filter = { type = "type" } } -AUTHOR.events.stopped = { help = "A rail camera reached a stop; event.name says which. pathNext moves it on.", filter = { name = "string" } } -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, 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" } } -AUTHOR.events.patrolEnd = { help = "A patrolling instance reached the last of its points.", filter = { type = "type" } } -AUTHOR.events.animationDone = { help = "A clip that does not loop ended on self; event.state says which state it was for.", filter = { type = "type", state = "state" } } -AUTHOR.events.soundDone = { help = "A clip that playSound or a sound behaviour started has ended; event.file says which, and self is the instance that played it.", filter = { file = "file", type = "type" } } - - --- ===== Conditions ============================================================================= --- --- Every entry emits a Lua expression. Parameters arrive as Lua fragments. - -AUTHOR.conditions.keyHeld = { - help = "A key is down.", - params = { key = "scancode" }, - emit = function(p) return "authorKeyHeld(" .. p.key .. ")" end -} - -AUTHOR.conditions.switchHeld = { - help = "A pad, gun, or mouse switch is down.", - params = { switch = "switch" }, - emit = function(p) return "authorSwitchHeld(" .. p.switch .. ")" end -} - -AUTHOR.conditions.keyPressed = { - help = "A key went down this frame.", - params = { key = "scancode" }, - emit = function(p) return "authorKeyPressed(" .. p.key .. ")" end -} - -AUTHOR.conditions.test = { - help = "An expression is true: self.health <= 0, count(\"zombie\") == 0, score > 1000.", - params = { expr = "expression" }, - emit = function(p) return "(" .. p.expr .. ")" end -} - -AUTHOR.conditions.timeBetween = { - help = "The game is between two moments, in seconds.", - params = { from = "number", to = "number" }, - emit = function(p) return "(authorTime() >= " .. p.from .. " and authorTime() < " .. p.to .. ")" end -} - -AUTHOR.conditions.discBetween = { - help = "The disc is between two frames -- the window a QTE is answered in.", - params = { from = "number", to = "number" }, - emit = function(p) return "authorDiscBetween(" .. p.from .. ", " .. p.to .. ")" end -} - -AUTHOR.conditions.onGround = { - help = "A platformer has ground under it.", - params = { entity = "entity" }, - emit = function(p) return "authorOnGround(" .. p.entity .. ")" end -} - -AUTHOR.conditions.touching = { - help = "Two instances overlap.", - params = { entity = "entity", other = "entity" }, - emit = function(p) return "authorTouching(" .. p.entity .. ", " .. p.other .. ")" end -} - -AUTHOR.conditions.below = { - help = "An instance has fallen past a line -- a pit, or the bottom of the screen.", - params = { entity = "entity", y = "number" }, - emit = function(p) return "(authorY(" .. p.entity .. ") > " .. p.y .. ")" end -} - -AUTHOR.conditions.inState = { - help = "An instance's state is this.", - params = { entity = "entity", state = "state" }, - emit = function(p) return "authorInState(" .. p.entity .. ", " .. p.state .. ")" end -} - -AUTHOR.conditions.pointerIn = { - help = "A player's pointer is over an instance.", - params = { entity = "entity", player = "number" }, - emit = function(p) return "authorPointerIn(" .. p.entity .. ", " .. p.player .. ")" end -} - -AUTHOR.conditions.pointerOffscreen = { - help = "A player's pointer is off the picture, which is how a light gun reloads.", - params = { player = "number" }, - emit = function(p) return "authorPointerOffscreen(" .. p.player .. ")" end -} - -AUTHOR.conditions.pictureAt = { - help = "The disc's picture at a point is bright, dark, or neither: a flash a game hides in its own video, a colour cue, or a lamp in the scene. A game with no disc reads black, since the engine answers zeroes when nothing is playing.", - params = { x = "number", y = "number", is = "string", high = "number", low = "number" }, - defaults = { is = "bright", high = PICTURE_BRIGHT, low = PICTURE_DARK }, - emit = function(p) return "authorPictureIs(" .. p.x .. ", " .. p.y .. ", " .. p.is .. ", " .. p.high .. ", " .. p.low .. ")" end -} - -AUTHOR.conditions.onScreen = { - help = "An instance's box is at least partly on the overlay.", - params = { entity = "entity" }, - emit = function(p) return "authorOnScreen(" .. p.entity .. ")" end -} - -AUTHOR.conditions.every = { - help = "True once every so many seconds, for a rule that fires steadily.", - params = { seconds = "number", tag = "string" }, - emit = function(p) return "authorEvery(" .. p.seconds .. ", " .. p.tag .. ")" end -} - -AUTHOR.conditions.chance = { - help = "True with a probability, 0 to 1.", - params = { p = "number" }, - emit = function(p) return "(math.random() < " .. p.p .. ")" end -} - -AUTHOR.conditions.hitPart = { - help = "The shot landed on a named zone of the target: head, weakspot.", - params = { part = "string" }, - emit = function(p) return "(event[\"part\"] == " .. p.part .. ")" end -} - -AUTHOR.conditions.waiting = { - help = "A rail camera is held at a stop.", - params = { entity = "entity" }, - emit = function(p) return "authorWaiting(" .. p.entity .. ")" end -} - -AUTHOR.conditions.has = { - help = "The inventory holds an item.", - params = { item = "string" }, - emit = function(p) return "authorHas(" .. p.item .. ")" end -} - -AUTHOR.conditions.hover = { - help = "A player's pointer is over the named hotspot.", - params = { target = "string", player = "number" }, - defaults = { player = 1 }, - emit = function(p) return "(authorHover(" .. p.player .. ") == " .. p.target .. ")" end -} - -AUTHOR.conditions.once = { - help = "Only the first time this rule would run -- per instance and per room unless scope is \"game\".", - params = { tag = "string", scope = "string" }, - optional = { scope = true }, - emit = function(p) return "authorOnce(" .. p.tag .. ", self, " .. p.scope .. ")" end -} - - --- ===== Actions ================================================================================ --- --- Every entry emits a Lua statement. waits marks the ones that take time: a rule with any of --- them runs as a coroutine, and the action yields until it is done. - -AUTHOR.actions.run = { - help = "Drive a platformer left (-1) or right (1) this frame.", - params = { entity = "entity", direction = "number" }, - emit = function(p) return "authorRun(" .. p.entity .. ", " .. p.direction .. ")" end -} - -AUTHOR.actions.jump = { - help = "Ask a platformer to jump.", - params = { entity = "entity" }, - emit = function(p) return "authorJump(" .. p.entity .. ")" end -} - -AUTHOR.actions.moveTo = { - help = "Put an instance somewhere.", - params = { entity = "entity", x = "number", y = "number", z = "number" }, - defaults = { z = 0 }, - emit = function(p) return "authorMoveTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end -} - -AUTHOR.actions.setText = { - help = "Change what a text instance says.", - params = { entity = "entity", text = "expression" }, - emit = function(p) return "authorSetVar(" .. p.entity .. ", \"text\", tostring(" .. p.text .. "))" end -} - -AUTHOR.actions.show = { - help = "Show or hide an instance.", - params = { entity = "entity", visible = "boolean" }, - emit = function(p) return "authorShow(" .. p.entity .. ", " .. p.visible .. ")" end -} - -AUTHOR.actions.addScore = { - help = "Add to the score.", - params = { amount = "number" }, - emit = function(p) return "authorAddVar(nil, \"score\", " .. p.amount .. ")" end -} - -AUTHOR.actions.setVar = { - help = "Set a var on an instance, or on the game with no entity.", - params = { entity = "entity", name = "string", value = "expression" }, - optional = { entity = true }, - emit = function(p) return "authorSetVar(" .. p.entity .. ", " .. p.name .. ", " .. p.value .. ")" end -} - -AUTHOR.actions.addVar = { - help = "Add to a var on an instance, or on the game with no entity.", - params = { entity = "entity", name = "string", amount = "number" }, - optional = { entity = true }, - emit = function(p) return "authorAddVar(" .. p.entity .. ", " .. p.name .. ", " .. p.amount .. ")" end -} - -AUTHOR.actions.setState = { - help = "Change an instance's state.", - params = { entity = "entity", state = "state" }, - emit = function(p) return "authorSetVar(" .. p.entity .. ", \"state\", " .. p.state .. ")" end -} - -AUTHOR.actions.damage = { - help = "Take from an instance's health; at zero it dies.", - params = { entity = "entity", amount = "number" }, - emit = function(p) return "authorDamage(" .. p.entity .. ", " .. p.amount .. ")" end -} - -AUTHOR.actions.spawn = { - help = "Make an instance of a type at a point.", - params = { type = "type", x = "number", y = "number", z = "number" }, - defaults = { z = 0 }, - emit = function(p) return "authorSpawn(" .. p.type .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end -} - -AUTHOR.actions.destroy = { - help = "Remove an instance.", - params = { entity = "entity" }, - emit = function(p) return "authorDestroy(" .. p.entity .. ")" end -} - -AUTHOR.actions.playSound = { - help = "Play a clip, at a volume of its own (0 to 100) under the game's.", - params = { file = "file", volume = "number" }, - defaults = { volume = 100 }, - emit = function(p) return "authorPlaySound(" .. p.file .. ", " .. p.volume .. ", self)" end -} - -AUTHOR.actions.playMusic = { - help = "Play a track, looping.", - params = { file = "file" }, - emit = function(p) return "authorPlayMusic(" .. p.file .. ")" end -} - -AUTHOR.actions.stopMusic = { - help = "Stop the track that is playing.", - params = {}, - emit = function() return "authorStopMusic()" end -} - -AUTHOR.actions.emit = { - help = "A burst of particles at an instance: an explosion, sparks, a puff.", - params = { entity = "entity", count = "number", r = "number", g = "number", b = "number", speed = "number" }, - defaults = { count = 20, r = 255, g = 200, b = 60, speed = 150 }, - emit = function(p) return "authorEmit(" .. p.entity .. ", " .. p.count .. ", " .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.speed .. ")" end -} - -AUTHOR.actions.say = { - help = "Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn.", - params = { text = "expression", seconds = "number" }, - optional = { seconds = true }, - waits = true, - emit = function(p) return "authorSay(" .. p.text .. ", " .. p.seconds .. ")" end -} - -AUTHOR.actions.wait = { - help = "Wait some seconds before the next action.", - params = { seconds = "number" }, - waits = true, - emit = function(p) return "authorWaitSeconds(" .. p.seconds .. ")" end -} - -AUTHOR.actions.flash = { - help = "Flash the screen a colour.", - params = { r = "number", g = "number", b = "number", seconds = "number" }, - defaults = { r = 255, g = 255, b = 255, seconds = 0.2 }, - emit = function(p) return "authorFlash(" .. p.r .. ", " .. p.g .. ", " .. p.b .. ", " .. p.seconds .. ")" end -} - -AUTHOR.actions.timerStart = { - help = "Start a named timer on an instance.", - params = { entity = "entity", name = "string", after = "number" }, - emit = function(p) return "authorTimerStart(" .. p.entity .. ", " .. p.name .. ", " .. p.after .. ")" end -} - -AUTHOR.actions.goTo = { - help = "Go to a room, taking the named entity along and placing it at a point there -- or where the entity called at stands in that room.", - params = { room = "room", entity = "entity", x = "number", y = "number", z = "number", at = "string" }, - optional = { entity = true, x = true, y = true, z = true, at = true }, - emit = function(p) return "authorGoTo(" .. p.room .. ", " .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.at .. ")" end -} - -AUTHOR.actions.reload = { - help = "Fill a gun's ammo.", - params = { entity = "entity" }, - emit = function(p) return "authorReload(" .. p.entity .. ")" end -} - -AUTHOR.actions.discTo = { - help = "Send the disc to a frame -- the branch a QTE takes.", - params = { frame = "number" }, - 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 = {}, - emit = function() return "authorGameOver()" end -} - -AUTHOR.actions.restart = { - help = "Begin the game again from its first room.", - params = {}, - emit = function() return "authorRestart()" end -} - -AUTHOR.actions.pathNext = { - help = "Send a rail camera on from its stop.", - params = { entity = "entity" }, - emit = function(p) return "authorPathNext(" .. p.entity .. ")" end -} - -AUTHOR.actions.playAnimation = { - help = "Play a model's clip, once or looping.", - params = { entity = "entity", clip = "string", loop = "boolean" }, - defaults = { loop = false }, - emit = function(p) return "authorPlayAnimation(" .. p.entity .. ", " .. p.clip .. ", " .. p.loop .. ")" end -} - -AUTHOR.actions.lookAt = { - help = "Turn an instance toward another.", - params = { entity = "entity", target = "entity" }, - emit = function(p) return "authorLookAt(" .. p.entity .. ", " .. p.target .. ")" end -} - -AUTHOR.actions.push = { - help = "Shove a body, or a ragdoll's bone, along a direction.", - params = { entity = "entity", x = "number", y = "number", z = "number", bone = "string" }, - defaults = { x = 0, y = 0, z = 0 }, - optional = { bone = true }, - emit = function(p) return "authorPush(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ", " .. p.bone .. ")" end -} - -AUTHOR.actions.shake = { - help = "Shake the camera for a moment.", - params = { amount = "number", seconds = "number" }, - defaults = { amount = 0.3, seconds = 0.4 }, - emit = function(p) return "authorShake(" .. p.amount .. ", " .. p.seconds .. ")" end -} - -AUTHOR.actions.cameraCut = { - help = "Draw the scene from another camera instance.", - params = { entity = "entity" }, - emit = function(p) return "authorCameraCut(" .. p.entity .. ")" end -} - -AUTHOR.actions.spawnAtPointer = { - help = "Make an instance of a type where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.", - params = { type = "type", player = "number" }, - defaults = { player = 1 }, - emit = function(p) return "authorSpawnAtPointer(" .. p.type .. ", " .. p.player .. ")" end -} - -AUTHOR.actions.walkTo = { - help = "Send a walker to a point, and wait until it gets there.", - params = { entity = "entity", x = "number", y = "number", z = "number" }, - defaults = { z = 0 }, - waits = true, - emit = function(p) return "authorWalkTo(" .. p.entity .. ", " .. p.x .. ", " .. p.y .. ", " .. p.z .. ")" end -} - -AUTHOR.actions.walkToPointer = { - help = "Send a walker where the player's pointer is, and wait.", - params = { entity = "entity", player = "number" }, - defaults = { player = 1 }, - waits = true, - emit = function(p) return "authorWalkToPointer(" .. p.entity .. ", " .. p.player .. ")" end -} - -AUTHOR.actions.walkToHotspot = { - help = "Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait.", - params = { entity = "entity", target = "entity" }, - waits = true, - emit = function(p) return "authorWalkToHotspot(" .. p.entity .. ", " .. p.target .. ")" end -} - -AUTHOR.actions.face = { - help = "Turn a walker to face left or right, or toward an instance.", - params = { entity = "entity", direction = "string", target = "entity" }, - optional = { direction = true, target = true }, - emit = function(p) return "authorFace(" .. p.entity .. ", " .. p.direction .. ", " .. p.target .. ")" end -} - -AUTHOR.actions.give = { - help = "Put an item in the inventory.", - params = { item = "string" }, - emit = function(p) return "authorGive(" .. p.item .. ")" end -} - -AUTHOR.actions.take = { - help = "Take an item out of the inventory.", - params = { item = "string" }, - emit = function(p) return "authorTake(" .. p.item .. ")" end -} - -AUTHOR.actions.setVerb = { - help = "Choose the verb the next click uses.", - params = { verb = "string" }, - emit = function(p) return "authorSetVerb(" .. p.verb .. ")" end -} - -AUTHOR.actions.nextVerb = { - help = "Cycle to the next verb in the game's list.", - params = {}, - emit = function() return "authorNextVerb()" end -} - -AUTHOR.actions.useItem = { - help = "Choose an inventory item for the next click: \"use key on ...\".", - params = { item = "string" }, - emit = function(p) return "authorUseItem(" .. p.item .. ")" end -} - -AUTHOR.actions.talk = { - help = "Run a dialogue from the game's dialogues, and wait for it to end.", - params = { dialogue = "string" }, - waits = true, - emit = function(p) return "authorTalk(" .. p.dialogue .. ")" end -} - -AUTHOR.actions.fade = { - help = "Fade the picture to black, or back (with out false), and wait.", - params = { seconds = "number", out = "boolean" }, - defaults = { seconds = FADE_DEFAULT, out = true }, - waits = true, - emit = function(p) return "authorFade(" .. p.seconds .. ", " .. p.out .. ")" end -} - -AUTHOR.actions.saveGame = { - help = "Keep the whole game -- room, positions, vars, inventory -- in a numbered slot.", - params = { slot = "number" }, - defaults = { slot = 1 }, - emit = function(p) return "authorSaveGame(" .. p.slot .. ")" end -} - -AUTHOR.actions.loadGame = { - help = "Bring a slot back.", - params = { slot = "number" }, - defaults = { slot = 1 }, - emit = function(p) return "authorLoadGame(" .. p.slot .. ")" end -} - -AUTHOR.actions.die = { - help = "A death: a message, then the game starts over.", - params = { text = "expression" }, - waits = true, - emit = function(p) return "authorDie(" .. p.text .. ")" end -} - -AUTHOR.actions.submitScore = { - help = "Send the score to the master service's board for this game, queued until it can go.", - params = { board = "string" }, - emit = function(p) return "authorSubmitScore(" .. (p.board or '"default"') .. ")" end -} - -AUTHOR.actions.credit = { - help = "A coin: one more credit.", - params = {}, - emit = function() return "authorAddVar(nil, \"credits\", 1)" end -} - -AUTHOR.actions.lua = { - help = "Anything the vocabulary cannot say. The way out, and it is meant to be used.", - params = { code = "lua" }, - emit = function(p) return p.code end -} - - --- ===== Instances ============================================================================== - -local function nextId(typeName) - local id = typeName .. "#" .. nextSerial - - nextSerial = nextSerial + 1 - - return id -end - - -local function copyOf(value) - local out - - if type(value) ~= "table" then - return value - end - out = {} - for key, item in pairs(value) do - out[key] = copyOf(item) - end - - return out -end - - --- Makes an instance of a type. overrides is an entities entry: id, x, y, z, and vars. -local function make(typeName, x, y, z, overrides) - local def = AUTHOR_GAME.types[typeName] - local instance - - if def == nil then - debugPrint("Author: no type called '" .. tostring(typeName) .. "'") - return nil - end - overrides = overrides or {} - instance = { - id = overrides.id or nextId(typeName), - type = typeName, - def = def, - look = copyOf(def.look or { kind = "none" }), - behaviours = def.behaviours or {}, - vars = copyOf(def.vars or {}), - visible = true, - alive = true, - node = nodeNew(), - nodes = {} - } - for key, value in pairs(overrides.vars or {}) do - instance.vars[key] = copyOf(value) - end - for key, value in pairs(overrides) do - if (key ~= "id") and (key ~= "x") and (key ~= "y") and (key ~= "z") and (key ~= "rx") and (key ~= "ry") and (key ~= "rz") and (key ~= "scale") and (key ~= "type") and (key ~= "vars") then - instance.vars[key] = copyOf(value) - end - end - instance.vars.drive = 0 - instance.vars.state = instance.vars.state or "idle" - nodeSetPosition(instance.node, x or 0, y or 0, z or 0) - if overrides.rx or overrides.ry or overrides.rz then - nodeSetRotation(instance.node, overrides.rx or 0, overrides.ry or 0, overrides.rz or 0) - end - -- A scale on the entity scales its node, and with it its look, its body (bodies take the node's - -- scale), and how big it counts as. - instance.scale = overrides.scale or 1 - if instance.scale ~= 1 then - nodeSetScale(instance.node, instance.scale) - end - if overrides.rz and not AUTHOR_3D then - instance.vars.angle = overrides.rz - end - - local look = AUTHOR.looks[instance.look.kind] - - if look == nil then - debugPrint("Author: no look called '" .. tostring(instance.look.kind) .. "' on " .. instance.id .. "; drawing a box") - instance.look.kind = "box" - look = AUTHOR.looks.box - end - if look.load then - look.load(instance) - end - AUTHOR_LIVE[#AUTHOR_LIVE + 1] = instance - AUTHOR_BY_ID[instance.id] = instance - AUTHOR_BY_NODE[instance.node] = instance - for _, b in ipairs(instance.behaviours) do - local behaviour = AUTHOR.behaviours[b.kind] - - if behaviour == nil then - debugPrint("Author: no behaviour called '" .. tostring(b.kind) .. "'") - elseif behaviour.attach then - behaviour.attach(instance, b) - end - end - - return instance -end - - -function authorSpawn(typeName, x, y, z, overrides) - local instance = make(typeName, x, y, z, overrides) - - if instance ~= nil then - authorEvent("spawn", { self = instance }) - end - - return instance -end - - --- Removes an instance now. Its node goes with it, and any body or player on the node. -function authorDestroy(instance) - if (instance == nil) or not instance.alive then - return - end - instance.alive = false - AUTHOR_BY_ID[instance.id] = nil - AUTHOR_BY_NODE[instance.node] = nil - for _, node in ipairs(instance.nodes) do - AUTHOR_BY_NODE[node] = nil - end - if instance.ragdoll then - ragdollDelete(instance.model) - end - for at = #AUTHOR_LIVE, 1, -1 do - if AUTHOR_LIVE[at] == instance then - table.remove(AUTHOR_LIVE, at) - end - end - for at = #timers, 1, -1 do - if timers[at].instance == instance then - table.remove(timers, at) - end - end - if instance.emitter then - emitterDelete(instance.emitter) - end - if instance.ownSprite then - spriteUnload(instance.ownSprite) - end - if instance.agent then - navAgentDelete(instance.agent) - AUTHOR_AGENTS[instance.agent] = nil - end - if instance.navNode and (instance.navNode ~= instance.node) then - nodeDelete(instance.navNode) - end - nodeDelete(instance.node) -end - - --- ===== The runtime the generated Lua calls ==================================================== - -function authorKeyHeld(scancode) - return AUTHOR_KEYS[scancode] == true -end - - -function authorKeyPressed(scancode) - return pressedNow[scancode] == true -end - - -function authorSwitchHeld(switch) - return AUTHOR_SWITCHES[switch] == true -end - - -function authorDiscBetween(from, to) - local frame = discGetFrame() - - return (frame >= from) and (frame < to) -end - - -function authorOnGround(instance) - return (instance ~= nil) and instance.player and playerIsOnGround(instance.node) -end - - -function authorTouching(a, b) - if (a == nil) or (b == nil) then - return false - end - if AUTHOR_3D then - local ax, ay, az = authorPosition(a) - local bx, by, bz = authorPosition(b) - local aw, ah, ad = authorSize(a) - local bw, bh, bd = authorSize(b) - - return (math.abs(ax - bx) * 2 < aw + bw) and (math.abs(ay - by) * 2 < ah + bh) and (math.abs(az - bz) * 2 < (ad or aw) + (bd or bw)) - end - local ax, ay, aw, ah = authorBounds(a) - local bx, by, bw, bh = authorBounds(b) - - return collideRects(ax, ay, aw, ah, bx, by, bw, bh) -end - - -function authorInState(instance, state) - return (instance ~= nil) and (instance.vars.state == state) -end - - -function authorOnScreen(instance) - if instance == nil then - return false - end - local x, y, w, h = authorBounds(instance) - - return collideRects(x, y, w, h, 0, 0, overlayGetWidth(), overlayGetHeight()) -end - - --- True the first time only, per instance and room by default; "game" makes it once ever. -function authorOnce(tag, instance, scope) - local key = tag - - if scope ~= "game" then - key = (AUTHOR_ROOM and AUTHOR_ROOM.name or "") .. "/" .. (instance and instance.id or "") .. "/" .. tag - end - if onceSeen[key] then - return false - end - onceSeen[key] = true - - return true -end - - -local everyLast = {} - -function authorEvery(seconds, tag) - local now = authorTime() - - if (everyLast[tag] == nil) or (now - everyLast[tag] >= seconds) then - everyLast[tag] = now - return true - end - - return false -end - - -function authorRun(instance, direction) - if instance then - instance.vars.drive = direction - end -end - - -function authorJump(instance) - if instance and instance.player then - playerJump(instance.node, instance.jump) - end -end - - -function authorMoveTo(instance, x, y, z) - if instance == nil then - return - end - if instance.player then - playerSetPosition(instance.node, x, y, z or 0) - else - nodeSetPosition(instance.node, x, y, z or 0) - end -end - - -function authorShow(instance, visible) - if instance then - instance.visible = visible - end -end - - --- A var on an instance, or on the game with nil. -function authorSetVar(instance, name, value) - if instance == nil then - AUTHOR_VARS[name] = value - elseif instance.alive then - instance.vars[name] = value - end -end - - -function authorAddVar(instance, name, amount) - local vars = instance and instance.vars or AUTHOR_VARS - - vars[name] = (vars[name] or 0) + amount -end - - -function authorDamage(instance, amount) - if (instance == nil) or not instance.alive then - return - end - instance.vars.health = (instance.vars.health or 1) - amount - authorBehaviourEvent(instance, "hit") - if instance.vars.health <= 0 then - instance.vars.state = "dead" - authorBehaviourEvent(instance, "death") - authorEvent("death", { self = instance }) - if not instance.keepDead then - authorDestroy(instance) - end - end -end - - --- Whether a sprite look is drawn mirrored: its art faces one way (faces: right unless said), the --- instance faces the other, and its sheet has no frames drawn that way (the frames behaviour says --- so by drawnFacing). Facing up or down never mirrors. -function authorMirrored(instance) - local faces = instance.look.faces or "right" - local opposite = (faces == "right") and "left" or ((faces == "left") and "right" or nil) - - if (opposite == nil) or (instance.vars.facing ~= opposite) then - return false - end - - return instance.drawnFacing ~= opposite -end - - --- A clip at a Forge volume, 0 to 100, under the engine's master. --- Plays a clip at a volume of its own, remembering who played it so soundDone can say so. -function authorPlaySound(file, volume, owner) - local channel = soundPlay(soundFor(file), 0, math.floor(math.max(0, math.min(100, volume or 100)) * SOUND_MAX_VOLUME / 100 + 0.5)) - - if channel and (channel >= 0) then - playing[channel] = { file = file, self = owner } - end - - return channel -end - - --- The engine says a channel has finished: soundDone for the clip that was on it, with the --- instance that played it as self. -function authorSoundDone(channel) - local was = playing[channel] - - if was == nil then - return - end - playing[channel] = nil - authorEvent("soundDone", { self = was.self, file = was.file }) -end - - -function authorPlayMusic(file) - if music then - musicStop(music) - end - music = musicLoad(authorFile(file)) - musicPlay(music, -1) -end - - -function authorStopMusic() - if music then - musicStop(music) - end -end - - -function authorEmit(instance, count, r, g, b, speed) - if instance == nil then - return - end - local x, y = authorPosition(instance) - - if instance.emitter == nil then - instance.emitter = emitterNew() - emitterSetLife(instance.emitter, 0.3, 0.7) - emitterSetSpread(instance.emitter, 360) - emitterSetSize(instance.emitter, 6, 1) - end - emitterSetPosition(instance.emitter, x, y) - emitterSetColor(instance.emitter, r, g, b, 255, r, g, b, 0) - emitterSetSpeed(instance.emitter, speed * 0.5, speed) - emitterBurst(instance.emitter, count) -end - - -function authorFlash(r, g, b, seconds) - flashLine = { r = r, g = g, b = b, seconds = seconds, till = authorTime() + seconds } -end - - -function authorTimerStart(instance, name, after, every) - if instance == nil then - return - end - timers[#timers + 1] = { instance = instance, name = name, at = authorTime() + (after or 0), every = every } -end - - -function authorReload(instance) - if instance and instance.gun then - instance.vars.ammo = instance.gun.ammo or 6 - authorBehaviourEvent(instance, "reload") - end -end - - -function authorGameOver() - if gameOver then - return - end - gameOver = true - -- The results card, and the best score kept for next time. - local best = math.max(AUTHOR_VARS.best or 0, AUTHOR_VARS.score or 0) - - AUTHOR_VARS.best = best - saveSet(BEST_KEY, best) - results = { score = AUTHOR_VARS.score or 0, best = best } - authorEvent("gameOver", {}) -end - - --- The master service's client is loaded when a score is first sent, and pumped from then on. A --- test may supply masterSubmitScore itself. -function authorSubmitScore(board) - if not masterReady then - if masterSubmitScore == nil then - dofile("Singe/Net.singe") - dofile("Singe/Master.singe") - masterLoad() - end - masterReady = true - end - masterSubmitScore(singeGetGameId(), AUTHOR_VARS.score or 0, board) -end - - -local function updateBezel() - if bezel == nil then - return - end - local shows = { { "score", 1 }, { "lives", 1 }, { "score2", 2 }, { "lives2", 2 } } - - for _, item in ipairs(shows) do - local value = AUTHOR_VARS[item[1]] - - if (value ~= nil) and (bezel.shown[item[1]] ~= value) then - bezel.shown[item[1]] = value - if item[1]:sub(1, 5) == "score" then - scoreBezelScore(item[2], value) - else - scoreBezelLives(item[2], value) - end - end - end - if (AUTHOR_VARS.credits ~= nil) and (bezel.shown.credits ~= AUTHOR_VARS.credits) then - bezel.shown.credits = AUTHOR_VARS.credits - scoreBezelCredits(AUTHOR_VARS.credits) - end -end - - --- ===== The scene ============================================================================== - --- Where a target named in a behaviour is: "camera", an entity's id, or nothing. -function authorTargetInstance(name) - if name == "camera" then - return cameraNode and AUTHOR_BY_NODE[cameraNode] or nil - end - - return AUTHOR_BY_ID[name or ""] -end - - -function authorTargetPosition(name) - local target = authorTargetInstance(name) - - if target == nil then - return nil - end - - return authorPosition(target) -end - - --- The camera's forward and right on the ground, for a character that moves relative to the view. --- Without a camera, world axes: forward is -Z. -function authorCameraAxes() - local camera = cameraNode and AUTHOR_BY_NODE[cameraNode] or nil - - if (camera == nil) or (camera.camera == nil) then - return 0, -1, 1, 0 - end - local yaw - - if camera.camera.mode == "first" or camera.camera.mode == "orbit" then - yaw = math.rad(camera.yaw) - else - local x, _, z = authorPosition(camera) - local tx, _, tz = authorTargetPosition(camera.camera.target) - - if tx == nil then - return 0, -1, 1, 0 - end - yaw = math.atan(x - tx, z - tz) - end - -- Forward is away from the camera, right is ninety degrees round from it. - local fx, fz = -math.sin(yaw), -math.cos(yaw) - - return fx, fz, -fz, fx -end - - --- The instance a node belongs to. Every node an instance owns is registered when it is made, so --- this never asks the engine -- which matters because physics can report a node a rule has already --- deleted, a prize destroyed as it is entered, and asking about that node would end the game. -function authorInstanceOf(node) - return node and AUTHOR_BY_NODE[node] or nil -end - - -function authorWaiting(instance) - return (instance ~= nil) and (instance.rail ~= nil) and (instance.rail.waiting ~= nil) -end - - -function authorPathNext(instance) - if instance and instance.rail then - instance.rail.waiting = nil - end -end - - -function authorPlayAnimation(instance, clip, loop) - if instance and instance.model then - animationPlay(instance.model, clip, loop) - instance.playing = clip - end -end - - -function authorLookAt(instance, target) - if instance and target then - local x, y, z = authorPosition(target) - - nodeLookAt(instance.model or instance.node, x, y, z) - end -end - - -function authorPush(instance, x, y, z, bone) - if instance == nil then - return - end - if instance.ragdoll and bone then - ragdollApplyImpulse(instance.model, bone, x, y, z) - elseif instance.hasBody then - bodyApplyImpulse(instance.node, x, y, z) - end -end - - -local shaking = nil - -function authorShake(amount, seconds) - shaking = { amount = amount, till = authorTime() + seconds } -end - - -function authorCameraCut(instance) - if instance then - cameraNode = instance.node - cameraSet(instance.node) - end -end - - --- One step along a rail: the camera track's points are positions by time, each looking at a --- point or an entity, and a point may be a stop that holds the rail until pathNext. -function authorRailStep(instance, dt) - local rail = instance.rail - local track = authorTrackNamed(instance.camera.track or "") - local points = track and track.points or {} - - if #points == 0 then - return - end - if rail.waiting == nil then - rail.t = rail.t + dt - end - local before, after - - for _, point in ipairs(points) do - if (point.at <= rail.t) and ((before == nil) or (point.at > before.at)) then - before = point - end - if (point.at >= rail.t) and ((after == nil) or (point.at < after.at)) then - after = point - end - end - if before == nil then - before = after - end - if after == nil then - after = before - if not rail.ended then - rail.ended = true - authorEvent("railEnd", { self = instance }) - end - end - -- A stop holds the rail the moment it is reached, once. - if before.stop and not rail.passed[before] and (rail.t >= before.at) then - rail.passed[before] = true - rail.waiting = before.stop - rail.t = before.at - authorEvent("stopped", { self = instance, name = before.stop }) - authorWavesAtStop(before.stop) - end - local k = 0 - - if (after ~= before) and (after.at > before.at) then - k = (rail.t - before.at) / (after.at - before.at) - end - local x = before.x + (after.x - before.x) * k - local y = before.y + (after.y - before.y) * k - local z = before.z + (after.z - before.z) * k - - if shaking then - if authorTime() >= shaking.till then - shaking = nil - else - x = x + (math.random() - 0.5) * shaking.amount - y = y + (math.random() - 0.5) * shaking.amount - end - end - nodeSetPosition(instance.node, x, y, z) - local look = before.look - - if type(look) == "string" then - local lx, ly, lz = authorTargetPosition(look) - - if lx then - nodeLookAt(instance.node, lx, ly, lz) - end - elseif type(look) == "table" then - local lx = look.x or look[1] or 0 - local ly = look.y or look[2] or 0 - local lz = look.z or look[3] or 0 - - if (after ~= before) and (type(after.look) == "table") then - lx = lx + ((after.look.x or after.look[1] or 0) - lx) * k - ly = ly + ((after.look.y or after.look[2] or 0) - ly) * k - lz = lz + ((after.look.z or after.look[3] or 0) - lz) * k - end - nodeLookAt(instance.node, lx, ly, lz) - end -end - - --- Physics reports a contact between two bodies; the rules hear about it by type. -function authorCollision(nodeA, nodeB, x, y, z, speed) - local a = authorInstanceOf(nodeA) - local b = authorInstanceOf(nodeB) - - if a and b then - authorEvent("collision", { self = a, other = b, x = x, y = y, z = z, speed = speed }) - authorEvent("collision", { self = b, other = a, x = x, y = y, z = z, speed = speed }) - end -end - - -function authorTrigger(trigger, other, entered) - local a = authorInstanceOf(trigger) - local b = authorInstanceOf(other) - - if a and b then - authorEvent(entered and "enter" or "leave", { self = a, other = b }) - end -end - - --- A MIDI message from the input port: note-on with a velocity is a note, and the rules hear it. -function authorMidi(status, data1, data2) - local kind = math.floor((status or 0) / 16) - - if (kind == 9) and ((data2 or 0) > 0) then - authorEvent("midi", { pitch = data1, velocity = data2, channel = (status % 16) + 1 }) - end -end - - -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 - - --- ===== Walking, hotspots, verbs, and the parser =============================================== --- --- The adventure's pieces (FORGE.md section 3). A room's walk areas become a navigation mesh so --- walking in a painted room and in a modelled one is the same agent; a hotspot is where a verb --- lands; the sentence line is built from the verb, the hotspot under the pointer, and the item in --- hand; the parser turns a typed line into the same kind of event. - -AUTHOR_AGENTS = {} -- Instances by navigation agent, for onNavArrived. - - --- Numbers from a table, or from text like "10,20, 30,40" typed in the editor. -function authorNumbers(value) - if type(value) == "table" then - return value - end - if type(value) ~= "string" then - return nil - end - local out = {} - - for number in string.gmatch(value, "-?[%d.]+") do - out[#out + 1] = tonumber(number) - end - - return (#out > 0) and out or nil -end - - --- Ear clipping: a polygon of x,y pairs into triangles, as vertex indexes counted from 1. -local function triangulate(polygon) - local points = {} - local order = {} - local tris = {} - - for at = 1, #polygon - 1, 2 do - points[#points + 1] = { polygon[at], polygon[at + 1] } - order[#order + 1] = #points - end - local function area2(a, b, c) - return (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2]) - end - local function inside(p, a, b, c) - local s1 = area2(a, b, p) - local s2 = area2(b, c, p) - local s3 = area2(c, a, p) - - return ((s1 >= 0) and (s2 >= 0) and (s3 >= 0)) or ((s1 <= 0) and (s2 <= 0) and (s3 <= 0)) - end - local total = 0 - - for at = 1, #points do - local a = points[at] - local b = points[(at % #points) + 1] - - total = total + a[1] * b[2] - b[1] * a[2] - end - local sign = (total >= 0) and 1 or -1 - local guard = 0 - - while (#order > 3) and (guard < 1000) do - local clipped = false - - guard = guard + 1 - for at = 1, #order do - local i0 = order[((at - 2) % #order) + 1] - local i1 = order[at] - local i2 = order[(at % #order) + 1] - local a, b, c = points[i0], points[i1], points[i2] - - if area2(a, b, c) * sign > 0 then - local ear = true - - for _, other in ipairs(order) do - if (other ~= i0) and (other ~= i1) and (other ~= i2) and inside(points[other], a, b, c) then - ear = false - end - end - if ear then - tris[#tris + 1] = { i0, i1, i2 } - table.remove(order, at) - clipped = true - break - end - end - end - if not clipped then - break - end - end - if #order == 3 then - tris[#tris + 1] = { order[1], order[2], order[3] } - end - - return points, tris -end - - --- The current room's navigation mesh, baked once: from its walk polygons in 2D, from the meshes --- of the entities navFrom names in 3D. nil for a room with neither. -function authorRoomNav() - local room = AUTHOR_ROOM - - if room == nil then - return nil - end - if room.nav ~= nil then - return room.nav or nil - end - if room.walk and (#room.walk > 0) then - local nav = navNew(NAV_RADIUS_2D, NAV_HEIGHT_2D, 60, 0.05) - - for _, polygon in ipairs(room.walk) do - local points, tris = triangulate(authorNumbers(polygon) or {}) - local positions = {} - local indices = {} - - for _, point in ipairs(points) do - positions[#positions + 1] = point[1] * NAV_SCALE - positions[#positions + 1] = 0 - positions[#positions + 1] = point[2] * NAV_SCALE - end - for _, tri in ipairs(tris) do - -- Wound so the face looks up, whichever way the author drew the outline. - local a, b, c = points[tri[1]], points[tri[2]], points[tri[3]] - local up = (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2]) - - if up > 0 then - indices[#indices + 1] = tri[1] - indices[#indices + 1] = tri[3] - indices[#indices + 1] = tri[2] - else - indices[#indices + 1] = tri[1] - indices[#indices + 1] = tri[2] - indices[#indices + 1] = tri[3] - end - end - if #indices > 0 then - local node = nodeNew() - - nodeSetMesh(node, meshNew(positions, nil, nil, indices)) - nodeSetVisible(node, false) - navAddNode(nav, node) - end - end - navBuild(nav) - room.nav = { nav = nav, scale = NAV_SCALE, radius = NAV_RADIUS_2D, height = NAV_HEIGHT_2D } - elseif room.navFrom then - local nav = navNew(0.3, 1.0, 45, 0.4) - - for _, id in ipairs(room.navFrom) do - local floor = AUTHOR_BY_ID[id] - - if floor then - navAddNode(nav, floor.node) - end - end - navBuild(nav) - room.nav = { nav = nav, scale = 1, radius = 0.3, height = 1.0 } - else - room.nav = false - end - - return room.nav or nil -end - - --- Sends a walker somewhere. Answers whether it set off. -function authorWalkStart(instance, x, y, z) - local nav = authorRoomNav() - - if (instance == nil) or (instance.agent == nil) or (nav == nil) then - return false - end - local tx, ty, tz - - if AUTHOR_3D then - tx, ty, tz = x, y, z or 0 - else - tx, ty, tz = x * NAV_SCALE, 0, y * NAV_SCALE - end - local sx, sy, sz = navNearest(nav.nav, tx, ty, tz) - - if sx == nil then - return false - end - if navAgentMoveTo(instance.agent, sx, sy, sz) then - instance.walking = true - instance.vars.state = "walk" - return true - end - - return false -end - - --- Sends a walker somewhere and waits until it arrives, or gives up after a while. -function authorWalkTo(instance, x, y, z) - if authorWalkStart(instance, x, y, z) then - local till = authorTime() + WALK_TIMEOUT - - authorWait(function() return (not instance.alive) or (not instance.walking) or (authorTime() > till) end) - end -end - - -function authorDistance3D(a, b) - local ax, ay, az = authorPosition(a) - local bx, by, bz = authorPosition(b) - - return math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2 + (az - bz) ^ 2) -end - - -function authorSpawnAtPointer(typeName, player) - local x, y, z = authorPointerWorld(player) - - if x ~= nil then - return authorSpawn(typeName, x, y, z) - end - - return nil -end - - --- The world point under a player's pointer: the overlay point in 2D, the floor the ray hits in 3D. -function authorPointerWorld(player) - local px, py, on = authorPointer(player) - - if not on then - return nil - end - if not AUTHOR_3D then - return px, py, 0 - end - local x0, y0, z0 = sceneUnproject(px, py, 0) - local x1, y1, z1 = sceneUnproject(px, py, 10) - local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) - - if node == nil then - return nil - end - - return hx, hy, hz -end - - -function authorWalkToPointer(instance, player) - local x, y, z = authorPointerWorld(player) - - if x ~= nil then - authorWalkTo(instance, x, y, z) - end -end - - -function authorWalkToHotspot(instance, target) - if (target == nil) or (target.hotspot == nil) then - return - end - local p = target.hotspot - local x, y, z = authorPosition(target) - - authorWalkTo(instance, p.walkX or x, p.walkY or y, p.walkZ or z) -end - - -function authorFace(instance, direction, target) - if instance == nil then - return - end - if target then - local x, y, z = authorPosition(instance) - local tx, ty, tz = authorPosition(target) - - instance.vars.facing = (tx < x) and "left" or "right" - if instance.model then - nodeLookAt(instance.model, tx, y, tz) - end - elseif direction then - instance.vars.facing = direction - end -end - - -function authorNavArrived(agent) - local instance = AUTHOR_AGENTS[agent] - - if instance and instance.walking then - instance.walking = false - instance.vars.state = "idle" - authorEvent("arrived", { self = instance }) - end -end - - --- The hotspot under a player's pointer, or nil. The topmost in 2D; what the ray meets in 3D. -function authorHoverInstance(player) - local px, py, on = authorPointer(player) - - if not on then - return nil - end - if AUTHOR_3D then - local x0, y0, z0 = sceneUnproject(px, py, 0) - local x1, y1, z1 = sceneUnproject(px, py, 10) - local node = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) - local hit = node and AUTHOR_BY_NODE[node] or nil - - return (hit and hit.hotspot) and hit or nil - end - for at = #AUTHOR_LIVE, 1, -1 do - local instance = AUTHOR_LIVE[at] - - if instance.alive and instance.visible and instance.hotspot then - if instance.polygon then - if collidePointPolygon(px, py, instance.polygon) then - return instance - end - else - local x, y, w, h = authorBounds(instance) - - if collidePointRect(px, py, x, y, w, h) then - return instance - end - end - end - end - - return nil -end - - -function authorHover(player) - local instance = authorHoverInstance(player) - - return instance and (instance.hotspot.name or instance.id) or nil -end - - -function authorGive(item) - AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {} - if not authorHas(item) then - AUTHOR_VARS.inventory[#AUTHOR_VARS.inventory + 1] = item - end -end - - -function authorTake(item) - for at = #(AUTHOR_VARS.inventory or {}), 1, -1 do - if AUTHOR_VARS.inventory[at] == item then - table.remove(AUTHOR_VARS.inventory, at) - end - end - if AUTHOR_VARS.item == item then - AUTHOR_VARS.item = nil - end -end - - -function authorSetVerb(verb) - AUTHOR_VARS.verb = verb - AUTHOR_VARS.item = nil -end - - -function authorNextVerb() - local verbs = AUTHOR_GAME.verbs or {} - local at = 0 - - for index, verb in ipairs(verbs) do - if verb == AUTHOR_VARS.verb then - at = index - end - end - AUTHOR_VARS.verb = verbs[(at % #verbs) + 1] - AUTHOR_VARS.item = nil -end - - -function authorUseItem(item) - AUTHOR_VARS.verb = "use" - AUTHOR_VARS.item = item -end - - --- The sentence line, rebuilt every frame: "use key with door". -local function updateSentence() - if AUTHOR_GAME.verbs == nil then - return - end - local hover = authorHover(1) or "" - local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1] or "" - local line = verb - - AUTHOR_VARS.hover = hover - if AUTHOR_VARS.item then - line = line .. " " .. AUTHOR_VARS.item .. ((hover ~= "") and " with " or "") - elseif hover ~= "" then - line = line .. " " - end - AUTHOR_VARS.sentence = line .. hover -end - - --- A press of the pointer's button in a game with verbs: on a hotspot it is the verb event, with --- the most specific rule winning; elsewhere it is left to the pressed rules (a walk, usually). -local function verbPress(player) - local target = authorHoverInstance(player) - - if target == nil then - return false - end - local verb = AUTHOR_VARS.verb or AUTHOR_GAME.verbs[1] - local item = AUTHOR_VARS.item - - authorEvent("verb", { self = target, verb = verb, target = target.hotspot.name or target.id, item = item, player = player }) - AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1] - AUTHOR_VARS.item = nil - - return true -end - - --- A word as the parser's words table knows it, or nil. -local function canonical(word) - for name, synonyms in pairs(parserLayer.words or {}) do - if name == word then - return name - end - for _, synonym in ipairs(synonyms) do - if synonym == word then - return name - end - end - end - - return nil -end - - --- A typed line: verb, noun, and a second noun after a preposition, as the words table knows --- them. A word it does not know is reported as unknown, so a rule can say so. -function authorSaid(text) - local words = {} - local ignore = {} - local unknown = nil - local known = {} - - for _, word in ipairs(parserLayer.ignore or { "the", "a", "an", "at", "to" }) do - ignore[word] = true - end - for word in string.gmatch(string.lower(text), "%a+") do - if not ignore[word] then - words[#words + 1] = word - end - end - for _, word in ipairs(words) do - local name = canonical(word) - - if name then - known[#known + 1] = name - elseif unknown == nil then - unknown = word - end - end - AUTHOR_VARS.typed = text - authorEvent("said", { verb = known[1], noun = known[2], second = known[3], unknown = unknown, text = text }) -end - - --- ===== Dialogue =============================================================================== --- --- A dialogue is nodes of a line and choices; the compiler hands them over with each choice's --- condition and actions as functions. talk runs one from its start and waits for it. - -function authorDialogues(list) - dialogues = list -end - - -function authorTalk(name) - local dialogue = dialogues[name] - - if dialogue == nil then - debugPrint("Author: no dialogue called '" .. tostring(name) .. "'") - return - end - local node = dialogue.nodes[dialogue.start] - local was = AUTHOR_CONTROLS - - AUTHOR_CONTROLS = false - while node ~= nil do - if node.text then - authorSay(((node.who and (node.who .. ": ")) or "") .. node.text, node.seconds) - end - local offered = {} - - for index, choice in ipairs(node.choices or {}) do - local key = name .. "/" .. tostring(node.name) .. "/" .. index - - if ((choice.when == nil) or choice.when()) and not (choice.once and onceSeen[key]) then - offered[#offered + 1] = { choice = choice, key = key } - end - end - if #offered == 0 then - node = node.next and dialogue.nodes[node.next] or nil - else - talking = { offered = offered, chosen = nil } - authorWait(function() return talking.chosen ~= nil end) - local picked = offered[talking.chosen] - - talking = nil - if picked.choice.once then - onceSeen[picked.key] = true - end - if picked.choice.run then - picked.choice.run() - end - node = picked.choice.next and dialogue.nodes[picked.choice.next] or nil - end - end - AUTHOR_CONTROLS = was -end - - --- Whether anything is still happening that a player would wait for: a sequence running, a line --- on screen. A dialogue waiting for a choice is not busy; it is waiting for the player. -function authorBusy() - if talking ~= nil then - return false - end - if sayLine ~= nil then - return true - end - - return next(sequences) ~= nil -end - - --- The choices a dialogue is offering, or nil. -function authorChoices() - if talking == nil then - return nil - end - local texts = {} - - for index, offer in ipairs(talking.offered) do - texts[index] = offer.choice.text - end - - return texts -end - - --- For tests and for a pointer: pick a choice by number. -function authorChoose(index) - if talking and talking.offered[index] then - talking.chosen = index - end -end - - -function authorFade(seconds, out) - local from = fadeLevel - local to = out and 1 or 0 - local till = authorTime() + seconds - local start = authorTime() - - authorWait(function() - local now = authorTime() - - fadeLevel = from + (to - from) * math.min(1, (now - start) / seconds) - return now >= till - end) - fadeLevel = to -end - - -function authorDie(text) - authorSay(text, nil) - authorFade(1, true) - authorRestart() - fadeLevel = 0 -end - - --- ===== Saving ================================================================================= --- --- The whole game -- which room, where everything in every visited room is, every var, the --- inventory -- as one string under a slot. Coroutines cannot be saved, so a save taken during a --- sequence records the world as it stands and the sequence does not resume. - -local function sourceOf(value) - if type(value) == "table" then - local parts = {} - - for key, item in pairs(value) do - local name = (type(key) == "string") and (key:match("^[%a_][%w_]*$") and key or ("[" .. string.format("%q", key) .. "]")) or ("[" .. tostring(key) .. "]") - - parts[#parts + 1] = name .. " = " .. sourceOf(item) - end - return "{ " .. table.concat(parts, ", ") .. " }" - elseif type(value) == "string" then - return string.format("%q", value) - end - - return tostring(value) -end - - -local function snapshotRoom(list) - local entities = {} - - for _, instance in ipairs(list) do - if instance.alive then - local x, y, z = authorPosition(instance) - - entities[#entities + 1] = { id = instance.id, type = instance.type, x = x, y = y, z = z, vars = instance.vars } - end - end - - return entities -end - - -function authorSaveGame(slot) - local rooms = {} - - for _, room in ipairs(AUTHOR_GAME.rooms) do - if room == AUTHOR_ROOM then - rooms[room.name] = snapshotRoom(AUTHOR_LIVE) - elseif room.kept then - rooms[room.name] = snapshotRoom(room.kept) - end - end - saveSet("forge.slot" .. tostring(slot), "return " .. sourceOf({ room = AUTHOR_ROOM.name, vars = AUTHOR_VARS, rooms = rooms, once = onceSeen })) -end - - -function authorLoadGame(slot) - local text = saveGet("forge.slot" .. tostring(slot)) - - if text == nil then - return false - end - local chunk = load(text, "save", "t", {}) - - if chunk == nil then - return false - end - local saved = chunk() - - authorRestart() - AUTHOR_VARS = saved.vars - onceSeen = saved.once or {} - for _, room in ipairs(AUTHOR_GAME.rooms) do - room.saved = saved.rooms[room.name] - if room.kept then - for _, instance in ipairs(room.kept) do - nodeDelete(instance.node) - end - room.kept = nil - end - end - for at = #AUTHOR_LIVE, 1, -1 do - authorDestroy(AUTHOR_LIVE[at]) - end - AUTHOR_ROOM = nil - authorGoTo(saved.room) - - return true -end - - --- ===== Sequences ============================================================================== --- --- An action that takes time yields from the rule's coroutine until it is done; authorWait is what --- it yields through. Outside a coroutine (a rule the compiler saw no waiting action in) the wait --- is skipped and the action's effect stands, so nothing breaks when a rule is edited by hand. - -function authorWait(predicate) - if not coroutine.isyieldable() then - return - end - while not predicate() do - coroutine.yield() - end -end - - -function authorWaitSeconds(seconds) - local till = authorTime() + seconds - - authorWait(function() return authorTime() >= till end) -end - - -function authorSay(text, seconds) - text = tostring(text or "") - seconds = seconds or (SAY_SECONDS_MIN + #text * SAY_PER_CHAR) - sayLine = { text = text, till = authorTime() + seconds } - if hud then - guiSetValue(hud.gui, hud.document, "say", text) - end - authorWait(function() return authorTime() >= sayLine.till end) -end - - --- ===== Rules ================================================================================== --- --- The compiler hands over the rules once; each is { on, filter, each, waits, controls, run }. run --- takes (self, other, event) and does the conditions and actions. A rule with waits runs as a --- coroutine kept until it finishes; the same rule on the same instance does not start again while --- one is running unless the rule says interrupt. - -function authorRules(list) - rules = {} - pairsToTest = {} - for _, rule in ipairs(list) do - rules[rule.on] = rules[rule.on] or {} - rules[rule.on][#rules[rule.on] + 1] = rule - if rule.on == "collision" then - pairsToTest[#pairsToTest + 1] = { a = rule.filter.a, b = rule.filter.b } - end - end - -- The first room was built by authorBegin, before this; what it raised -- roomStart, a spawn - -- for every entity placed in it -- is delivered now that there are rules to hear it. - rulesReady = true - for _, held in ipairs(pending) do - authorEvent(held.name, held.event) - end - pending = {} -end - - -local runRule - - --- Runs a rule for an event: once, with the event's own instance as self, or once per instance --- of the rule's type when the event has none. Answers whether it ran for anything. -local function dispatch(rule, event) - if rule.each and (event.self == nil) then - local any = false - - for _, instance in ipairs(authorEach(rule.each)) do - if runRule(rule, instance, event.other, event) then - any = true - end - end - return any - end - - return runRule(rule, event.self, event.other, event) -end - - --- Runs a rule. Answers whether its conditions held -- a sequence that yielded has passed them, --- since the conditions come before the first action. -function runRule(rule, self, other, event) - if not rule.waits then - return rule.run(self, other, event) == true - end - local key = tostring(rule) .. "/" .. (self and self.id or "") - - if sequences[key] and not rule.interrupt then - return false - end - local co = coroutine.create(function() - local held - - if rule.controls == false then - AUTHOR_CONTROLS = false - end - held = rule.run(self, other, event) - if rule.controls == false then - AUTHOR_CONTROLS = true - end - return held - end) - - sequences[key] = co - local ok, held = coroutine.resume(co) - - if not ok then - debugPrint("Author: " .. tostring(held)) - sequences[key] = nil - return false - end - if coroutine.status(co) == "dead" then - sequences[key] = nil - return held == true - end - - return true -end - - -local function matches(rule, event) - for name, want in pairs(rule.filter) do - if name == "type" then - if (event.self == nil) or (event.self.type ~= want) then - return false - end - elseif name == "a" then - if (event.self == nil) or (event.self.type ~= want) then - return false - end - elseif name == "b" then - if (event.other == nil) or (event.other.type ~= want) then - return false - end - elseif event[name] ~= want then - return false - end - end - -- each on an event about an instance narrows it to that type; on an event about nothing in - -- particular (a key, a room, the disc) it fans the rule out over every instance of the type. - if rule.each and (event.self ~= nil) and (event.self.type ~= rule.each) then - return false - end - -- 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 - - return true -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] - - 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 } - return - end - if list == nil then - return - end - if entry and entry.exclusive then - -- Tiers by how many of the event's parameters a rule names, a rule with conditions - -- ranking above one without among the same parameters; the most specific tier whose - -- conditions hold is the one that acts, and the rest are left alone. - local tiers = {} - - for _, rule in ipairs(list) do - if matches(rule, event) then - local score = rule.guarded and 1 or 0 - - for _ in pairs(rule.filter) do - score = score + 2 - end - tiers[score] = tiers[score] or {} - tiers[score][#tiers[score] + 1] = rule - end - end - for score = 7, 0, -1 do - local ran = false - - for _, rule in ipairs(tiers[score] or {}) do - if dispatch(rule, event) then - ran = true - end - end - if ran then - return - end - end - return - end - for _, rule in ipairs(list) do - if matches(rule, event) then - dispatch(rule, event) - end - end -end - - --- The behaviours hear about their instance's events too: a sound plays its clip on hit. -function authorBehaviourEvent(instance, name, event) - for _, b in ipairs(instance.behaviours) do - local behaviour = AUTHOR.behaviours[b.kind] - - if behaviour and behaviour.on then - behaviour.on(instance, name, event or {}) - end - end -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 (room == rule.room) then - if rule.each then - for _, instance in ipairs(authorEach(rule.each)) do - runRule(rule, instance, nil, {}) - end - else - runRule(rule, nil, nil, {}) - end - end - end -end - - -local function resumeSequences() - local running = {} - - -- A snapshot, because a resumed rule may start another sequence, and adding to a table while - -- pairs walks it is not allowed. - for key, co in pairs(sequences) do - running[#running + 1] = { key = key, co = co } - end - for _, entry in ipairs(running) do - if coroutine.status(entry.co) == "dead" then - sequences[entry.key] = nil - elseif sequences[entry.key] == entry.co then - local ok, err = coroutine.resume(entry.co) - - if not ok then - debugPrint("Author: " .. tostring(err)) - sequences[entry.key] = nil - elseif coroutine.status(entry.co) == "dead" then - sequences[entry.key] = nil - end - end - end -end - - --- ===== Rooms ================================================================================== - -local function roomNamed(name) - for _, room in ipairs(AUTHOR_GAME.rooms) do - if room.name == name then - return room - end - end - - return nil -end - - -local function buildRoom(room) - local from = room.saved or room.entities or {} - - room.saved = nil - for _, entry in ipairs(from) do - make(entry.type, entry.x, entry.y, entry.z, entry) - end - authorRoomNav() -end - - --- Leaves the current room, keeping its instances if it wants to be remembered. Kept instances --- keep their nodes; they are only taken out of the live list and drawn no more. -local function leaveRoom() - if AUTHOR_ROOM == nil then - return - end - authorEvent("roomEnd", { room = AUTHOR_ROOM.name }) - if AUTHOR_ROOM.reset then - for at = #AUTHOR_LIVE, 1, -1 do - authorDestroy(AUTHOR_LIVE[at]) - end - AUTHOR_ROOM.kept = nil - else - AUTHOR_ROOM.kept = AUTHOR_LIVE - for _, instance in ipairs(AUTHOR_LIVE) do - nodeSetVisible(instance.node, false) - end - end - -- Agents belong to the room's mesh; a walker makes a new one where it arrives. - for _, instance in ipairs(AUTHOR_LIVE) do - if instance.agent then - navAgentDelete(instance.agent) - AUTHOR_AGENTS[instance.agent] = nil - instance.agent = nil - if instance.navNode and (instance.navNode ~= instance.node) then - nodeDelete(instance.navNode) - instance.navNode = nil - end - end - end - AUTHOR_LIVE = {} - AUTHOR_BY_ID = {} - timers = {} - -- Sequences stay: the cut-scene that walked through the door is the one changing rooms, and - -- it has a fade-in and a score still to do on the other side. -end - - -function authorGoTo(name, instance, x, y, z, at) - local room = roomNamed(name) - - if room == nil then - debugPrint("Author: no room called '" .. tostring(name) .. "'") - return - end - local carried = instance and instance.alive and instance or nil - - if carried then - -- The one that travels comes out of the room it leaves before it is stashed. - for at = #AUTHOR_LIVE, 1, -1 do - if AUTHOR_LIVE[at] == carried then - table.remove(AUTHOR_LIVE, at) - end - end - end - leaveRoom() - AUTHOR_ROOM = room - if room.kept then - AUTHOR_LIVE = room.kept - room.kept = nil - for _, kept in ipairs(AUTHOR_LIVE) do - AUTHOR_BY_ID[kept.id] = kept - nodeSetVisible(kept.node, true) - if kept.camera then - cameraNode = kept.node - cameraSet(kept.node) - end - end - else - buildRoom(room) - end - if carried then - -- An instance of the same id already in the room gives way to the one arriving. - local standing = AUTHOR_BY_ID[carried.id] - - if standing and (standing ~= carried) then - authorDestroy(standing) - end - AUTHOR_LIVE[#AUTHOR_LIVE + 1] = carried - AUTHOR_BY_ID[carried.id] = carried - AUTHOR_BY_NODE[carried.node] = carried - for _, node in ipairs(carried.nodes) do - AUTHOR_BY_NODE[node] = carried - end - if at and AUTHOR_BY_ID[at] then - local ax, ay, az = authorPosition(AUTHOR_BY_ID[at]) - - authorMoveTo(carried, ax, ay, az) - elseif x ~= nil then - authorMoveTo(carried, x, y, z) - end - end - for _, spawned in ipairs(AUTHOR_LIVE) do - if spawned ~= carried and not spawned.begun then - spawned.begun = true - authorEvent("spawn", { self = spawned }) - end - end - authorWavesReset() - authorEvent("roomStart", { room = name }) -end - - -function authorRestart() - for _, room in ipairs(AUTHOR_GAME.rooms) do - if room.kept then - for _, instance in ipairs(room.kept) do - nodeDelete(instance.node) - end - room.kept = nil - end - end - for at = #AUTHOR_LIVE, 1, -1 do - authorDestroy(AUTHOR_LIVE[at]) - end - AUTHOR_ROOM = nil - cameraNode = nil - sequences = {} - AUTHOR_VARS = copyOf(AUTHOR_GAME.vars or {}) - AUTHOR_VARS.score = AUTHOR_VARS.score or 0 - if AUTHOR_GAME.verbs then - AUTHOR_VARS.verb = AUTHOR_GAME.verbs[1] - AUTHOR_VARS.inventory = AUTHOR_VARS.inventory or {} - end - AUTHOR_TYPING = "" - talking = nil - fadeLevel = 0 - results = nil - AUTHOR_VARS.best = saveGet(BEST_KEY, 0) - onceSeen = {} - gameOver = false - sayLine = nil - AUTHOR_STARTED = singeGetTicks() - lastTime = 0 - lastFrame = -1 - authorGoTo(AUTHOR_GAME.rooms[1].name) -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 - - if pointers[player or 1] then - return pointers[player or 1].x, pointers[player or 1].y, pointers[player or 1].on - end - if device >= mouseHowMany() then - device = 0 - end - local x, y = mouseGetPosition(device) - - return x, y, (x >= 0) and (y >= 0) and (x < overlayGetWidth()) and (y < overlayGetHeight()) -end - - --- For tests: where a player's pointer is, and whether it counts as on the picture. -function authorSetPointer(player, x, y, on) - if x == nil then - pointers[player] = nil - else - pointers[player] = { x = x, y = y, on = (on ~= false) } - end -end - - -function authorPointerIn(instance, player) - if instance == nil then - return false - end - local px, py, on = authorPointer(player) - local x, y, w, h = authorHitShape(instance) - - return on and (x ~= nil) and collidePointRect(px, py, x, y, w, h) -end - - -function authorPointerOffscreen(player) - local _, _, on = authorPointer(player) - - return not on -end - - --- What the picture is at a point, as a light sensor sees it: bright, dark, or unknown between --- the two thresholds. The engine answers zeroes when no video is playing, so a game with no --- disc layer reads dark everywhere rather than failing. -function authorPictureState(x, y, high, low) - local r, g, b = vldpGetPixel(math.floor(x), math.floor(y)) - - high = high or PICTURE_BRIGHT - low = low or PICTURE_DARK - if ((255 - r) < high) and ((255 - g) < high) and ((255 - b) < high) then - return "bright" - elseif (r < low) and (g < low) and (b < low) then - return "dark" - end - - return "unknown" -end - - -function authorPictureIs(x, y, want, high, low) - return authorPictureState(x, y, high, low) == want -end - - --- What a gun can hit an instance in: its track's box at this frame when it has a hitbox --- behaviour, its look's box otherwise. nil when the track has no box now. -function authorHitShape(instance) - if instance.hitTrack then - local box = authorTrackBox(instance.hitTrack) - - if box == nil then - return nil - end - - return box.x, box.y, box.w, box.h - end - - return authorBounds(instance) -end - - --- A shot from a player's gun. The topmost instance under the pointer is hit; nothing is a miss. -local function fire(gun) - local player = gun.vars.player - local px, py, on = authorPointer(player) - - if gun.gun.aim == "centre" then - px, py, on = overlayGetWidth() / 2, overlayGetHeight() / 2, true - end - - if gun.vars.ammo <= 0 then - authorBehaviourEvent(gun, "empty") - if (gun.gun.reload == "offscreen") and not on then - authorReload(gun) - end - return - end - if (gun.gun.reload == "offscreen") and not on then - authorReload(gun) - return - end - gun.vars.ammo = gun.vars.ammo - 1 - authorBehaviourEvent(gun, "fire") - if on and AUTHOR_3D then - -- A ray through the pointer, from the near plane out: what it hits is a body, and the - -- body's node says which instance -- and which zone of it, when it has zones. - local x0, y0, z0 = sceneUnproject(px, py, 0) - local x1, y1, z1 = sceneUnproject(px, py, 10) - local node, hx, hy, hz = physicsRaycast(x0, y0, z0, x1 - x0, y1 - y0, z1 - z0, RAY_REACH) - local target = node and authorInstanceOf(node) or nil - local casts = 0 - - -- What the ray meets first may be something a shot passes through: the hero's own body - -- in first person, a trigger, a wall of glass. The cast goes on from just past it, a few - -- times, before the shot counts as a miss. - while node and not (target and target.isTarget) and (casts < 4) do - local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0 - local len = math.sqrt(dx * dx + dy * dy + dz * dz) - - casts = casts + 1 - x0, y0, z0 = hx + dx / len * 0.05, hy + dy / len * 0.05, hz + dz / len * 0.05 - x1, y1, z1 = x0 + dx, y0 + dy, z0 + dz - node, hx, hy, hz = physicsRaycast(x0, y0, z0, dx, dy, dz, RAY_REACH) - target = node and authorInstanceOf(node) or nil - end - if target and target.alive and target.isTarget then - -- The body box is what the ray meets first; a zone inside it -- the head -- is found by - -- how close the ray passes to the zone's centre. - local part = "body" - local dx, dy, dz = x1 - x0, y1 - y0, z1 - z0 - local len = math.sqrt(dx * dx + dy * dy + dz * dz) - - dx, dy, dz = dx / len, dy / len, dz / len - for _, zone in ipairs(target.zones or {}) do - local zx, zy, zz = nodeGetWorldPosition(zone.node) - local t = (zx - x0) * dx + (zy - y0) * dy + (zz - z0) * dz - local cx, cy, cz = x0 + dx * t - zx, y0 + dy * t - zy, z0 + dz * t - zz - - if (t > 0) and (math.sqrt(cx * cx + cy * cy + cz * cz) <= zone.reach) then - part = zone.name - end - end - authorEvent("hit", { self = target, other = gun, player = player, x = hx, y = hy, z = hz, part = part }) - return - end - elseif on then - for at = #AUTHOR_LIVE, 1, -1 do - local target = AUTHOR_LIVE[at] - - if target.alive and target.visible and target.isTarget then - local x, y, w, h = authorHitShape(target) - - if (x ~= nil) and collidePointRect(px, py, x, y, w, h) then - authorEvent("hit", { self = target, other = gun, player = player, x = px, y = py }) - return - end - end - end - end - authorEvent("miss", { self = gun, player = player, offscreen = not on, x = px, y = py }) -end - - --- ===== Tracks ================================================================================= --- --- A track is a list of keys by frame or time. A box track answers the box at the current key --- position, interpolated between keys, nil outside them. - -function authorTrackNamed(name) - for _, track in ipairs(AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}) do - if track.name == name then - return track - end - end - for _, track in ipairs(AUTHOR_GAME.tracks or {}) do - if track.name == name then - return track - end - end - - return nil -end - - -local function trackNow(track) - if track.key == "frame" then - return discGetFrame() - end - - return authorTime() -end - - --- The interpolated box of a track at a position along it. -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 gun games' hitmaps) 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 - end - if key.at >= at and ((after == nil) or (key.at < after.at)) then - after = key - end - end - if (before == nil) or (after == nil) then - return nil - end - if (before == after) or (after.at == before.at) then - return { x = before.x, y = before.y, w = before.w, h = before.h } - end - local t = (at - before.at) / (after.at - before.at) - - return { - x = before.x + (after.x - before.x) * t, - y = before.y + (after.y - before.y) * t, - w = before.w + (after.w - before.w) * t, - h = before.h + (after.h - before.h) * t - } -end - - -function authorTrackBox(name) - local track = authorTrackNamed(name) - - if track == nil then - return nil - end - - return authorTrackBoxAt(track, trackNow(track)) -end - - --- ===== Waves ================================================================================== --- --- A track of spawns: keys by time, by disc frame, or by a rail camera's stop, each making count --- instances of a type, one every so many seconds, at an entity named in from -- at each of a --- comma-separated list of them in turn -- or at the key's own point. A key begins once per --- visit to the room. - -local waves = {} -- The waves under way: { key, made, wait }. -local started = {} -- The keys begun this visit, by key. - - -local function waveBegin(key) - started[key] = true - waves[#waves + 1] = { key = key, made = 0, wait = 0 } -end - - --- Where a wave's next instance goes. -local function wavePlace(wave) - local from = wave.key.from - local names = {} - - if type(from) == "table" then - names = from - elseif type(from) == "string" then - for name in from:gmatch("[^,%s]+") do - names[#names + 1] = name - end - end - if #names > 0 then - local x, y, z = authorTargetPosition(names[(wave.made % #names) + 1]) - - if x then - return x, y, z - end - end - - return wave.key.x or 0, wave.key.y or 0, wave.key.z or 0 -end - - --- Every track of spawns, the room's and the game's. -local function waveTracks() - local found = {} - - for _, list in ipairs({ AUTHOR_ROOM and AUTHOR_ROOM.tracks or {}, AUTHOR_GAME.tracks or {} }) do - for _, track in ipairs(list) do - if track.spawns then - found[#found + 1] = track - end - end - end - - return found -end - - -local function updateWaves(dt) - -- Keys by time or by frame begin when the track passes them. - for _, track in ipairs(waveTracks()) do - if track.key ~= "stop" then - local now = trackNow(track) - - for _, key in ipairs(track.spawns) do - if not started[key] and (type(key.at) == "number") and (now >= key.at) then - waveBegin(key) - end - end - end - end - -- Each wave under way makes its next instance when its wait is up. - for index = #waves, 1, -1 do - local wave = waves[index] - - wave.wait = wave.wait - dt - if wave.wait <= 0 then - local x, y, z = wavePlace(wave) - - authorSpawn(wave.key.type, x, y, z) - wave.made = wave.made + 1 - wave.wait = wave.key.every or 0 - if wave.made >= (wave.key.count or 1) then - table.remove(waves, index) - end - end - end -end - - --- A rail camera reached a stop: the waves keyed by that stop begin. -function authorWavesAtStop(name) - for _, track in ipairs(waveTracks()) do - if track.key == "stop" then - for _, key in ipairs(track.spawns) do - if not started[key] and (key.at == name) then - waveBegin(key) - end - end - end - end -end - - --- A room begun: its waves start over. -function authorWavesReset() - waves = {} - started = {} -end - - --- ===== Timers and the disc ==================================================================== - -local function updateTimers() - local now = authorTime() - - for at = #timers, 1, -1 do - local timer = timers[at] - - -- A timer's rule may destroy an instance and take its timers out from under this loop. - if timer == nil then - -- Nothing left at this index. - elseif timer.instance.alive and (now >= timer.at) then - if timer.every then - timer.at = now + timer.every - else - table.remove(timers, at) - end - authorEvent("timer", { self = timer.instance, name = timer.name }) - elseif not timer.instance.alive then - table.remove(timers, at) - end - end -end - - -local function updateDisc() - if (discGetState == nil) or (AUTHOR_GAME.disc == false) then - return - 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) and ((rule.room == nil) or (room == rule.room)) then - runRule(rule, nil, nil, { frame = frame, room = room }) - end - end - end - lastFrame = frame -end - - --- ===== Collisions ============================================================================= --- --- The rules say which types they care about; only those pairs are tested, and a pair that goes on --- touching is reported once. - -local touchingNow = {} - -local function checkCollisions() - local seen = {} - - for _, pair in ipairs(pairsToTest) do - for _, a in ipairs(authorEach(pair.a)) do - for _, b in ipairs(authorEach(pair.b)) do - if (a ~= b) and a.alive and b.alive and authorTouching(a, b) then - local key = a.id .. "|" .. b.id - - seen[key] = true - if not touchingNow[key] then - authorEvent("collision", { self = a, other = b }) - end - end - end - end - end - touchingNow = seen -end - - --- ===== HUD ==================================================================================== - -local function updateHud() - if hud == nil then - return - end - for id, var in pairs(hud.bind) do - local value = AUTHOR_VARS[var] - - if type(value) == "table" then - value = table.concat(value, ", ") - else - value = tostring(value or "") - end - - if hud.shown[id] ~= value then - hud.shown[id] = value - guiSetValue(hud.gui, hud.document, id, value) - end - end - if sayLine and (authorTime() >= sayLine.till) then - sayLine = nil - guiSetValue(hud.gui, hud.document, "say", "") - end -end - - --- ===== The frame ============================================================================== - --- Builds the game from its description: layers, vars, and the first room. The compiled game --- calls this once, then authorRules with what it compiled, then authorFrame every frame. -function authorBegin(game) - -- A font, because the text look draws with fontPrint and fontPrint ends the game when none is - -- selected. A game that wants its own calls fontSelect afterwards. - if AUTHOR_FONT == nil then - AUTHOR_FONT = fontLoad(AUTHOR_FONT_FILE, AUTHOR_FONT_POINTS) - fontSelect(AUTHOR_FONT) - fontQuality(FONT_QUALITY_BLENDED) - end - MOUSE_LEFT = SWITCH_BUTTON3 - AUTHOR_GAME = game - -- The overlay the game was drawn for. Without it the engine's default stands, which over a - -- disc is the disc's own size. - if game.size then - overlaySetResolution(game.size.w, game.size.h) - end - game.types = game.types or {} - game.rooms = game.rooms or { { name = "main", entities = {} } } - game.disc = false - for _, layer in ipairs(game.layers or {}) do - local kind = AUTHOR.layers[layer.kind] - - if kind == nil then - debugPrint("Author: no layer called '" .. tostring(layer.kind) .. "'") - else - if layer.kind == "disc" then - game.disc = true - end - kind.begin(layer) - end - end - -- What is behind the overlay: the disc or the scene when the game has one, and a dark ground - -- of its own otherwise, so a flat game never plays over whatever video the engine was given. - if game.disc or AUTHOR_3D then - colorBackground(0, 0, 0, 0) - else - colorBackground(AUTHOR_GROUND.r, AUTHOR_GROUND.g, AUTHOR_GROUND.b, 255) - end - if (game.players or 1) > 1 then - mouseSetMode(MOUSE_MANY) - end - authorRestart() -end - - --- One frame: timers, behaviours, sequences, the frame rules, collisions, the HUD, and everything --- drawn in the order it was made. The generated game calls this from onOverlayUpdate. -function authorFrame() - local now = authorTime() - local dt = math.min(now - lastTime, FRAME_CAP) - - lastTime = now - updateTimers() - updateDisc() - updateWaves(dt) - for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive then - for _, b in ipairs(instance.behaviours) do - local behaviour = AUTHOR.behaviours[b.kind] - - if behaviour and behaviour.step then - behaviour.step(instance, dt) - end - end - end - end - resumeSequences() - runFrameRules() - checkCollisions() - updateSentence() - updateHud() - updateBezel() - if masterReady and (masterPump ~= nil) then - masterPump() - end - pressedNow = {} - AUTHOR_MOUSE.dx = 0 - AUTHOR_MOUSE.dy = 0 - - overlayClear() - if AUTHOR_ROOM and AUTHOR_ROOM.depthSort then - -- Further up the picture is further away, so what is lower is drawn last, over it. - local order = {} - - for at, instance in ipairs(AUTHOR_LIVE) do - order[at] = instance - instance.order = at - end - table.sort(order, function(a, b) - local ay, by = authorDepthY(a), authorDepthY(b) - - if ay == by then - return a.order < b.order - end - return ay < by - end) - for _, instance in ipairs(order) do - if instance.alive and instance.visible then - AUTHOR.looks[instance.look.kind].draw(instance) - end - end - else - for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive and instance.visible then - AUTHOR.looks[instance.look.kind].draw(instance) - end - end - end - -- Particles in a 2D room are drawn on request, unlike the scene's. - if not AUTHOR_3D then - for _, instance in ipairs(AUTHOR_LIVE) do - if instance.alive and instance.emitter then - emitterDraw(instance.emitter) - end - end - end - if talking then - -- The choices, numbered, low on the picture (or in the hud's choices element). - local lines = {} - - for index, offer in ipairs(talking.offered) do - lines[#lines + 1] = index .. ". " .. offer.choice.text - end - if hud then - guiSetValue(hud.gui, hud.document, "choices", table.concat(lines, "\n")) - else - colorForeground(255, 230, 120, 255) - for index, line in ipairs(lines) do - fontPrint(40, overlayGetHeight() - 40 - (#lines - index + 1) * 22, line) - end - end - elseif hud and hud.choicesShown then - guiSetValue(hud.gui, hud.document, "choices", "") - end - if hud then - hud.choicesShown = (talking ~= nil) - end - if parserLayer then - colorForeground(235, 235, 245, 255) - fontPrint(12, overlayGetHeight() - 30, (parserLayer.prompt or ">") .. AUTHOR_TYPING .. "_") - end - if results then - local middle = overlayGetHeight() / 2 - - colorForeground(255, 230, 120, 255) - fontPrint(overlayGetWidth() / 2 - 60, middle - 30, "GAME OVER") - colorForeground(235, 235, 245, 255) - fontPrint(overlayGetWidth() / 2 - 60, middle, "score " .. results.score) - fontPrint(overlayGetWidth() / 2 - 60, middle + 24, "best " .. results.best) - end - if fadeLevel > 0 then - colorForeground(0, 0, 0, math.floor(255 * fadeLevel)) - for row = 0, overlayGetHeight() - 1 do - overlayLine(0, row, overlayGetWidth(), row) - end - end - if flashLine then - if now >= flashLine.till then - flashLine = nil - else - local alpha = math.floor(255 * (flashLine.till - now) / flashLine.seconds) - - colorForeground(flashLine.r, flashLine.g, flashLine.b, alpha) - for row = 0, overlayGetHeight() - 1, 2 do - overlayLine(0, row, overlayGetWidth(), row) - end - end - end - if sayLine and (hud == nil) then - if now >= sayLine.till then - sayLine = nil - else - colorForeground(255, 255, 255, 255) - fontPrint(overlayGetWidth() / 2 - #sayLine.text * 4, overlayGetHeight() - 60, sayLine.text) - end - end - if hud then - guiDraw(hud.gui) - end -end - - --- ===== Input ================================================================================== --- --- Held state, because rules ask "is this key down" rather than "was it just pressed", and edges, --- because some do. The generated game points the engine's callbacks straight at these. - -AUTHOR_KEYS = {} -AUTHOR_SWITCHES = {} - - -function authorKeyDown(keysym, scancode) - AUTHOR_KEYS[scancode] = true - pressedNow[scancode] = true - -- A dialogue's choices take the number keys first. - if talking then - for index, name in ipairs(CHOICE_KEYS) do - if scancode == SCANCODE[name].value then - authorChoose(index) - return - end - end - end - -- The parser takes the typing keys. - if parserLayer and AUTHOR_CONTROLS then - if scancode == SCANCODE.RETURN.value then - local line = AUTHOR_TYPING - - AUTHOR_TYPING = "" - if line ~= "" then - authorSaid(line) - end - return - elseif scancode == SCANCODE.BACKSPACE.value then - AUTHOR_TYPING = string.sub(AUTHOR_TYPING, 1, -2) - return - elseif (keysym >= 32) and (keysym < 127) then - AUTHOR_TYPING = AUTHOR_TYPING .. string.char(keysym) - return - end - end - if AUTHOR_CONTROLS then - local px, py = authorPointer(1) - - authorEvent("pressed", { key = scancode, x = px, y = py }) - end -end - - -function authorKeyUp(keysym, scancode) - AUTHOR_KEYS[scancode] = nil - authorEvent("released", { key = scancode }) -end - - --- In MODE_FULL the engine hands every key to onInputPressed as its keysym, on the same channel --- as the switches, and four keysyms are switch numbers too: BACKSPACE is 8 (SWITCH_BUTTON3), --- TAB 9 (SWITCH_COIN1), RETURN 13, and ESCAPE 27. The key is down at that moment and the switch --- is not, which tells them apart. -local KEY_MASQUERADES = { [8] = "BACKSPACE", [9] = "TAB", [13] = "RETURN", [27] = "ESCAPE" } - -function authorSwitchIsKey(what) - local name = KEY_MASQUERADES[what] - - return (name ~= nil) and keyboardIsDown(SCANCODE[name].value) -end - - --- 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 - AUTHOR_SWITCHES[what] = true - pressedNow[what] = true - if not AUTHOR_CONTROLS then - return - end - for _, instance in ipairs(AUTHOR_LIVE) do - 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 - if talking and (what == MOUSE_LEFT) then - -- A click picks the choice under the pointer, by its line. - local _, py = authorPointer(1) - local count = #talking.offered - local index = count - math.floor((overlayGetHeight() - 40 - py) / 22) - - authorChoose(math.max(1, math.min(count, index))) - return - end - if AUTHOR_GAME.verbs and (what == MOUSE_LEFT) and verbPress(1) then - return - end - local px, py = authorPointer(player or 1) - - authorEvent("pressed", { switch = what, x = px, y = py, player = player }) -end - - -function authorSwitchUp(what, device) - if authorSwitchIsKey(what) then - return - end - AUTHOR_SWITCHES[what] = nil - authorEvent("released", { switch = what, player = device and (device + 1) or nil }) -end - - --- ===== The quick-time-event game loop =========================================================== --- --- The qte behaviour plays a laserdisc quick-time-event game: --- an attract loop, credits and a start, levels of scenes, each scene a run of moves with a --- window of frames to answer in, a death clip and a life lost for a wrong or missed one, a --- continue screen, a game over, and the framework's dips deciding how strict, how many lives, --- 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 (a game may pick a scene by which levels are beaten), and its Script/addons.singe --- hooks the loop (startConf, swapLevel, swapScene, swapDeath, specialScore, doLevelSelect). --- Both run inside a shim whose globals are q, so they play as they do in the framework. The --- description names them under game.qte (the port converter writes it), and carries a --- snapshot of the same tables for the editor. The states and their numbers are the --- framework's, so a run of the original and a run of the port trace alike (FORGE.md 14). - -local Q = {} -- The loop's functions, one table so the file keeps within Lua's count of locals. -local K = {} -- The held style's own functions. --- The held style's functions: its own where it has one, the shared screens' otherwise. A game's --- q.qteFns is this or Q, so a shared screen calls the style's scorer, start, and board. -local HELD_FNS = setmetatable({}, { __index = function(_, name) return K[name] or Q[name] end }) -local QTE = { - -- The framework's constants, as globals.singe declares them. - UP = 1, DOWN = 2, LEFT = 3, RIGHT = 4, BUTTON1 = 5, BUTTON2 = 6, BUTTON3 = 7, BUTTON4 = 8, - 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 compound kinds: two and three inputs in turn, circles - -- from any of four starts, mashes of one direction or button (MASH3 alternates BUTTON1 and - -- BUTTON3), and a direction with BUTTON2 or BUTTON3, or two directions with a button. - DLB = 149, DRB = 150, ULB = 151, URB = 152, MASH3 = 153, MASH3MIN = 154, MASH3MAX = 155, - 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, SELECTSTILL = 8, - LEVELSTARTED = 1, BEATSTATUS = 2, DEATHCOUNT = 3, SCENEID = 1, SCENECOMPLETE = 2, NODEATH = -1, NODEATHSCORE = -2, - lvlSetup = 0, lvlRunning = 1, lvlEnd = 2, lvlPlayDeath = 3, lvlPlayRest = 4, - branch01 = 10, branch02 = 11, branch03 = 12, branch04 = 13, branch05 = 14, branch06 = 15, branch07 = 16, branch08 = 17, branch09 = 18, branch10 = 19, branch11 = 20, branch12 = 21, - levelIntro = 0, levelContinue = 100, levelGameOver = 101, levelChoose = 102, levelNormal = 103, levelService = 104, levelMap = 105, levelHighScore = 106, - levelMovie = 107, levelSave = 108, levelDiffScreen = 109, levelFinish = 110, levelTrophy = 111, levelExit = 112, levelGraphic = 113, levelPerf = 114, levelSecret = 1000, - DOPT_FREEPLAY = 0, DOPT_INFINITE_CONTINUES = 4, - WHITE = 1, RED = 2, BLUE = 3, YELLOW = 4, GREEN = 5, ORANGE = 6, PINK = 7, PURPLE = 8, LIGHTBLUE = 9, GREY = 10, BLACK = 11, MISC = 12, - TOP = 1, BOTTOM = 2, MIDDLE = 3 -} - --- 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", [SWITCH_TEST] = "p1TEST", [SWITCH_CONSOLE] = "p1CONSOLE", - [SWITCH_SKILL1] = "p1SKILL1", [SWITCH_SKILL2] = "p1SKILL2", [SWITCH_SKILL3] = "p1SKILL3" } - end - - return QTE.FLAG_OF[switch] -end - - --- ----- Timers, sounds, clips ---------------------------------------------------------------- - --- The framework's two countdowns, the timer and the joystick delay, arithmetic and all: each sums --- os.clock() deltas as (sum + now) - last and compares the sum. One that subtracted two times --- instead lands a hair either side of the limit on the frame it is due, which is a frame late for --- the rest of the game (FORGE.md 14.19). A paused game's countdown holds, as the framework's does. --- keys names the countdown's three fields on q. -local TIMER_KEYS = { limit = "timerLimit", secs = "timerSecs", last = "timerLast" } -local JOY_KEYS = { limit = "joyLimit", secs = "joySecs", last = "joyLast" } - - -local function countdownStart(q, keys, seconds) - q[keys.limit] = seconds - q[keys.secs] = 0 - q[keys.last] = os.clock() -end - - -local function countdownDue(q, keys) - if q[keys.limit] == nil then - return true - end - local now = os.clock() - - if q.bPause then - q[keys.last] = now - return false - end - if now ~= q[keys.last] then - q[keys.secs] = q[keys.secs] + now - q[keys.last] - q[keys.last] = now - end - if q[keys.secs] >= q[keys.limit] then - q[keys.limit] = nil - return true - end - - return false -end - - -function Q.timerON(q, seconds) - countdownStart(q, TIMER_KEYS, seconds) -end - - -function Q.timerDue(q) - return countdownDue(q, TIMER_KEYS) -end - - -function Q.joyDelayON(q, seconds) - countdownStart(q, JOY_KEYS, seconds) -end - - -function Q.joyDelayDue(q) - return countdownDue(q, JOY_KEYS) -end - - --- The framework's setLang: the disc's audio in the language the dips name, or the default when --- that file is missing. readConfig calls it only when the saved config has a dip_Lang line, which --- is exactly when the description's snapshot carries one, so the loops call it on that condition. --- Beyond the language, the call itself holds the disc for a frame, and the traces see it. -function Q.setLang(q) - if (q.LangOpt == nil) or (q.snapshot.dips == nil) or (q.snapshot.dips.dip_Lang == nil) then - return - end - if q.dip_Lang + 1 > #q.LangOpt then - q.dip_Lang = 0 - end - if not discAudioSuffix(q.LangOpt[q.dip_Lang + 1][2]) then - debugPrint(q.LangOpt[q.dip_Lang + 1][1] .. " audio file unavailable, using default instead.") - discAudioSuffix("") - end -end - - --- Whether the clip playing has reached its end: at or past it, or exactly at it --- (clipEndExact, as the first copies of the segmented lineage test). -function Q.clipEnded(q) - if Q.option(q, "clipEndExact", false) then - return q.currentFrame == q.iFrameEnd - end - - return q.currentFrame >= q.iFrameEnd -end - - -function Q.setupClip(q, from, to) - q.iFrameStart = from - q.iFrameEnd = to - 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 settings the games' config reader turns from 1 and 0 into true and false (a 0 is true in --- Lua, so a switched-off one would show). -local QTE_CONFIG_FLAGS = { "dip_Hints", "ShowTop", "ShowLCD", "ShowLevel", "ShowTitle" } --- A description's clips by name, and the globals the loops read for each one's first and last --- frames. The port converter reads this table from here, so the names are written once. -local QTE_CLIPS = { - { "title", "offsetTitle", "offsetTitleend" }, - { "gameIntro", "offsetIntroGame", "offsetIntroGameend" }, - { "getReady", "offsetGetReady", "offsetGetReadyEnd" }, - { "resurrect", "offsetResurrect", "offsetResurrectEnd" }, - { "extraDeath", "offsetSupDeath", "offsetSupDeathEnd" }, - { "continue", "offsetContinue", "offsetContinueend" }, - { "gameOver", "offsetGameOver", "offsetGameOverend" }, - { "gameOverAlt", "offsetGameOverAlt", "offsetGameOverAltend" }, - { "clear", "offsetClear", "offsetClearend" }, - { "newHighScore", "offsetNewHScore", "offsetNewHScoreend" }, - { "enterName", "offsetEnterHScore", "offsetEnterHScoreend" }, - { "rankings", "offsetRankings", "offsetRankingsend" }, - { "quit", "offsetQuit", "offsetQuitend" } -} --- A description's other named fields, as group.name, and the global the loops read for each. --- The port converter reads this table from here too. -local QTE_FIELDS = { - { "scoring.move", "SCOREMOVE" }, - { "scoring.perDifficulty", "BUFFMOVE" }, - { "scoring.scene", "SCORESCENE" }, - { "scoring.deathPenalty", "DEATHPENALTY" }, - { "scoring.level", "SCORELEVEL" }, - { "scoring.noDeathBonus", "PERFECTBONUS" }, - { "scoring.game", "SCOREGAME" }, - { "scoring.secret", "SCORESECRET" }, - { "scoring.extraLifeEvery", "EXTRALIFE" }, - { "lifeBar.size", "BarSize" }, - { "lifeBar.missCost", "BarMin" }, - { "lifeBar.regainAfter", "BarBonus" }, - { "windowDelay.normal", "PenalNormal" }, - { "windowDelay.hard", "PenalHard" }, - { "windowDelay.extreme", "PenalExtreme" }, - { "order.play", "PlayOrder" }, - { "order.levels", "LvlOrder" }, - { "order.tiers", "Tiers" }, - { "order.mapStart", "MapStart" }, - { "deaths.clips", "Death" }, - { "deaths.randomCount", "totalDeath" }, - { "secret.allowed", "AllowSecret" }, - { "video.fps", "MovieFPS" }, - { "video.relativeFrames", "RelativeFrames" } -} - - --- The description's snapshot as the game's own globals: its settings, its clips, stills, and named --- fields (scoring, lifeBar, windowDelay, order, deaths, secret, video), the level rows the loops --- read by position (built from its levels, the secret one at levelSecret), and its dips over all --- of them. -function Q.applySnapshot(q, data) - for key, value in pairs(data.settings or {}) do - q[key] = value - end - -- The clips and stills under their own names, as the globals the loops read: a still is - -- frame and its name, capitalised; the attract steps are numbered from 01. - for _, clip in ipairs(QTE_CLIPS) do - local span = (data.clips or {})[clip[1]] - - if span then - q[clip[2]], q[clip[3]] = span[1], span[2] - end - end - for step, span in ipairs((data.clips or {}).attract or {}) do - q[string.format("offsetIntro%02d", step)] = span[1] - q[string.format("offsetIntro%02dend", step)] = span[2] - end - for name, frame in pairs(data.stills or {}) do - q["frame" .. name:sub(1, 1):upper() .. name:sub(2)] = frame - end - for _, field in ipairs(QTE_FIELDS) do - local group, name = field[1]:match("^(%w+)%.(%w+)$") - local value = (data[group] or {})[name] - - if value ~= nil then - q[field[2]] = value - end - end - if data.levels then - q.Level = {} - for key, level in pairs(data.levels) do - local index = (key == "secret") and q.levelSecret or key - - q.Level[index] = { level.title, level.intro, level.introEnd, level.scenes, level.mirror, level.deathMirror, level.replay, level.selectStill } - end - end - for key, value in pairs(data.dips or {}) do - q[key] = value - end -end - - --- A game's options (FORGE.md 15.4): where the games this behaviour plays differ, the --- description says which way under qte.options, and nothing asks where a game came from. The --- default is what a description without the option gets. -function Q.option(q, name, default) - local options = q.snapshot.options - local value = options and options[name] - - -- Not in the description: the style's own default, then the caller's. - if value == nil then - value = q.optionDefaults and q.optionDefaults[name] - end - if value == nil then - return default - end - - return value -end - - --- The secret level's combination on the attract screens: every switch in secretInputs held. -local SECRET_INPUTS = { "p1BUTTON2", "p1BUTTON3", "p1UP", "p1RIGHT" } - - -function Q.secretPressed(q) - if not q.AllowSecret then - return false - end - for _, name in ipairs(Q.option(q, "secretInputs", SECRET_INPUTS)) do - if not q[name] then - return false - end - end - - return true -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 - -- The death-count game type scores nothing. - if q.dip_GameType == Q.option(q, "deathCountGameType", nil) then - return - end - if q.dip_GameType ~= 3 then - local worth = (q.thisScore == 0) and thisMuch or q.thisScore - - q.iScore = q.iScore + worth - q.iExtraLife = q.iExtraLife + worth - if (q.EXTRALIFE > 0) and (q.iExtraLife >= q.EXTRALIFE) and (q.dip_GameType == 0) and (((q.i2P or 0) == 0) or q.arcadeEdition) then - q.iExtraLife = 0 - if q.iLives < q.dip_LivesPerCredit then - Q.sound(q, "sndvictory") - q.iLives = q.iLives + 1 - end - end - -- barBonusRestarts: the count to the life bar's bonus starts over at the bonus, a full bar - -- or none, and a tilt with it; otherwise it starts over only when the bar gains. - if Q.option(q, "barBonusRestarts", false) then - if (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) then - q.iRightMv, q.iTilt = 0, 0 - q.bShowWarnTilt, q.bShowTilt = false, false - if (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then - q.iLifeBar = q.iLifeBar + 1 - end - end - elseif (q.dip_GameType == 1) and (q.iRightMv == q.BarBonusT) and (q.BarBonusT ~= 0) and (q.iLifeBar < q.BarSize) then - q.iRightMv = 0 - q.iLifeBar = q.iLifeBar + 1 - end - 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 - - --- The key the boards are kept under in the engine's save store, per game. -local BOARDS_KEY = "forge.boards" --- The names a board entry takes when the player enters none, drawn at random as the games draw. -local BOARD_NAMES = { "CAL", "AKI", "DON", "TYG", "KIM", "LAR", "KOR", "MOR", "KAR", "CYR" } - - --- The game's boards: the ones an earlier run of this game saved, or the description's (what it --- shipped with). Three are ten { name, score } entries, the best first (normal, life bar, --- survivor; the records shown are their first entries), and percent is each level's best --- { name, percent, difficulty letter }. -function Q.boardsLoad(q, data) - local text = saveGet(BOARDS_KEY) - local chunk = (type(text) == "string") and load(text, "boards", "t", {}) or nil - local saved = chunk and chunk() or nil - local source = (type(saved) == "table") and saved or data.boards or { normal = data.highScores } - - q.boards = { percent = {} } - for _, name in ipairs({ "normal", "lifeBar", "survivor" }) do - q.boards[name] = {} - for k, entry in ipairs(source[name] or source.normal or {}) do - q.boards[name][k] = { entry[1], tonumber(entry[2]) } - end - end - for k, entry in ipairs(source.percent or {}) do - q.boards.percent[k] = { entry[1], tonumber(entry[2]), entry[3] } - end - q.iTopN = q.boards.normal[1] and q.boards.normal[1][2] or 0 - q.iTopLB = q.boards.lifeBar[1] and q.boards.lifeBar[1][2] or 0 - q.iTopS = q.boards.survivor[1] and q.boards.survivor[1][2] or 0 -end - - --- The boards kept for the next run of the game. -function Q.boardsSave(q) - saveSet(BOARDS_KEY, "return " .. sourceOf(q.boards)) -end - - --- The board the game type plays for: the life bar's, the survivor's, or the normal one. -function Q.boardFor(q) - if q.dip_GameType == 1 then - return q.boards.lifeBar - elseif q.dip_GameType == 3 then - return q.boards.survivor - end - - return q.boards.normal -end - - --- A result onto its game type's board, above the first entry it equals or beats, the rest --- moved down and the last dropped; a name left empty is one of BOARD_NAMES, drawn at random. -function Q.boardUpdate(q, name, score) - local board = Q.boardFor(q) - - if name == "" then - name = BOARD_NAMES[math.random(#BOARD_NAMES)] - end - for k = 1, 10 do - if board[k] and (score >= board[k][2]) then - table.insert(board, k, { name, score }) - board[11] = nil - return - end - end -end - - --- The score to beat shown for the game type: the life bar's, the survival's, the death count's --- record, or the board's. -function Q.topFor(q) - if q.dip_GameType == 1 then - return q.iTopLB - elseif q.dip_GameType == 3 then - return q.iTopS - elseif q.dip_GameType == Q.option(q, "deathCountGameType", nil) then - return q.hsDR[q.dip_Difficulty + 1] - end - - return q.iTopN -end - - --- Whether a result makes the board: a score among the ten, or, for the death-count game type, --- no more deaths than the difficulty's record. -function Q.newScore(q, score) - -- The death-count game type ranks the fewest deaths, against the difficulty's record. - if q.dip_GameType == Q.option(q, "deathCountGameType", nil) then - return score <= q.hsDR[q.dip_Difficulty + 1] - end - for k = 1, 10 do - local entry = Q.boardFor(q)[k] - - if entry and (score >= tonumber(entry[2])) then - return true - end - end - - 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 = 0 - q.bLvlJump = false - -- A maze game keeps its scene's deaths across its levels, but for the arcade edition. - if q.arcadeEdition or not q.MazeGame then - q.iScDeath = 0 - end - -- The game type whose deaths are counted keeps its total from level to level. - if q.dip_GameType ~= Q.option(q, "deathCountGameType", nil) then - q.iTotDeath = 0 - end - if q.dip_PlayStyle == 3 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelMap - 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 - - -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 - - --- On from a level: the map, the order drawn again, the level select (play style 4), or the next --- level in order. -function Q.onToNextLevel(q, reordering) - q.bSkipIntroClip = false - q.iLiveSave = q.iLives - q.iScoreSave = q.iScore - 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 - elseif reordering then - Q.reOrder(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - elseif Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4) then - -- One level at a time: back to the level select for the next. - q.dip_StartLevel = q.dip_StartLevel + 1 - q.iScene = 0 - Q.toLevelSelect(q) - else - Q.nextLevel(q, q.iLevel) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelNormal - end -end - - --- A level done, by its replay setting: 0 goes on at once, 1 plays it once more first, and any --- other but -1 draws the level order again the first time through. -function Q.levelReplay(q) - local replay = q.Level[q.iLevel][QTE.LVLREPLAY] - - if replay == 0 then - Q.onToNextLevel(q, false) - elseif replay == 1 then - if not q.stage[q.iLevel][QTE.LEVELSTARTED] then - q.stage[q.iLevel][QTE.LEVELSTARTED] = true - else - Q.onToNextLevel(q, 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 - Q.onToNextLevel(q, true) - else - Q.onToNextLevel(q, false) - end - 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() - else - -- The same answers, read from the description: the converter asked the game's swapScene - -- for every difficulty, level and scene, and kept the ones that differ. A game that still - -- carries the hook runs it, since a hook can be edited to do more than this describes. - local byLevel = (q.snapshot.swaps or {})[q.dip_Difficulty or 0] - local scene = byLevel and byLevel[thisLevel] and byLevel[thisLevel][q.iScene] - - if scene then - q.bSwap = true - q.iScene = scene - end - end - q.bFlip = false - if q.iScene > level[QTE.TOTALSCENES] then - q.iScene = q.iScene - 1 - end - if level[QTE.MIRROR] > 0 then - if math.random(100) <= 50 then - q.bFlip = true - end - end - q.multi = {} - Q.loadMoves(q, thisLevel, q.iScene) - q.qteFns.setupFrames(q, thisLevel) - if q.bAllowSave and (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) and ((q.i2P or 0) == 0) then - -- saveIcon: the style shows its disk where the game saves; keepsSaves writes the - -- autosave there too. - if Q.option(q, "saveIcon", false) then - if Q.option(q, "keepsSaves", true) then - Q.autoSave(q, 4) - end - q.bShowDiskA = true - q.altState = QTE.branch01 - end - q.bAllowSave = false - end -end - - --- The windows style's reset of a move's judging state. -function Q.resetJudge(q) - q.bTestMash, q.bTestMashL, q.bTestMashR, q.bTestRunL, q.bTestRunR, q.bTestHold = false, false, false, false, false, false - q.iMash, q.iMulti, q.iLoopStep, q.iLenHold = 0, 0, 0, 0 - q.bCalc = true -end - - --- The windows style's reset after a move answered: its judge has cleared what the move used. -function Q.resetMoveDone(q) - q.iMulti, q.iLoopStep = 0, 0 -end - - --- The windows style's reset of a move's judging state, and the input with it. -function Q.resetVar(q) - Q.clearInput(q) - Q.resetJudge(q) -end - - --- A wrong or missed move: the death clip and a life gone -- or nothing at all, for a move whose --- death is negative. -function Q.setupDeathClip(q) - local level = q.Level[q.iLevel] - local m = q.move[q.currentMove] - - -- A game's swapDeath may choose another death than the move's. - q.curDeath = m[QTE.moveDeath] - if q.swapDeath then - q.swapDeath() - end - q.bShowLvl, q.bShowNext, q.bShowWarnTilt = false, false, false - q.qteFns.resetVar(q) - if q.curDeath < 0 then - q.lvlState = QTE.lvlPlayRest - return - end - q.lvlState = QTE.lvlPlayDeath - -- deathCountGameType: the game type that counts deaths instead of taking lives. - if q.dip_GameType ~= Q.option(q, "deathCountGameType", nil) then - q.iLives = q.iLives - 1 - end - 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] == q.CHOOSE then - m[QTE.moveDeath] = q.numChoice - q.iPauseFrame = q.move[q.currentMove - 2][QTE.inputFrmEnd] + 1 - q.currentMove = q.currentMove - 2 - elseif m[QTE.correctMove] == q.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[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 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 = q.MOVEPENDING - - if playerMove == q.BUTTON1 then - q.bTestMash = false - if q.iMash >= q.mashCounter then - z = curMove - -- mashClearsButton: the press that finished the mash is used up. - if Q.option(q, "mashClearsButton", true) then - q.p1BUTTON1 = false - end - end - else - if q.iMash > 0 then - q.iMash = q.iMash - q.unMash - end - if playerMove ~= q.NOMOVE then - q.iMash = 0 - z = q.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) - local levelSelect = Q.option(q, "levelSelect", false) - local deathCount = Q.option(q, "deathCountGameType", nil) - - math.randomseed(AUTHOR_SEED or os.time()) - math.random(100) - q.bLvlJump = false - if q.bExtendedPlay then - Q.initStages(q) - q.currentLevel = QTE.levelNormal - q.iLevel = QTE.levelSecret - else - if (q.iCredits > 0) and ((q.i2P or 0) == 0) then - q.iCredits = q.iCredits - 1 - end - q.iScore, q.iScoreTemp, q.iBonus, q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0, 0, 0, 0 - q.iRightMv, q.iWrongMv, q.iTiltMv, q.numTrophy = 0, 0, 0, 0 - if q.currentLevel == QTE.levelContinue then - q.currentLevel = q.iTempLevel - q.iLifeBar = q.BarSize - 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 - 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 Q.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 - -- The death-count game type plays in order, from the start unless a level was unlocked. - if q.dip_GameType == deathCount then - q.dip_PlayStyle = 0 - if not q.bUnlockSel then - q.dip_StartLevel, q.dip_StartScene = 1, 1 - end - end - q.iTop = Q.topFor(q) - if q.dip_PlayStyle == 0 then - Q.doMixSEQ(q) - q.iLevel = q.dip_StartLevel - 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 levelSelect and (q.dip_PlayStyle == 4) then - q.iLevel, q.iScene = q.dip_StartLevel, 0 - Q.toLevelSelect(q) - end - elseif q.dip_GameType == 2 then - q.bShowLives, q.bShowScore = false, false - q.iLevel, q.iScene = q.dip_StartLevel, 0 - -- A style with a level select starts there, unless the game has turned its screen off. - if levelSelect and not q.bNoScreen then - Q.toLevelSelect(q) - else - q.currentLevel = QTE.levelNormal - end - elseif q.dip_GameType == 3 then - Q.doMixSEQ(q) - q.iLevel, q.iScene = 1, 0 - q.iTop = q.iTopS - q.currentLevel = QTE.levelNormal - elseif levelSelect and (q.dip_GameType == 5) then - q.iLevel, q.iScene = q.dip_StartLevel, 0 - Q.toLevelSelect(q) - end - if q.startConf then - q.startConf() - end - end - end - if q.IngameDiffchoice and (q.dip_Diffshow == 4) and q.bOneDiff and not q.bNoScreen then - q.altState = QTE.lvlSetup - q.currentLevel = QTE.levelDiffScreen - if (q.i2P or 0) > 0 then - q.bOneDiff = false - end - end - if (q.offsetIntroGame ~= 0) and (q.iContinues == 0) and (q.dip_StartScene == 1) then - Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) - q.lvlState = QTE.branch11 - else - q.lvlState = QTE.lvlSetup - end - q.bShowScore, q.bRes = false, true - Q.livesForType(q) - q.bShowAction, q.bShowNext, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false -end - - -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 - - 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.qteFns.addPoints(q, bonus, 0) - end - q.qteFns.addPoints(q, q.SCOREGAME, 0) - if Q.beatGameWithOneLife(q) then - q.qteFns.addPoints(q, q.SCORESECRET, 0) - end - else - if bonus > 0 then - q.qteFns.addPoints(q, bonus, 0) - end - q.qteFns.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.qteFns.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 - - --- Back into play after a death, from the frame the move paused on, and past the move missed. -function Q.resumePlay(q) - local m = q.move[q.currentMove] - local after = q.move[q.currentMove + 1] - - -- managedHud: the arrows and HUD are put back as play resumes. - if Q.option(q, "managedHud", false) then - K.resetArrows(q) - if q.dip_Display == 0 then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - q.bShowTop = q.ShowTop and true or false - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - end - discSkipToFrame(q.iPauseFrame) - if (q.dip_Rewind == 3) and after and (after[3] == q.LETGO) then - q.bSwap = true - q.currentMove = q.currentMove + 2 - elseif (q.dip_Rewind == 3) and m and (m[3] == q.PATH) then - q.iPathAend = q.path[q.currentMove][4] - 1 - q.iPathAjmp = q.path[q.currentMove][9] - q.currentMove = q.currentMove + 1 - else - q.currentMove = q.currentMove + 1 - end - q.lvlState = QTE.lvlRunning -end - - --- Whether the next move ends the scene with a kind that rewind mode 3 counts the scene done on. -function Q.nextEndsScene(q) - local after = q.move[q.currentMove + 1] - - if q.currentMove == q.totalMoves then - return true - end - - return after and (q.currentMove + 1 == q.totalMoves) and ((after[3] == q.CHOOSE) or (after[3] == q.LETGO) or (after[3] == q.PATH) or (after[3] == q.YESNO)) -end - - --- After a death clip (or the extra clip after it): the death counted, the rewind mode says --- where play goes. getReady: whether the "get ready" clip may play first; "both" shows the --- whole HUD over it. -function Q.afterDeath(q, thisLevel, getReady) - q.stage[thisLevel][QTE.DEATHCOUNT] = q.stage[thisLevel][QTE.DEATHCOUNT] + 1 - if q.iLives <= 0 then - q.lvlState = QTE.lvlEnd - 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.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 - -- managedHud: the style's HUD over the get-ready, all of it on "both". - if Q.option(q, "managedHud", false) then - if (q.dip_Display == 1) or ((q.dip_Display == 2) and getReady == "both") then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - end - q.lvlState = QTE.branch09 - else - Q.resumePlay(q) - 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.option(q, "alternateMashRate", 9) - if kind == QTE.MASH2MIN then q.unMash = q.unMash - 0.01 elseif kind == QTE.MASH2MAX then q.unMash = q.unMash + 0.01 end - end - thisMove = Q.checkMash2(q, thisMove, kind) - 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 - - --- The windows style's verdict on a wrong or missed move: hints first if the dip says so, else the --- life bar's cost, the pass of the game type that has none, or the death. -function Q.failed(q) - if q.dip_Hints and (q.dip_GameType ~= 1) and (q.dip_GameType ~= 2) then - Q.sound(q, "sndwrong") - q.bShowLvl, q.bShowScore, q.bShowLives = false, false, false - discSkipToFrame(q.frameHints) - Q.timerON(q, Q.option(q, "hintSeconds", 2)) - discPause() - q.lvlState = QTE.branch03 - else - q.iWrongMv = q.iWrongMv + 1 - q.iScDeath = q.iScDeath + 1 - Q.resetJudge(q) - if q.dip_GameType == 2 then - Q.sound(q, "sndwrong") - q.lvlState = QTE.lvlPlayRest - elseif q.dip_GameType == 1 then - q.iLifeBar = q.iLifeBar - q.BarMinT - q.iRightMv = 0 - Q.sound(q, "sndwrong") - q.lvlState = QTE.lvlPlayRest - if q.iLifeBar <= 0 then - Q.setupDeathClip(q) - end - else - Q.setupDeathClip(q) - end - end -end - - --- The score and lives shown in play: managedHud sets the level and the record with them by the --- display setting; otherwise both show when the display is on, but for game type 2. -function Q.showPlayFlags(q) - if not Q.option(q, "managedHud", false) then - q.bShowScore = (q.dip_GameType ~= 2) and (q.dip_Display == 0) - q.bShowLives = q.bShowScore - elseif q.dip_GameType == 2 then - q.bShowScore, q.bShowLives = false, false - elseif q.dip_Display == 0 then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - q.bShowTop = q.ShowTop and true or false - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end -end - - --- A tilt: input before a move's window, counted against the tilt dip; at the limit it costs --- as a wrong move does, by game type. -function Q.checkTilt(q, m, kind) - if (q.thisMove == q.NOMOVE) or (kind >= 25) or ((q.dip_Tilt or 0) <= 0) or (q.dip_GameType >= 5) then - return - end - q.iTilt = q.iTilt + 1 - q.iTrigTilt = 50 - 10 * q.dip_Tilt - if q.iTilt >= q.iTrigTilt then - q.iPauseFrame = m[QTE.inputFrmEnd] - if (q.dip_GameType == 0) or (q.dip_GameType == 3) or (q.dip_GameType == 4) then - q.lvlState = QTE.branch02 - elseif q.dip_GameType == 1 then - q.iLifeBar = q.iLifeBar - q.BarMinT - q.iRightMv, q.iTilt = 0, 0 - Q.sound(q, "sndwrong") - q.altState = QTE.branch01 - if q.iLifeBar <= 0 then - Q.setupDeathClip(q) - end - elseif q.dip_GameType == 2 then - q.iTiltMv = q.iTiltMv + 1 - q.iTilt = 0 - Q.sound(q, "sndwrong") - q.altState = QTE.branch01 - end - q.bShowTilt = true - elseif (q.iTilt > q.iTrigTilt / 2) and (q.iTilt < q.iTrigTilt) and (q.dip_GameType ~= 5) and (q.dip_GameType ~= 6) then - q.bShowWarnTilt = true - end -end - - --- The level loop: a scene's setup and its intro, the moves in turn, the deaths and the get-ready, --- and the end of the scene. The style's own judge, verdict, and resets come through q.qteFns. -function Q.doLevel(q) - local thisLevel = q.iLevel - local level = q.Level[thisLevel] - local managedHud = Q.option(q, "managedHud", false) - - if q.lvlState == QTE.lvlSetup then - q.bShuffleOrder, q.bPlayPrompt, q.bPlayRight = true, true, true - q.bShowLvl, q.bShowAction, 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 - -- The arcade edition starts every level with the rewind off. - if q.arcadeEdition and (q.iScene == 0) then - q.dip_Rewind = 0 - end - q.qteFns.resetJudge(q) - Q.showPlayFlags(q) - if not q.bSave then - q.currentMove = 1 - end - 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) - if managedHud then - if (q.dip_Display == 1) or (q.dip_Display == 2) then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - end - q.bShowGet = true - q.lvlState = QTE.branch08 - q.bRes = false - 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 - q.qteFns.failed(q) - elseif q.lvlState == QTE.branch03 then - if Q.timerDue(q) then - -- hintedDeathCounts: a death after the hints counts as the wrong move it was. - if Q.option(q, "hintedDeathCounts", false) then - q.iPath = 0 - q.iWrongMv = q.iWrongMv + 1 - q.iScDeath = q.iScDeath + 1 - end - Q.setupDeathClip(q) - end - elseif q.lvlState == QTE.branch04 then - local worth = q.SCOREMOVE + q.dip_Difficulty * q.BUFFMOVE - - q.bShowAction, q.bShowNext, q.bTime = false, false, true - q.qteFns.resetMoveDone(q) - q.lvlState = QTE.lvlPlayRest - -- A move of nothing that was answered scores nothing, but for a plain one outside the - -- arcade edition. - if (q.thisMove ~= q.NOTHING) or ((not q.arcadeEdition) and (q.move[q.currentMove][5] == nil)) then - if q.bPlayRight then - Q.sound(q, "sndright") - q.bPlayRight = false - end - q.iRightMv = q.iRightMv + 1 - Q.addPoints(q, worth, q.currentMove) - q.iScoreTemp = q.iScoreTemp + worth - end - elseif q.lvlState == QTE.branch05 then - if Q.timerDue(q) then - local deathCount = Q.option(q, "deathCountGameType", nil) - - q.bGOAlt = true - if (Q.beatGameWithOneLife(q) or Q.beatGameWithOneCredit(q)) and (thisLevel ~= QTE.levelSecret) and q.AllowSecret and (q.dip_GameType ~= deathCount) then - Q.sound(q, "sndvictory") - discSkipToFrame(q.frameSecret) - discPause() - Q.timerON(q, 4) - q.lvlState = QTE.branch06 - elseif (q.dip_GameType ~= deathCount) and Q.newScore(q, q.iScore) then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelHighScore - elseif (q.dip_GameType == deathCount) and (not q.bUnlockSel) and Q.newScore(q, q.iTotDeath + q.iScDeath) then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelHighScore - else - 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 - -- extraDeathClipSkips: BUTTON1 cuts the extra clip after a death short. - if (q.currentFrame >= q.iFrameEnd) or (q.p1BUTTON1 and Q.option(q, "extraDeathClipSkips", false)) then - Q.afterDeath(q, thisLevel, "both") - end - elseif q.lvlState == QTE.branch08 then - if q.currentFrame >= q.iFrameEnd then - q.bShowGet = false - Q.showPlayFlags(q) - Q.enterScene(q, thisLevel) - end - elseif q.lvlState == QTE.branch09 then - if q.currentFrame >= q.iFrameEnd then - q.bShowGet = false - Q.resumePlay(q) - if managedHud then - Q.showPlayFlags(q) - end - 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 == q.branch13 then - -- managedHud's pause on the hints frame after a death, with the score and lives up. - if Q.timerDue(q) then - Q.afterDeath(q, thisLevel, false) - else - q.bShowScore, q.bShowLives = true, true - end - elseif q.lvlState == QTE.lvlPlayRest then - local m = q.move[q.currentMove] - - q.bPlayRight = true - if q.currentMove < q.totalMoves then - q.bCheckMove = true - end - q.thisMove = Q.scanInput(q) - if q.currentFrame >= m[QTE.moveFrmEnd] then - q.bCheckMove = false - if q.iPathAjmp == q.OUT then - q.currentMove = q.iPathAjmp - q.iPathAend, q.iPathAjmp = 0, 0 - q.lvlState = QTE.branch12 - -- outEndsSceneNow: the scene's end is taken in this same step, not the next. - if not Q.option(q, "outEndsSceneNow", false) then - return - end - elseif (q.iPathAend ~= 0) and (q.currentMove == q.iPathAend) then - q.currentMove = q.iPathAjmp - q.iPathAend, q.iPathAjmp = 0, 0 - 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 - if next[QTE.correctMove] == q.CHOOSE then - q.altState = QTE.lvlSetup - q.lvlState = QTE.branch10 - else - q.lvlState = QTE.lvlRunning - end - else - q.bShowNext = false - if not Q.sceneDone(q, thisLevel) then - if q.currentFrame >= q.sceneEnd then - q.lvlState = QTE.lvlEnd - end - end - end - end - elseif q.lvlState == QTE.lvlRunning then - local m = q.move[q.currentMove] - local kind = m and m[QTE.correctMove] - - if m == nil then - q.lvlState = QTE.lvlEnd - return - end - if (q.dip_GameType <= 2) and q.dip_Next then - q.bShowNext = true - end - -- waitForWindowStart: a window that ends before it starts waits for its start; otherwise - -- its end is what closes it. - local early = q.currentFrame < m[QTE.inputFrmStart] - local late = (q.currentFrame > m[QTE.inputFrmEnd]) and not (early and Q.option(q, "waitForWindowStart", false)) - - if (not early) and (not late) then - q.bShowAction = true - if q.bPlayPrompt and ((q.dip_ShowAction == 1) or (q.dip_ShowAction == 5)) and (kind < 50) and not q.arcadeEdition then - Q.sound(q, "sndcoin") - q.bPlayPrompt = false - end - q.qteFns.judge(q) - elseif late then - -- pendingIsUnanswered: a move still pending when its window closes counts as none. - local pending = (q.thisMove == q.NOMOVE) or ((q.thisMove == q.MOVEPENDING) and Q.option(q, "pendingIsUnanswered", false)) - local jump = (kind == q.SKIP) or (kind == q.WAY) or (kind == q.WAYOUT) or (kind == q.TOSCENE) or (kind == q.TOLEVEL) - - if pending and ((m[QTE.moveDeath] == -1) or (m[QTE.moveDeath] == -3)) then - q.bShowAction = false - q.lvlState = QTE.lvlPlayRest - elseif pending and (m[QTE.moveDeath] == -2) then - q.bShowAction = false - q.lvlState = QTE.branch04 - elseif not jump then - q.iPauseFrame = m[QTE.inputFrmEnd] - q.bShowAction = false - q.lvlState = QTE.branch02 - else - q.bShowAction = false - Q.addPoints(q, 0, q.currentMove) - if (kind == q.WAY) and (m[QTE.moveDeath] > 0) then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.lvlPlayRest - elseif kind == q.WAYOUT then - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - elseif kind == q.TOSCENE then - q.bScnJump = true - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - elseif kind == q.TOLEVEL then - q.bLvlJump = true - q.iPath = m[QTE.moveDeath] - q.lvlState = QTE.branch12 - else - q.lvlState = QTE.lvlPlayRest - end - end - else - q.thisMove = Q.scanInput(q) - Q.checkTilt(q, m, kind) - end - elseif q.lvlState == QTE.lvlPlayDeath then - if managedHud then - if q.dip_Display == 1 then - q.bShowScore, q.bShowLives = true, true - else - q.bShowScore, q.bShowLives = false, false - end - end - if q.currentFrame >= q.iFrameEnd then - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - if (q.i2P or 0) > 0 then - -- Two players take turns at each death. - q.i2P = (q.i2P == 1) and 2 or 1 - q.lvlState = QTE.lvlSetup - q.currentLevel = (q.b1PEnd and q.b2PEnd) and q.level2PEnd or q.level2P - elseif managedHud and (q.dip_Display == 2) and (q.iLives > 0) and (not q.ShowSupDeath) and (not q.ShowResurrect) then - discSkipToFrame(q.frameHints) - discPause() - Q.timerON(q, 3) - q.lvlState = q.branch13 - elseif q.ShowSupDeath and (q.dip_GameType ~= 3) then - Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) - q.lvlState = QTE.branch07 - else - Q.afterDeath(q, thisLevel, 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 = Q.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 - 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.iLives, q.iScore - q.bAllowSave, q.bRes = true, true - if (q.ShowLvlClear or (q.dip_GameType == 2)) and (q.dip_GameType ~= 3) and (q.dip_GameType ~= Q.option(q, "deathCountGameType", nil)) then - Q.sound(q, "sndclear") - q.currentLevel = QTE.levelFinish - else - q.iScoreTemp, q.iBonus = 0, 0 - if q.dip_PlayStyle == 3 then - q.currentLevel = QTE.levelMap - elseif Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4) then - q.dip_StartLevel = q.dip_StartLevel + 1 - q.iScene = 0 - Q.toLevelSelect(q) - else - Q.nextLevel(q, q.iLevel) - q.currentLevel = QTE.levelNormal - end - end - else - -- wayOutPlaysTarget: a way out plays the scene it names, done or not; otherwise - -- it goes there when that scene is still to do, and on past it when it is done. - local target = q.iPath - local playsTo = Q.option(q, "wayOutPlaysTarget", false) and (target ~= 0) - - if (target ~= 0) and not q.bLvlJump then - q.iScene = playsTo and (target - 1) or target - end - if (not q.scene[thisLevel][q.iScene][QTE.SCENECOMPLETE]) and not playsTo 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 - Q.nextLevel(q, q.iLevel) - q.currentLevel = QTE.levelNormal - end - end - q.iPath = 0 - if (q.dip_Rewind == 0) or (q.dip_Rewind == 2) then - Q.levelReplay(q) - end - end - q.bShowLives, q.bShowLvl, q.bShowAction, q.bShowNext = false, false, false, false - q.qteFns.resetJudge(q) - 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 - - --- A continue taken: the scene lost is played again, or the arcade edition starts its stages over. -function Q.stepBack(q) - if q.arcadeEdition then - Q.initStages(q) - q.iScene, q.currentMove = 0, 1 - elseif q.iScene > 0 then - q.iScene = q.iScene - 1 - end -end - - -function Q.doContinue(q) - if q.lvlState == QTE.lvlSetup then - Q.setupClip(q, q.offsetContinue, q.offsetContinueend) - q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowAction, q.bShowNext = false, false, false, false, false, false - q.bShowCredits, q.bTestMash, q.iMash = true, false, 0 - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if Q.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.bOneDiff, q.bSkipIntroClip = false, true - q.qteFns.stepBack(q) - q.qteFns.startGame(q) - end - elseif q.p1BUTTON2 and Q.option(q, "continueDeclines", true) then - -- continueDeclines: BUTTON2 turns the continue down. - q.p1BUTTON2 = false - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.lvlState = QTE.lvlSetup - q.bSkipIntroClip = false - if q.qteFns.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.bShowScene, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext = false, false, false, false, false, false, false - q.bTestMash, q.iMash = false, 0 - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - -- diffChoiceAfterGameOver: the next game offers the difficulty again, even after a - -- continue turned it off. - if Q.option(q, "diffChoiceAfterGameOver", true) then - q.bOneDiff = true - end - if q.bGOAlt then - Q.setupClip(q, q.offsetGameOverAlt, q.offsetGameOverAltend) - q.bGOAlt = false - elseif (Q.option(q, "gameOverAfterContinue", "playOn") == "playOn") and (q.currentFrame == q.offsetContinueend) then - -- playOn: the game over plays on from the continue clip when it follows it on the - -- disc; seek: it is always sought. - q.iFrameEnd = q.offsetGameOverend - else - Q.setupClip(q, q.offsetGameOver, q.offsetGameOverend) - end - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if Q.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 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 - - --- To the style's level select. -function Q.toLevelSelect(q) - q.altState = QTE.lvlSetup - q.currentLevel = q.levelSelect -end - - --- The in-game difficulty screen: a still per difficulty, the stick to choose, the action button --- or thirty seconds to take it. --- Whether the game type chosen is one that picks its level (levelSelect: the style has a level --- select). -function Q.levelSelectType(q) - return Q.option(q, "levelSelect", false) and ((q.dip_GameType == 2) or (q.dip_GameType == 4)) -end - - --- The lives, and the life bar's floor and bonus, for the game type and difficulty chosen. -function Q.livesForType(q) - if q.dip_GameType == 1 then - q.iLives = 1 - if q.dip_Difficulty == 0 then - q.BarMinT = q.BarMin - if q.BarBonus > 0 then - q.BarBonusT = q.BarBonus - 1 - end - elseif q.dip_Difficulty == 1 then - q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus - elseif q.dip_Difficulty == 2 then - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus - else - q.BarMinT, q.BarBonusT = q.BarMin + 1, q.BarBonus + 1 - end - elseif q.dip_GameType == 3 then - q.iLives = 1 - else - q.iLives = q.dip_LivesPerCredit - end -end - - -function Q.doDiffSelect(q) - if q.altState == QTE.lvlSetup then - q.bShowScore, q.bShowLives, q.bIgnoreJoy = false, false, false - 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 - Q.livesForType(q) - if (q.offsetIntroGame ~= 0) and (q.dip_StartScene == 1) then - Q.setupClip(q, q.offsetIntroGame, q.offsetIntroGameend) - q.lvlState = QTE.branch11 - else - q.lvlState = QTE.lvlSetup - end - -- levelSelect: the game types that choose their level go to the style's level select. - if (q.dip_PlayStyle == 3) and (q.MapStart == 0) then - q.currentLevel = QTE.levelMap - elseif Q.levelSelectType(q) or (Q.option(q, "levelSelect", false) and (q.dip_PlayStyle == 4)) then - Q.toLevelSelect(q) - else - q.currentLevel = QTE.levelNormal - end - end -end - - --- The letter grid a name is spelled on: 42 letters, digits, and marks, 9 to a row, then CLR --- (one letter back) and END. -local NAME_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ@123456789?!# " -local NAME_CLEAR = 43 -local NAME_END = 44 -local NAME_ROW = 9 -local NAME_REPEAT = 0.2 -- Seconds a move on the grid holds off the next. -local NAME_DOWN_TO = 35 -- The last cell DOWN moves a whole row from. --- Where UP and DOWN go from the cells of the short last row and the first row's ends, as the --- games move there. -local NAME_DOWN = { [36] = 44, [37] = 1, [38] = 2, [39] = 3, [40] = 4, [41] = 5, [42] = 6, [43] = 7, [44] = 9 } -local NAME_UP = { [1] = 37, [2] = 38, [3] = 39, [4] = 40, [5] = 41, [6] = 42, [7] = 43, [8] = 43, [9] = 44, [43] = 34, [44] = 36 } - - --- A press on the name's grid: the stick moves the cursor, BUTTON1 takes the letter under it (up --- to three), CLR takes one back, END puts the result on the board and shows the rankings. -function Q.nameEntryInput(q) - if q.p1LEFT then - q.iCursor = q.iCursor - 1 - Q.sound(q, "sndright") - elseif q.p1RIGHT then - q.iCursor = q.iCursor + 1 - Q.sound(q, "sndright") - elseif q.p1DOWN then - Q.sound(q, "sndright") - q.iCursor = (q.iCursor <= NAME_DOWN_TO) and (q.iCursor + NAME_ROW) or NAME_DOWN[q.iCursor] or q.iCursor - elseif q.p1UP then - Q.sound(q, "sndright") - q.iCursor = ((q.iCursor > NAME_ROW) and (q.iCursor < NAME_CLEAR)) and (q.iCursor - NAME_ROW) or NAME_UP[q.iCursor] or q.iCursor - elseif q.p1BUTTON1 then - if q.iCursor < NAME_CLEAR then - Q.sound(q, "sndcredit") - if #q.sName < 3 then - q.sName = q.sName .. NAME_LETTERS:sub(q.iCursor, q.iCursor) - end - if #q.sName == 3 then - q.iCursor = NAME_END - end - elseif q.iCursor == NAME_CLEAR then - Q.sound(q, "snddeath") - q.sName = q.sName:sub(1, -2) - else - Q.boardUpdate(q, q.sName, q.iScore) - Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) - q.lvlState = QTE.branch02 - end - end - if q.iCursor > NAME_END then - q.iCursor = 1 - elseif q.iCursor < 1 then - q.iCursor = NAME_END - end - if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 then - q.bIgnoreJoy = true - Q.joyDelayON(q, NAME_REPEAT) - end - q.p1UP, q.p1DOWN, q.p1LEFT, q.p1RIGHT, q.p1BUTTON1 = false, false, false, false, false -end - - --- The high score board: its clips, the name left unentered (what is typed is not play), the --- rankings, and on to the game over -- or the attract, for the game type without one. -function Q.doHighScore(q) - if q.lvlState == QTE.lvlSetup then - q.bShowLives, q.bShowScore, q.bShowCredits, q.bShowAction, q.bShowNext, q.bShowLCD = false, false, false, false, false, false - q.bIgnoreJoy = false - q.iTilt = 0 - q.bShowWarnTilt, q.bShowTilt = false, false - q.iCursor, q.sName = 1, "" - Q.setupClip(q, q.offsetNewHScore, q.offsetNewHScoreend) - q.lvlState = QTE.branch01 - elseif q.lvlState == QTE.branch01 then - if (q.currentFrame >= q.iFrameEnd) or q.p1BUTTON1 then - 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 - -- nameEntry: the player spells a name on the letter grid while the clip plays; the - -- clip's end, or END, puts the result on the board. - local entering = Q.option(q, "nameEntry", true) - - if entering and (q.currentFrame < q.iFrameEnd) then - if q.bIgnoreJoy then - if Q.joyDelayDue(q) then - q.bIgnoreJoy = false - end - else - Q.nameEntryInput(q) - end - return - end - if q.currentFrame >= q.iFrameEnd then - if entering then - Q.boardUpdate(q, q.sName, q.iScore) - end - -- rankingsAfterBoard: playOn plays on into the rankings when they follow the board on - -- the disc; seek always seeks them. - if (Q.option(q, "rankingsAfterBoard", "playOn") == "playOn") and (q.iFrameEnd + 1 == q.offsetRankings) then - q.iFrameEnd = q.offsetRankingsend - else - Q.setupClip(q, q.offsetRankings, q.offsetRankingsend) - end - -- A game type that chooses its level waits on the rankings in a state of its own. - q.lvlState = Q.levelSelectType(q) and QTE.branch03 or QTE.branch02 - end - elseif (q.lvlState == QTE.branch02) or (q.lvlState == QTE.branch03) then - if q.currentFrame >= q.iFrameEnd then - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - Q.boardsSave(q) - q.lvlState = QTE.lvlSetup - -- noGameOverGameType: the game type that goes from the board back to the attract. - if q.dip_GameType == Q.option(q, "noGameOverGameType", nil) then - q.currentLevel = QTE.levelIntro - else - q.bGOAlt = true - q.currentLevel = QTE.levelGameOver - end - end -end - - --- ----- 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 a singe// tree: 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(frames, path) - 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 - - --- What the games change as they draw, after the frame's play, done here where the qteHud look --- reads it after: the level name goes once play is past its first move or its time is up, and --- the autosave disk shows two seconds (on the shared timer and altState, as the games time it; --- it first shows the frame after it is set, and not at all unless the HUD is on, dip_Display 0). -function Q.afterStep(q) - if q.bShowLvl and q.ShowLevel and ((q.currentFrame == q.Tlimit) or ((q.currentMove or 0) > 1)) then - q.bShowLvl = false - end - -- The slots' disks, then the autosave's (only with the HUD on), each on the shared timer. - q.diskVisible = nil - for _, disk in ipairs({ "1", "2", "3", "A" }) do - if q["bShowDisk" .. disk] and ((disk ~= "A") or (q.dip_Display == 0)) then - if q.altState == QTE.branch01 then - Q.timerON(q, 2) - q.altState = QTE.branch02 - elseif q.altState == QTE.branch02 then - if Q.timerDue(q) then - q["bShowDisk" .. disk] = false - else - q.diskVisible = q.diskVisible or disk - end - end - end - end -end - - --- Leaving play for a screen (SERVICE, TEST, the exit chord): the game autosaved (not in the --- survivor game type, nor past the scene's last move) to come back to; the chord's autosave also --- restarts the disk's timing. -function Q.leavePlay(q, chord) - if (q.currentLevel == QTE.levelNormal) or (q.currentLevel == QTE.levelMap) then - q.bInPlayExit = true - if (q.currentLevel == QTE.levelMap) or ((q.currentMove <= q.totalMoves) and (q.dip_GameType ~= 3)) then - Q.autoSave(q, 4) - if chord then - q.altState = QTE.branch01 - end - end - end -end - - --- The loop's state as Forge's vars, for a description's own rules and HUD to read. -function Q.publishVars(q) - AUTHOR_VARS.score = q.iScore - AUTHOR_VARS.lives = q.iLives - AUTHOR_VARS.credits = q.iCredits - AUTHOR_VARS.level = q.iLevel - if q.bShowAction and q.move[q.currentMove] then - AUTHOR_VARS.prompt = q.move[q.currentMove][QTE.correctMove] - else - AUTHOR_VARS.prompt = "" - end -end - - --- The games' start, after the title clip and again after the options screen: the attract loop --- from its first state, the credits and score cleared, the HUD back to the credits alone, the --- life bar's and the difficulty's settings taken up, the settings' language, extra clips, and --- record, and the overlay laid out again (the options may have changed its resolution). -function Q.initJob(q) - local penalties = { [0] = 0, [1] = q.PenalNormal, [2] = q.PenalHard, [3] = q.PenalExtreme } - - q.gameflow = "running" - q.currentLevel = QTE.levelIntro - q.lvlState = QTE.lvlSetup - q.altState = QTE.lvlSetup - q.iCoins, q.iScore, q.iScoreTemp, q.iBonus, q.iScene = 0, 0, 0, 0, 0 - q.bShowCredits = true - q.bShowScore, q.bShowLives, q.bShowAction, q.bShowLvl, q.bShowSkip = false, false, false, false, false - q.bShowDisk1, q.bShowDisk2, q.bShowDisk3, q.bShowDiskA = false, false, false, false - q.BarMinT, q.BarBonusT = q.BarMin, q.BarBonus - q.iPenal = penalties[q.dip_Difficulty] or q.iPenal - Q.setLang(q) - Q.applyExtras(q) - if q.ShowTop then - q.bShowTop = true - end - Q.applyResolution(q) -end - - --- ===== Saves ===================================================================================== --- --- The games' save slots: three a player saves to, the autosave (4) the games write as a level --- clears and before a service screen from play, and two more of the held style's. A save is --- { line = { level, scene, move, lives or life bar, difficulty, score, game type, scenes played, --- deaths in the scene, deaths in all, play style }, levels = { { order, started, beaten, deaths }, --- ... } }. They are the description's (what the game shipped with) until the game writes one, --- which keepsSaves keeps in the engine's save store for the next run. - -local SAVE_SLOTS = 6 -local SAVE_KEY = "forge.save" - - --- The slots, each the one an earlier run saved or the description's. -function Q.savesLoad(q) - q.saves = {} - for slot = 1, SAVE_SLOTS do - local text = saveGet(SAVE_KEY .. slot) - local chunk = (type(text) == "string") and load(text, "save", "t", {}) or nil - local saved = chunk and chunk() or nil - - q.saves[slot] = (type(saved) == "table") and saved or (q.snapshot.saves or {})[slot] - end -end - - --- A save's line into a slot, with the levels as they stand, in their order, each started as it is --- beaten (as the games write them); kept for the next run under keepsSaves. -function Q.saveWrite(q, slot, line) - local levels = {} - - for k = 1, q.finalstage do - local stage = q.stage[q.LvlOrder[k]] - - levels[k] = { q.LvlOrder[k], stage[QTE.BEATSTATUS] == true, stage[QTE.BEATSTATUS] == true, stage[QTE.DEATHCOUNT] } - end - q.saves[slot] = { line = line, levels = levels } - if Q.option(q, "keepsSaves", true) then - saveSet(SAVE_KEY .. slot, "return " .. sourceOf(q.saves[slot])) - end -end - - --- Where the game stands, into a slot, as the games' autoSave writes it. -function Q.autoSave(q, slot) - Q.saveWrite(q, slot, { q.iLevel, q.iScene, q.bCheckMove and (q.currentMove + 1) or q.currentMove, (q.dip_GameType == 1) and q.iLifeBar or q.iLives, q.dip_Difficulty, q.iScore, q.dip_GameType, q.iScPlayed, q.iScDeath, q.iTotDeath, q.dip_PlayStyle }) -end - - -function Q.loadSave(q, thisSlot) - local save = q.saves[thisSlot] - - if save == nil then - q.lvlState = QTE.lvlEnd - return - end - local line = save.line - - q.scene, q.stage = {}, {} - for k = 1, q.finalstage do - q.scene[k] = {} - q.stage[k] = { false, false, 0 } - for i = 1, q.Level[k][QTE.TOTALSCENES] do - q.scene[k][i] = { i, false } - end - end - if (thisSlot == 5) or (thisSlot == 6) then - q.iLevel, q.iScene = tonumber(line[1]), tonumber(line[2]) - else - q.dip_StartLevel, q.dip_StartScene = tonumber(line[1]), tonumber(line[2]) - end - q.currentMove = tonumber(line[3]) - q.dip_GameType = tonumber(line[7]) - if q.dip_GameType == 1 then - q.iLifeBar, q.iLives = tonumber(line[4]), 1 - else - q.iLives = tonumber(line[4]) - end - q.iScPlayed, q.iScDeath, q.iTotDeath = tonumber(line[8]), tonumber(line[9]), tonumber(line[10]) - q.dip_PlayStyle, q.dip_Difficulty, q.iScore = tonumber(line[11]), tonumber(line[5]), tonumber(line[6]) - for k, entry in ipairs(save.levels) do - q.LvlOrder[k] = entry[1] - if entry[2] then - q.stage[k][QTE.LEVELSTARTED] = true - end - if entry[3] then - q.stage[k][QTE.BEATSTATUS] = true - end - q.stage[k][QTE.DEATHCOUNT] = entry[4] - end - for k = 1, q.finalstage do - if q.stage[k][QTE.BEATSTATUS] then - for i = 1, q.Level[k][QTE.TOTALSCENES] do - q.scene[k][i] = { i, true } - end - elseif q.stage[k][QTE.LEVELSTARTED] and (q.dip_StartScene ~= 1) then - for i = 1, q.dip_StartScene - 1 do - q.scene[k][i] = { i, true } - end - end - end - if q.AllowSecret and q.Level[QTE.levelSecret] then - q.stage[QTE.levelSecret] = { false, false, 0 } - q.scene[QTE.levelSecret] = {} - for i = 1, q.Level[QTE.levelSecret][QTE.TOTALSCENES] do - q.scene[QTE.levelSecret][i] = { i, false } - end - end - if (q.dip_StartLevel == 1) and (q.dip_StartScene == 1) and (q.currentMove == 1) and (thisSlot < 5) then - q.lvlState = QTE.lvlEnd - elseif (q.dip_StartScene <= 0) and (q.dip_PlayStyle == 3) then - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - q.qteFns.startMap(q) - else - q.iLiveSave, q.iScoreSave = q.iLives, q.iScore - q.qteFns.startSave(q) - end - if q.loadSavePlus then - q.loadSavePlus(thisSlot) - end -end - - --- The windows style's start from a save: the level and scene it stood at, the counts of the --- scene's deaths cleared, the life bar's settings for the difficulty, the record for the game type, --- and the HUD by the display setting. -function Q.startSave(q) - if q.currentMove ~= 1 then - q.bSave = true - end - q.iContinues = 0 - q.iLevel = q.dip_StartLevel - q.iScene = q.dip_StartScene - 1 - q.iScoreTemp = q.iScore - q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 - if q.dip_GameType == 1 then - Q.livesForType(q) - end - q.iTop = Q.topFor(q) - q.currentLevel = QTE.levelNormal - q.lvlState = QTE.lvlSetup - Q.startSaveHud(q) -end - - --- The windows style's start from a save made on the map: the map, the lives a credit gives. -function Q.startMap(q) - q.iContinues, q.iScene = 0, 0 - q.iLevel = q.dip_StartLevel - q.iScoreTemp = q.iScore - q.iScPlayed, q.iScDeath, q.iTotDeath = 0, 0, 0 - if q.dip_GameType == 1 then - Q.livesForType(q) - else - q.iLives = q.dip_LivesPerCredit - end - q.iTop = Q.topFor(q) - q.currentLevel = QTE.levelMap - q.lvlState = QTE.lvlSetup - Q.startSaveHud(q) -end - - --- The HUD as a game from a save starts it: the score, lives, and level by the display setting, --- everything else off. -function Q.startSaveHud(q) - if q.dip_Display == 0 then - q.bShowScore, q.bShowLives, q.bShowLvl = true, true, true - q.bShowTop = q.ShowTop and true or false - else - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop = false, false, false, false - end - q.bShowGet, q.bShowSkip, q.bShowAction, q.bShowCredits, q.bShowLCD, q.bResetContinue, q.bExtendedPlay = false, false, false, false, false, false, false -end - - --- ===== The service screens ===================================================================== --- --- The options screen SERVICE opens from the attract loop: fifteen rows on the options still, UP --- and DOWN between them, RIGHT or BUTTON1 to the next value and LEFT to the one before (each --- move holding off the next a quarter of a second), START to keep them and leave. The values --- change by the games' own rules, which tie some to others (a game type sets the hints, the --- rewind, and the difficulty screen; the level to start at resets the scene). What is kept is --- the dips, written through the engine's save store (Q.configSave) and read at attach. - -local SERVICE_ROWS = 15 -local SERVICE_REPEAT = 0.25 -- Seconds a move on the screen holds off the next. -local SERVICE_GAME_TYPES = { [0] = "Normal", "Life Bar", "Percent", "Survivor", "Kid (Action Only)", "Watch Movie" } -local SERVICE_DIFFICULTY = { [0] = "Easy", "Normal", "Hard", "Extreme", "In-Game" } -local SERVICE_STYLES = { [0] = "Normal", "Random", "Tiers", "Map" } -local SERVICE_REWINDS = { [0] = "Scene", "Last Move", "Level", "Next Move" } -local SERVICE_PROMPTS = { [0] = "Nothing", "Sound", "Guess", "Cliff", "Normal", "Normal+Sound" } -local SERVICE_MASHES = { [0] = "Good Stick", "Bad Stick", "Two Buttons", "Simple Mash" } -local SERVICE_LABELS = { "Game Options", "Game Type", "Difficulty", "Play Style", "Start at Level", "Start at Scene", "Death Rewind", "Coins per Credit", "Lives per Credit", "Allow Continue?", "Show Moves", "Show Hints", "Run Action with" } - - --- A value one step on, round from the last to the first. -local function serviceStep(value, step, first, last) - value = value + step - if value > last then - return first - elseif value < first then - return last - end - - return value -end - - --- A game type chosen, with what it ties to it: every type but the normal one turns the hints --- off, the life bar turns the rewind to the scene, and the difficulty screen goes to the in-game --- choice (or normal), none for the kid's type, and normal to watch the movie. -local function serviceGameType(q, gameType) - q.dip_GameType = gameType - if gameType ~= 0 then - q.dip_Hints = false - end - if gameType == 1 then - q.dip_Rewind = 0 - end - if gameType == 4 then - q.dip_Diffshow = 0 - elseif gameType == 5 then - q.dip_Diffshow = 1 - else - q.dip_Diffshow = q.IngameDiffchoice and 4 or 1 - end -end - - --- The play style one step on or back among those the game allows (random, tiers, map; normal --- always), the level and scene to start at going back to the first on leaving or reaching the --- normal one. -local function servicePlayStyle(q, step) - local allowed = { [0] = true, [1] = (q.AllowRandom == true), [2] = (q.AllowTier == true), [3] = (q.AllowMap == true) } - local style = q.dip_PlayStyle - - repeat - style = serviceStep(style, step, 0, 3) - until allowed[style] - if (q.dip_PlayStyle == 0) or (style == 0) then - q.dip_StartLevel, q.dip_StartScene = 1, 1 - end - q.dip_PlayStyle = style -end - - --- A screen's last two rows, the same on every screen: "Default" (or "Restart" from play), and --- EXIT (or back to the game), which leaves. -local function serviceLastRows(q, row, rows, state) - if row == rows - 1 then - if q.bInPlayExit then - Q.applyResolution(q) - q.qteFns.startGame(q) - else - Q.serviceDefaults(q) - end - elseif row == rows then - q[state] = QTE.lvlEnd - end -end - - --- A row's value one step on (1) or back (-1), by the games' rules; the first row opens the --- graphics screen going on and the performance screen going back, the last two restore the --- defaults and leave. -function Q.serviceChange(q, row, step) - local gameType = q.dip_GameType - - if row == 1 then - if step > 0 then - q.optSelG, q.altState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelGraphic - else - q.optSelP, q.altState2, q.currentLevel = 1, QTE.lvlRunning, QTE.levelPerf - end - elseif row == 2 then - serviceGameType(q, serviceStep(gameType, step, 0, 5)) - elseif (row == 3) and (gameType < 4) then - q.dip_Diffshow = serviceStep(q.dip_Diffshow, step, 0, q.IngameDiffchoice and 4 or 3) - elseif (row == 4) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then - servicePlayStyle(q, step) - elseif (row == 5) and (gameType ~= 5) and (gameType ~= 3) and (q.dip_PlayStyle == 0) then - q.dip_StartLevel = serviceStep(q.dip_StartLevel, step, 1, q.finalstage) - q.dip_StartScene = 1 - elseif (row == 6) and ((gameType == 0) or (gameType == 1) or (gameType == 4)) and (q.dip_Rewind ~= 2) then - q.dip_StartScene = serviceStep(q.dip_StartScene, step, 1, q.Level[q.dip_StartLevel][QTE.TOTALSCENES]) - elseif (row == 7) and (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then - if not q.ForceRewind then - q.dip_Rewind = serviceStep(q.dip_Rewind, step, 0, 3) - end - if q.dip_Rewind == 2 then - q.dip_StartScene = 1 - end - elseif row == 8 then - q.dip_CoinsPerCredit = serviceStep(q.dip_CoinsPerCredit, step, 0, 3) - elseif (row == 9) and (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then - q.dip_LivesPerCredit = serviceStep(q.dip_LivesPerCredit, step, 1, 5) - elseif (row == 10) and (gameType ~= 2) and (gameType ~= 3) and (gameType ~= 5) then - q.dip_LimitContinue = serviceStep(q.dip_LimitContinue, step, 0, 4) - elseif (row == 11) and (gameType ~= 5) then - q.dip_ShowAction = serviceStep(q.dip_ShowAction, step, 0, 5) - elseif row == 12 then - -- The games let the kid's type change its hints going back, not going on. - if (gameType ~= 1) and (gameType ~= 2) and (gameType ~= 5) and ((gameType ~= 4) or (step < 0)) then - q.dip_Hints = not q.dip_Hints - end - elseif (row == 13) and (gameType ~= 4) and (gameType ~= 5) then - q.dip_MashtoRun = serviceStep(q.dip_MashtoRun, step, 0, 3) - else - serviceLastRows(q, row, SERVICE_ROWS, "lvlState") - end -end - - --- The overlay laid out as the games lay theirs: the disc's own size, or half of it at the lower --- resolution (dip_Res); the HUD's pictures load again for it when it next draws, and whether --- the resolution has scanlines is known. -function Q.applyResolution(q) - local scan = io.open(q.MYDIR .. (((q.dip_Res or 0) == 0) and "/Overlay/" or "/Overlay/Lores/") .. "scanlines.png", "rb") - - if discGetWidth and (discGetWidth() > 0) then - if (q.dip_Res or 0) == 0 then - overlaySetResolution(discGetWidth(), discGetHeight()) - else - overlaySetResolution(discGetWidth() / 2, discGetHeight() / 2) - end - end - -- Scanlines are the resolution's own picture, where the game has one; once found, the games - -- keep saying they have them, at either resolution. - q.bScan = q.bScan or (scan ~= nil) - if scan then - scan:close() - end - q.hud = nil -end - - --- The extra clips' combinations: get-ready, the clip after a death, level clear; each pair; --- all three. -local SERVICE_EXTRAS = { [0] = { false, false, false }, { true, false, false }, { false, true, false }, { false, false, true }, { true, true, false }, { true, false, true }, { false, true, true }, { true, true, true } } - - --- The next combination of extra clips on (1) or back (-1) whose clips the disc has, round to none; --- the three show-flags follow it. -local function serviceExtras(q, step) - local has = { (q.offsetGetReadyEnd or 0) - (q.offsetGetReady or 0) > 1, (q.offsetSupDeathEnd or 0) - (q.offsetSupDeath or 0) > 1, (q.offsetClearend or 0) - (q.offsetClear or 0) > 1 } - local extras = q.dip_Extravid or 0 - - repeat - extras = serviceStep(extras, step, 0, 7) - local want = SERVICE_EXTRAS[extras] - until (extras == 0) or (((not want[1]) or has[1]) and ((not want[2]) or has[2]) and ((not want[3]) or has[3])) - q.dip_Extravid = extras - Q.applyExtras(q) -end - - --- The extra clips the setting turns on (dip_Extravid): the get-ready, the clip after a death, the --- level clear. The games read it again whenever they read their settings. -function Q.applyExtras(q) - local extras = SERVICE_EXTRAS[q.dip_Extravid or 0] or SERVICE_EXTRAS[0] - - q.ShowResurrect, q.ShowSupDeath, q.ShowLvlClear = extras[1], extras[2], extras[3] -end - - --- A text colour one step on or back, not the other colour: going on it steps past the other after --- wrapping (to 13, as the games let it), going back before wrapping. -local function serviceColour(q, name, other, step) - local colour = q[name] + step - - if step > 0 then - if colour > 12 then - colour = 1 - end - if colour == q[other] then - colour = colour + 1 - end - else - if colour == q[other] then - colour = colour - 1 - end - if colour < 1 then - colour = 12 - end - end - q[name] = colour - Q.fontColour(q, colour) -end - - --- The graphics screen's rows: the first goes to the performance screen on and the options back. -function Q.serviceChangeGraphics(q, row, step) - if row == 1 then - if step > 0 then - q.optSelP, q.altState2, q.currentLevel = 1, QTE.lvlRunning, QTE.levelPerf - else - q.optSel, q.lvlState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelService - end - elseif (row == 2) and (q.dip_GameType ~= 5) then - serviceExtras(q, step) - elseif (row >= 3) and (row <= 6) then - local flag = ({ "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop" })[row - 2] - - q[flag] = not q[flag] - elseif (row >= 7) and (row <= 9) then - local where = ({ "ScorePos", "LivPos", "CoinPos" })[row - 6] - - -- Laid out again when the game next starts, as the games lay it out. - q[where] = serviceStep(q[where], step, 1, 2) - elseif row == 10 then - q.fontType = serviceStep(q.fontType, step, 1, 2) - q.fontSize = (q.fontType == 1) and (q.fontSave or 0) or 0 - q.hud = nil - elseif (row == 11) and (q.fontType == 1) then - -- The size wraps at 8 going on and at -8 going back, to 0 either way. - q.fontSize = q.fontSize + step - if (q.fontSize > 8) or (q.fontSize < -8) then - q.fontSize = 0 - end - q.fontSave = q.fontSize - q.hud = nil - elseif row == 12 then - serviceColour(q, "mycolor1", "mycolor2", step) - elseif row == 13 then - serviceColour(q, "mycolor2", "mycolor1", step) - else - serviceLastRows(q, row, SERVICE_ROWS, "altState") - end -end - - --- The performance screen's machine settings, by model, going on and going back (the games set the --- third one's resolution differently the two ways). -local SERVICE_MODELS_ON = { [0] = { 0, 0, 0, 0, 3 }, { 1, 0, 0, 0, 2 }, { 0, 1, 0, 0, 1 }, { 1, 1, 1, 1, 1 } } -local SERVICE_MODELS_BACK = { [0] = { 0, 0, 0, 0, 3 }, { 1, 0, 0, 0, 2 }, { 1, 1, 0, 0, 1 }, { 1, 1, 1, 1, 1 } } -local SERVICE_PERF_ROWS = 10 - - --- The performance screen's rows: the first goes to the options on and the graphics back. -function Q.serviceChangePerformance(q, row, step) - if row == 1 then - if step > 0 then - q.optSel, q.lvlState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelService - else - q.optSelG, q.altState, q.currentLevel = 1, QTE.lvlRunning, QTE.levelGraphic - end - elseif row == 2 then - local model - - q.dip_Model = serviceStep(q.dip_Model or 0, step, 0, 3) - model = ((step > 0) and SERVICE_MODELS_ON or SERVICE_MODELS_BACK)[q.dip_Model] - q.dip_Res, q.dip_Display, q.dip_ScType, q.dip_LivType, q.dip_FontQ = model[1], model[2], model[3], model[4], model[5] - fontQuality(q.dip_FontQ) - Q.applyResolution(q) - elseif row == 3 then - q.dip_Res = serviceStep(q.dip_Res, step, 0, 1) - Q.applyResolution(q) - elseif row == 4 then - q.dip_Display = serviceStep(q.dip_Display, step, 0, 1) - elseif (row == 5) and q.bScan then - q.dip_Scanlines = serviceStep(q.dip_Scanlines or 0, step, 0, 2) - elseif row == 6 then - q.dip_ScType = serviceStep(q.dip_ScType, step, 0, 1) - elseif row == 7 then - q.dip_LivType = serviceStep(q.dip_LivType, step, 0, 1) - elseif row == 8 then - q.dip_FontQ = serviceStep(q.dip_FontQ, step, 1, 3) - fontQuality(q.dip_FontQ) - else - serviceLastRows(q, row, SERVICE_PERF_ROWS, "altState2") - end -end - - --- The settings the game shipped with (its default.cfg, the description's defaults), what the --- screen's "Default" restores, with the font quality and the resolution they set. -function Q.serviceDefaults(q) - for name, value in pairs(q.snapshot.defaults or q.snapshot.dips or {}) do - q[name] = value - end - for _, name in ipairs(QTE_CONFIG_FLAGS) do - q[name] = (q[name] == 1) or (q[name] == true) - end - if q.dip_FontQ then - fontQuality(q.dip_FontQ) - end - Q.applyExtras(q) - Q.applyResolution(q) -end - - --- A screen of options the shared way: on its state variable, its row, UP and DOWN between the --- rows, RIGHT or BUTTON1 to the next value and LEFT to the one before (a move holding off the --- next a quarter of a second), START to leave; a screen of buttonOnly takes BUTTON1 alone and --- no START. Leaving is the screen's own (leave), or the options' (serviceLeave). -function Q.doServiceScreen(q, screen) - local state = screen.state - local rows = screen.rows or (#screen.labels + 2) - local act = q.p1BUTTON1 or ((not screen.buttonOnly) and (q.p1RIGHT or q.p1LEFT)) - - if q[state] == QTE.lvlSetup then - if screen.setup then - screen.setup(q) - end - q[screen.row] = 1 - q[state] = QTE.lvlRunning - elseif q[state] == QTE.lvlRunning then - if act or q.p1DOWN or q.p1UP then - if q.bIgnoreJoy then - if Q.joyDelayDue(q) then - q.bIgnoreJoy = false - end - else - if q.p1BUTTON1 or (q.p1RIGHT and not screen.buttonOnly) then - q.p1BUTTON1, q.p1RIGHT = false, false - Q.sound(q, "sndcredit") - screen.change(q, q[screen.row], 1) - elseif q.p1LEFT and not screen.buttonOnly then - q.p1LEFT = false - Q.sound(q, "sndcredit") - screen.change(q, q[screen.row], -1) - else - q[screen.row] = serviceStep(q[screen.row], q.p1DOWN and 1 or -1, 1, rows) - q.p1DOWN, q.p1UP = false, false - Q.sound(q, "sndcoin") - end - q.bIgnoreJoy = true - Q.joyDelayON(q, SERVICE_REPEAT) - end - elseif q.p1START1 and not screen.buttonOnly then - q.p1START1 = false - q[state] = QTE.lvlEnd - end - elseif q[state] == QTE.lvlEnd then - (screen.leave or Q.serviceLeave)(q) - end -end - - --- Leaving an options screen: the settings kept; from play, back to where the game stood when --- nothing that sets up a game changed (the autosave), else a new game; else the games' start. -function Q.serviceLeave(q) - Q.configSave(q) - if q.bInPlayExit then - q.bInPlayExit = false - if (q.sLevel == q.dip_StartLevel) and (q.sScene == q.dip_StartScene) and (q.sMode == q.dip_PlayStyle) and (q.sType == q.dip_GameType) and (q.sLives == q.dip_LivesPerCredit) then - Q.loadSave(q, 4) - else - Q.applyResolution(q) - q.qteFns.startGame(q) - end - else - q.gameflow = "init" - end -end - - --- Leaving the save or exit screen: back to the game through the autosave, or the games' start. -local function serviceLeaveToGame(q) - if q.bInPlayExit then - q.bInPlayExit = false - Q.loadSave(q, 4) - else - q.gameflow = "init" - end -end - - --- The options screen's own start: the options still, the timer off, the HUD off, and from play --- where the game stood (so leaving can tell whether an option changed it). -local function serviceSetup(q) - discSkipToFrame(q.frameOptions) - discPause() - q.timerLimit = nil - q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowLives, q.bShowSkip = false, false, false, false, false - q.bShowCredits, q.bShowGet, q.bShowLCD, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false - if q.bInPlayExit then - q.sType, q.sMode, q.sLevel, q.sScene, q.sLives = q.dip_GameType, q.dip_PlayStyle, q.iLevel, q.iScene, q.dip_LivesPerCredit - q.dip_StartLevel, q.dip_StartScene = q.iLevel, q.iScene - end -end - - --- ===== The HUD of a quick-time-event game ==================================================== --- --- What the loop's flags say a player sees, drawn by the qteHud look from the loop's state: the --- score and the record, the lives or the life bar, and the rest as it comes. Laid out as the --- games lay it out: in lines and columns of an eightieth of the overlay, from the sizes of the --- game's own pictures (its Overlay folder, Overlay/Lores at the lower resolution) and its font. - -local HUD_LINES = 80 -- The overlay is laid out in eightieths, across and down. -local HUD_POINTS = 90 -- A font's size grows by the overlay's height over this. -local HUD_BLINK = 0.3 -- Seconds a blinking picture is on, then off. -local HUD_SPRITES = { - barFull = "barfull.png", - barNone = "barnone.png", - buttons = "buttons.png", - credit = "credit.png", - scanlines = "scanlines.png", - diskA = "diskA.png", - disk1 = "disk1.png", - disk2 = "disk2.png", - disk3 = "disk3.png", - freePlay = "freeplay.png", - getReady = "getready.png", - insertCoin = "insertcoin.png", - lives = "live.png", - noMoves = "nomoves.png", - scenes = "scene.png", - select = "arrowsel2.png", - selectLeft = "arrowsel1.png", - skip = "skip.png", - specials = "special.png", - stick = "stick.png", - topScore = "topscore.png" -} --- A move's prompt picture by its kind, in the games' numbering (the QTE table's). -local HUD_PROMPTS = { - [1] = "arrowup.png", [2] = "arrowdown.png", [3] = "arrowleft.png", [4] = "arrowright.png", - [5] = "action.png", [6] = "action2.png", [7] = "action3.png", [8] = "action4.png" -} - - --- A picture of the game's HUD, or nil when the game has none by that name. -local function hudSprite(h, file) - local handle = io.open(h.dir .. file, "rb") - - if handle == nil then - return nil - end - handle:close() - - return spriteLoad(h.dir .. file) -end - - --- The HUD's pictures, fonts, and layout, once, when it first draws. -function Q.hudLoad(q) - local h = { sprite = {}, number = {} } - local lowRes = (q.dip_Res or 0) ~= 0 - local points = (lowRes and ((q.fontSize or 0) / 2) or (q.fontSize or 0)) + overlayGetHeight() / HUD_POINTS - local font = q.MYDIR .. ((q.fontType == 1) and "/Fonts/font.ttf" or "/Fonts/hudDefault.ttf") - local zero = nil - - h.shown = {} - h.dir = q.MYDIR .. (lowRes and "/Overlay/Lores/" or "/Overlay/") - h.OVLW = overlayGetWidth() - h.OVLH = overlayGetHeight() - h.LINEW = h.OVLW / HUD_LINES - h.LINEH = h.OVLH / HUD_LINES - h.fontGame = fontLoad(font, points * 4) - h.fontLCD = fontLoad(font, points * 5) - h.fontBig = fontLoad(font, points * 6) - for name, file in pairs(HUD_SPRITES) do - h.sprite[name] = hudSprite(h, file) - end - for digit = 0, 9 do - h.number[digit] = hudSprite(h, string.format("num%02d.png", digit)) - end - h.prompt = {} - for kind, file in pairs(HUD_PROMPTS) do - h.prompt[kind] = hudSprite(h, file) - end - fontSelect(h.fontGame) - zero = fontToSprite("0") - h.RENDH = spriteGetHeight(zero) - spriteUnload(zero) - fontSelect(AUTHOR_FONT) - h.NUMW = h.number[0] and spriteGetWidth(h.number[0]) or 0 - h.NUMH = h.number[0] and spriteGetHeight(h.number[0]) or 0 - h.LIVW = h.sprite.lives and spriteGetWidth(h.sprite.lives) or 0 - h.LIVH = h.sprite.lives and spriteGetHeight(h.sprite.lives) or 0 - h.BARW = h.sprite.barFull and spriteGetWidth(h.sprite.barFull) or 0 - h.BARH = h.sprite.barFull and spriteGetHeight(h.sprite.barFull) or 0 - h.SKIPW = h.sprite.skip and spriteGetWidth(h.sprite.skip) or 0 - h.TEXTH = h.sprite.skip and spriteGetHeight(h.sprite.skip) or 0 - h.CRDW = h.sprite.credit and spriteGetWidth(h.sprite.credit) or 0 - h.SCNW = h.sprite.scenes and spriteGetWidth(h.sprite.scenes) or 0 - h.SELCW = h.sprite.select and spriteGetWidth(h.sprite.select) or 0 - h.TEXW = h.sprite.stick and spriteGetWidth(h.sprite.stick) or 0 - h.BUTW = h.prompt[5] and spriteGetWidth(h.prompt[5]) or 0 - h.BUTH = h.prompt[5] and spriteGetHeight(h.prompt[5]) or 0 - h.ARUW = h.prompt[1] and spriteGetWidth(h.prompt[1]) or 0 - h.ARUH = h.prompt[1] and spriteGetHeight(h.prompt[1]) or 0 - h.ARLW = h.prompt[3] and spriteGetWidth(h.prompt[3]) or 0 - h.ARLH = h.prompt[3] and spriteGetHeight(h.prompt[3]) or 0 - h.posxUI = (h.OVLW / 2) - (h.SKIPW / 2) - h.posxTEXT = (h.OVLW / 2) - (h.TEXW / 2) - h.posxBUTTONS = (h.OVLW / 2) - (h.BUTW / 2) - h.posyBUTTONS = (h.OVLH / 2) - (h.BUTH / 2) - h.posxUDARROWS = (h.OVLW / 2) - (h.ARUW / 2) - h.posyLRARROWS = (h.OVLH / 2) - (h.ARLH / 2) - -- The arrows slide in from 28 lines in to 8 lines from the edge, 32 and 12 across. - h.startyUARROW = h.LINEH * 28 - h.startyDARROW = h.OVLH - h.ARUH - h.LINEH * 28 - h.endyUARROW = h.LINEH * 8 - h.endyDARROW = h.OVLH - h.ARUH - h.LINEH * 8 - h.startxLARROW = h.LINEW * 32 - h.startxRARROW = h.OVLW - h.ARLW - h.LINEW * 32 - h.endxLARROW = h.LINEW * 12 - h.endxRARROW = h.OVLW - h.ARLW - h.LINEW * 12 - h.ycoinpos = (q.CoinPos == 1) and (h.OVLH / 2 - h.TEXTH / 2) or (h.OVLH - h.TEXTH - h.LINEH) - h.DISKW = h.sprite.disk1 and spriteGetWidth(h.sprite.disk1) or 0 - h.DISKH = h.sprite.disk1 and spriteGetHeight(h.sprite.disk1) or 0 - h.ydskpos = (q.LivPos == 1) and (h.OVLH - h.DISKH - h.LINEH) or h.LINEH - -- Lives at the top or the bottom (LivPos), the score and the level name opposite (ScorePos). - if q.LivPos == 1 then - h.ylivpos = h.LINEH - elseif q.dip_GameType == 1 then - h.ylivpos = h.OVLH - h.BARH - h.LINEH - else - h.ylivpos = h.OVLH - h.LIVH - h.LINEH - end - if q.ScorePos == 1 then - h.yscopos = h.LINEH - h.ylvlpos = h.OVLH - h.RENDH - h.LINEH - else - h.yscopos = h.OVLH - h.NUMH - h.LINEH - h.ylvlpos = h.LINEH - end - q.hud = h -end - - --- A number in the game's digit pictures, or in its font when the dips say so (dip_ScType). -function Q.hudNumber(q, x, y, amount) - local h = q.hud - local text = tostring(math.floor(amount)) - - if (q.dip_ScType or 0) == 0 then - for k = 1, #text do - local digit = h.number[tonumber(text:sub(k, k))] - - if digit then - spriteDraw(digit, x + (k - 1) * h.NUMW, y) - end - end - else - fontSelect(h.fontGame) - fontPrint(x, y, text) - fontSelect(AUTHOR_FONT) - end -end - - --- The score, and the record over the middle when the loop shows it (or the game is paused). -function Q.hudScore(q) - local h = q.hud - - Q.hudNumber(q, h.LINEW, h.yscopos, q.iScore or 0) - if (q.bShowTop or q.bPause) and h.sprite.topScore then - spriteDraw(h.sprite.topScore, h.posxUI, h.LINEH) - if q.dip_GameType == 3 then - Q.hudNumber(q, (h.OVLW / 2) - h.NUMW, h.LINEH * 3, q.iTop or 0) - else - Q.hudNumber(q, (h.OVLW / 2) - h.NUMW * 3, h.LINEH * 3, q.iTop or 0) - end - end -end - - --- The lives right to left from the right edge, or the life bar in the life-bar game type. -function Q.hudLives(q) - local h = q.hud - - if q.dip_GameType == 1 then - local size = q.BarSize or 0 - local left = h.OVLW - (h.LINEW * (2 + (q.dip_Res or 0))) - (h.BARW * (size + 1)) - - if (q.iLifeBar or 0) > 0 then - for k = 1, q.iLifeBar do - spriteDraw(h.sprite.barFull, left + (h.BARW + 2) * k, h.ylivpos) - end - if q.iLifeBar < size then - for k = q.iLifeBar, size do - spriteDraw(h.sprite.barNone, left + (h.BARW + 2) * k, h.ylivpos) - end - end - end - elseif (q.iLives or 0) > 0 then - if (q.dip_LivType or 0) == 0 then - for k = 1, q.iLives do - spriteDraw(h.sprite.lives, h.OVLW - h.LINEW - h.LIVW * k, h.ylivpos) - end - else - spriteDraw(h.sprite.lives, h.OVLW - 2 * h.LINEW - h.LIVW * 2, h.ylivpos) - fontSelect(h.fontGame) - fontPrint(h.OVLW - h.LINEW - h.LIVW, h.ylivpos + h.LIVH / 4, "x" .. q.iLives) - fontSelect(AUTHOR_FONT) - end - end -end - - --- Whether a blinking picture is on this frame, counted from when it appeared, on first: the --- engine's clock, where the games blink on the CPU's, so a blink's phase can drift from an --- original's. Q.hudDraw forgets when a picture went away, so its next showing starts on. -local function hudBlinkOn(h, name, seconds) - local now = singeGetTicks() - - h.shown[name] = h.shown[name] or now - - return math.floor((now - h.shown[name]) / (seconds * 1000)) % 2 == 0 -end - - --- Text centred across the overlay, in the font selected. -local function hudMiddle(h, text) - local sprite = fontToSprite(text) - local x = h.OVLW / 2 - spriteGetWidth(sprite) / 2 - - spriteUnload(sprite) - - return x -end - - --- The level's title on its first scene (numbered but for the secret level), then the scene's --- number, while the level shows (ShowLevel; the loop turns bShowLvl off past the first move). -function Q.hudLevel(q) - local h = q.hud - local level = q.Level[q.iLevel] - - if (not q.ShowLevel) or (level == nil) then - return - end - fontSelect(h.fontGame) - if q.iScene == 1 then - fontPrint(h.LINEW, h.ylvlpos, (q.iLevel == q.levelSecret) and level[QTE.TITLE] or (q.iLevel .. ". " .. level[QTE.TITLE])) - elseif h.sprite.scenes then - spriteDraw(h.sprite.scenes, h.LINEW, h.ylvlpos) - Q.hudNumber(q, h.LINEW + h.SCNW + h.NUMW, h.ylvlpos, q.iScene or 0) - end - fontSelect(AUTHOR_FONT) -end - - --- FREE PLAY, the credits, or INSERT COIN, blinking. -function Q.hudCredits(q) - local h = q.hud - - if not hudBlinkOn(h, "credits", HUD_BLINK) then - return - end - if q.dip_CoinsPerCredit == q.DOPT_FREEPLAY then - spriteDraw(h.sprite.freePlay, h.posxUI, h.ycoinpos) - elseif (q.iCredits or 0) > 0 then - spriteDraw(h.sprite.credit, (h.OVLW / 2) - h.CRDW / 2 - h.NUMW, h.ycoinpos) - Q.hudNumber(q, (h.OVLW / 2) - h.CRDW / 2 + h.CRDW, h.ycoinpos, q.iCredits) - else - spriteDraw(h.sprite.insertCoin, h.posxUI, h.ycoinpos) - end -end - - --- GET READY, blinking, when the game has the picture. -function Q.hudGetReady(q) - local h = q.hud - - if h.sprite.getReady and hudBlinkOn(h, "getReady", HUD_BLINK) then - spriteDraw(h.sprite.getReady, h.posxTEXT, h.posyBUTTONS) - end -end - - --- The skip prompt over a clip a button skips, blinking. -function Q.hudSkip(q) - local h = q.hud - - if h.sprite.skip and hudBlinkOn(h, "skip", HUD_BLINK + 0.1) then - spriteDraw(h.sprite.skip, h.posxUI, h.OVLH - h.TEXTH - h.LINEH) - end -end - - --- A choice move's menu: TIMESTOP, SELECT YOUR ACTION, the options in the loop's shuffled order, --- and the selector at the one chosen. -function Q.hudChoose(q) - local h = q.hud - local m = q.move[q.currentMove] - local count = m and m[QTE.moveDeath] or 0 - local x = h.OVLW / 2.5 - - if (q.optorder == nil) or (count < 2) then - return - end - fontSelect(h.fontLCD) - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, "TIMESTOP"), h.OVLH / 5, "TIMESTOP") - Q.fontColour(q, q.mycolor1) - fontPrint(hudMiddle(h, "SELECT YOUR ACTION"), h.OVLH / 5 + h.LINEH * 3, "SELECT YOUR ACTION") - Q.fontColour(q, q.mycolor2) - for row = 1, math.min(count, 4) do - local option = q.choice[q.optorder[row]] - local y = h.OVLH / 3 + h.LINEH * 6 * (row - 1) - - if option then - fontPrint(x, y, option[1]) - end - if (q.iChoice == row) and h.sprite.select then - spriteDraw(h.sprite.select, x - h.SELCW - 10, y + 5) - end - end - fontSelect(AUTHOR_FONT) -end - - --- PAUSED, large, over the middle. -function Q.hudPause(q) - local h = q.hud - - fontSelect(h.fontBig) - fontPrint(hudMiddle(h, "PAUSED"), h.OVLH / 2 - 3 * h.LINEH, "PAUSED") - fontSelect(AUTHOR_FONT) -end - - --- The prompt for the move waited on, by the prompt setting (dip_ShowAction): none, a sound only --- (the loop plays it), the nomoves picture, the kind of input (stick, buttons, both, specials), --- or the input itself (4, and 5 with the sound too) -- a direction's arrow sliding in from the --- middle, a button's picture. The compound kinds (mashes, holds, runs, the gauge, timed moves) --- are not drawn yet: their pictures differ between the framework's versions. -function Q.hudAction(q) - local h = q.hud - local m = q.move[q.currentMove] - local kind = m and m[QTE.correctMove] - local show = q.dip_ShowAction or 0 - - if (kind == nil) or ((q.currentMove or 0) > (q.totalMoves or 0)) then - return - end - -- A move's arrows start over from the middle each time its prompt shows (a new move, or the - -- same one again after a death), and slide out a line a frame. - if h.actionMove ~= q.currentMove then - h.actionMove = q.currentMove - h.posyUARROW, h.posyDARROW = h.startyUARROW, h.startyDARROW - h.posxLARROW, h.posxRARROW = h.startxLARROW, h.startxRARROW - end - if ((show == 0) and (kind ~= q.SKIP)) or ((show == 1) and (kind < q.CHOOSE)) then - return - elseif (show == 2) and (kind < q.CHOOSE) then - spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS) - elseif (show == 3) and (kind < q.CHOOSE) then - if ((kind >= 1) and (kind <= 4)) or ((kind >= 9) and (kind <= 12)) then - spriteDraw(h.sprite.stick, h.posxTEXT, h.posyBUTTONS) - elseif (kind >= 5) and (kind <= 8) then - spriteDraw(h.sprite.buttons, h.posxTEXT, h.posyBUTTONS) - elseif (kind >= 13) and (kind <= 16) then - spriteDraw(h.sprite.buttons, h.posxTEXT, h.posyBUTTONS + h.BUTH / 2) - spriteDraw(h.sprite.stick, h.posxTEXT, h.posyBUTTONS - h.BUTH / 2) - elseif kind >= 17 then - spriteDraw(h.sprite.specials, h.posxTEXT, h.posyBUTTONS + h.LINEH) - end - elseif (kind >= q.UP) and (kind <= q.RIGHT) then - if kind == q.UP then - h.posyUARROW = math.max(h.endyUARROW, h.posyUARROW - h.LINEH) - spriteDraw(h.prompt[kind], h.posxUDARROWS, h.posyUARROW) - elseif kind == q.DOWN then - h.posyDARROW = math.min(h.endyDARROW, h.posyDARROW + h.LINEH) - spriteDraw(h.prompt[kind], h.posxUDARROWS, h.posyDARROW) - elseif kind == q.LEFT then - h.posxLARROW = math.max(h.endxLARROW, h.posxLARROW - h.LINEW) - spriteDraw(h.prompt[kind], h.posxLARROW, h.posyLRARROWS) - else - h.posxRARROW = math.min(h.endxRARROW, h.posxRARROW + h.LINEW) - spriteDraw(h.prompt[kind], h.posxRARROW, h.posyLRARROWS) - end - elseif (kind == q.CHOOSE) or (kind == q.YESNO) then - -- A choice is the button; a yes or no, the button over "no moves". - spriteDraw(h.prompt[q.BUTTON1], h.posxBUTTONS, h.posyBUTTONS) - if kind == q.YESNO then - spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS + h.LINEH + h.BUTH) - end - elseif kind == q.PATH then - spriteDraw(h.sprite.noMoves, h.posxTEXT, h.posyBUTTONS) - elseif h.prompt[kind] then - spriteDraw(h.prompt[kind], h.posxBUTTONS, h.posyBUTTONS) - end -end - - --- A board's rows under its mode title: the rank, the name, and the score at fractions of the --- overlay's width (the rank's shifts for "1." and "10."), the first row in the second colour. -local BOARD_COLUMNS = { rank = { 2.56, 2.64, 2.73 }, name = 2.37, score = 1.92 } --- The three boards of the attract loop's rankings side by side, at their own fractions. -local BOARDS_ALL = { - { board = "lifeBar", rank = { 21.8, 29.4, 43.64 }, name = 13.33, score = 5.69 }, - { board = "normal", rank = { 2.56, 2.64, 2.73 }, name = 2.37, score = 1.92 }, - { board = "survivor", rank = { 1.34, 1.36, 1.37 }, name = 1.28, score = 1.13 } -} -local BOARD_TITLES = { [1] = "LIFE BAR MODE", [3] = "SURVIVOR" } - - --- Ten rows of a board at the columns given, from the row the games start a board on. -local function hudBoardRows(q, board, columns) - local h = q.hud - - for k = 1, 10 do - local entry = board[k] - local rank = (k == 1) and columns.rank[1] or ((k == 10) and columns.rank[3] or columns.rank[2]) - local y = h.OVLH / 4.59 + k * h.LINEH * 5 - - Q.fontColour(q, (k == 1) and q.mycolor2 or q.mycolor1) - fontPrint(h.OVLW / rank, y, k .. ".") - if entry then - fontPrint(h.OVLW / columns.name, y, tostring(entry[1])) - fontPrint(h.OVLW / columns.score, y, tostring(entry[2])) - end - end -end - - --- RANKINGS over a board, when the game shows its titles (ShowTitle). -local function hudRankingsTitle(q) - local h = q.hud - - if q.ShowTitle then - fontSelect(h.fontBig) - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, "RANKINGS"), h.LINEH * 4, "RANKINGS") - end -end - - --- The game type's board after a result went onto it: its mode's title and its ten rows. -function Q.hudBoard(q) - local h = q.hud - local title = BOARD_TITLES[q.dip_GameType] or "NORMAL MODE" - - hudRankingsTitle(q) - fontSelect(h.fontLCD) - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, title), h.LINEH * 11, title) - hudBoardRows(q, Q.boardFor(q), BOARD_COLUMNS) -end - - --- The attract loop's rankings: the three boards side by side under their headings (their places --- as the games set them, wider screens a little further out). -function Q.hudBoardsAll(q) - local h = q.hud - local wide = h.OVLW / h.OVLH > 1.7 - - hudRankingsTitle(q) - fontSelect((h.OVLW >= 1920) and h.fontLCD or h.fontGame) - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, "NORMAL"), h.LINEH * 15, "NORMAL") - fontPrint(h.OVLW / (wide and 10.58 or 11.9), h.LINEH * 15, "LIFE BAR") - fontPrint(h.OVLW / (wide and 1.28 or 1.34), h.LINEH * 15, "SURVIVOR") - for _, columns in ipairs(BOARDS_ALL) do - hudBoardRows(q, q.boards[columns.board], columns) - end -end - - --- The name being spelled, the letter grid with its cursor blinking, and the titles. -function Q.hudNameEntry(q) - local h = q.hud - local spelled = "" - - if q.ShowTitle then - Q.fontColour(q, q.mycolor2) - fontSelect(h.fontBig) - fontPrint(hudMiddle(h, "HIGH SCORE!!"), h.LINEH * 4, "HIGH SCORE!!") - fontSelect(h.fontGame) - fontPrint(hudMiddle(h, "PLEASE ENTER YOUR NAME"), h.OVLH - h.LINEH * 4, "PLEASE ENTER YOUR NAME") - end - for k = 1, 3 do - spelled = spelled .. ((k <= #q.sName) and q.sName:sub(k, k) or "_") .. " " - end - fontSelect(h.fontLCD) - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, spelled), h.OVLH / 4, spelled) - for k = 1, NAME_END do - local column = (k - 1) % NAME_ROW - local x = (h.OVLW - 33 * h.LINEW) / 2 + column * h.LINEW * 4 - local y = h.OVLH / 3 + math.floor((k - 1) / NAME_ROW) * h.LINEH * 5 - - Q.fontColour(q, ((k == q.iCursor) and hudBlinkOn(h, "cursor", HUD_BLINK)) and q.mycolor2 or q.mycolor1) - if k < NAME_CLEAR then - fontPrint(x, y, NAME_LETTERS:sub(k, k)) - elseif k == NAME_CLEAR then - fontPrint(x - h.LINEW, y, "CLR") - else - fontPrint(x + h.LINEW * 2, y, "END") - end - end -end - - --- The attract loop's best percent of each level, three levels a row, each row centred as the --- games centre it (on a row of three of the widest they allow for, whatever it holds). -function Q.hudPercent(q) - local h = q.hud - local levels = #q.boards.percent - local step = (levels > 11) and 3 or 5.5 - local widest = { "L2: BAB 99 (E)", "L2: BAB 99 (E) L2: BAB 99 (E)", "L2: BAB 99 (E) L2: BAB 99 (E) L2: BAB 99 (E)" } - - hudRankingsTitle(q) - if levels > 11 then - fontSelect((h.OVLW >= 1920) and h.fontLCD or h.fontGame) - else - fontSelect((h.OVLW >= 1920) and h.fontBig or h.fontGame) - end - Q.fontColour(q, q.mycolor2) - fontPrint(hudMiddle(h, "BEST PERCENT"), h.LINEH * 11, "BEST PERCENT") - Q.fontColour(q, q.mycolor1) - for first = 1, levels, 3 do - local cells = {} - - for k = first, math.min(first + 2, levels) do - local best = q.boards.percent[k] - - cells[#cells + 1] = "L" .. k .. ": " .. best[1] .. " " .. best[2] .. " (" .. best[3] .. ")" - end - fontPrint(hudMiddle(h, widest[#cells]), h.OVLH / 5 + first * h.LINEH * step, table.concat(cells, " ")) - end -end - - --- The value a row of the options screen shows, "N/A" where the game type or play style rules --- the option out, as the games print them. -local function serviceValue(q, row) - local gameType = q.dip_GameType - local style = q.dip_PlayStyle - - if row == 2 then - return SERVICE_GAME_TYPES[gameType] - elseif row == 3 then - return (gameType == 5) and "N/A" or SERVICE_DIFFICULTY[q.dip_Diffshow] - elseif row == 4 then - return ((gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or SERVICE_STYLES[style] - elseif row == 5 then - return ((style > 0) or (gameType == 3) or (gameType == 5)) and "N/A" or (q.dip_StartLevel .. ". " .. q.Level[q.dip_StartLevel][QTE.TITLE]) - elseif row == 6 then - return ((gameType == 2) or (gameType == 3) or (gameType == 5) or (style > 0)) and "N/A" or tostring(q.dip_StartScene) - elseif row == 7 then - return ((gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or SERVICE_REWINDS[q.dip_Rewind] - elseif row == 8 then - return (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY) and "Free Play" or tostring(q.dip_CoinsPerCredit) - elseif row == 9 then - return ((gameType == 1) or (gameType == 2) or (gameType == 5)) and "N/A" or ((gameType == 3) and "1" or tostring(q.dip_LivesPerCredit)) - elseif row == 10 then - if (gameType == 2) or (gameType == 5) then - return "N/A" - elseif (gameType == 3) or (q.dip_LimitContinue == 0) then - return "None" - end - return (q.dip_LimitContinue == 4) and "Unlimited" or tostring(q.dip_LimitContinue) - elseif row == 11 then - return (gameType == 5) and "N/A" or SERVICE_PROMPTS[q.dip_ShowAction] - elseif row == 12 then - return ((gameType == 1) or (gameType == 2) or (gameType == 3) or (gameType == 5)) and "N/A" or (q.dip_Hints and "Yes" or "No") - elseif row == 13 then - return ((gameType == 4) or (gameType == 5)) and "N/A" or SERVICE_MASHES[q.dip_MashtoRun] - end - - return nil -end - - -local SERVICE_EXTRA_NAMES = { [0] = "None", "Ready", "Death", "Clear", "Ready/Death", "Ready/Clear", "Clear/Death", "All" } -local SERVICE_COLOURS = { "WHITE", "RED", "BLUE", "YELLOW", "GREEN", "ORANGE", "PINK", "PURPLE", "LIGHTBLUE", "GREY", "BLACK", "MISC" } - - --- The value a row of the graphics screen shows. -local function serviceValueGraphics(q, row) - if row == 2 then - return (q.dip_GameType == 5) and "N/A" or SERVICE_EXTRA_NAMES[q.dip_Extravid or 0] - elseif (row >= 3) and (row <= 6) then - return q[({ "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop" })[row - 2]] and "Yes" or "No" - elseif row == 7 then - return (q.ScorePos == 1) and "TOP" or "BOTTOM" - elseif row == 8 then - return (q.LivPos == 1) and "TOP" or "BOTTOM" - elseif row == 9 then - return (q.CoinPos == 1) and "MIDDLE" or "BOTTOM" - elseif row == 10 then - return (q.fontType == 1) and "GAME" or "DEFAULT" - elseif row == 11 then - return tostring(q.fontSize) - elseif row == 12 then - return SERVICE_COLOURS[q.mycolor1] - elseif row == 13 then - return SERVICE_COLOURS[q.mycolor2] - end - - return nil -end - - --- The value a row of the performance screen shows. -local function serviceValuePerformance(q, row) - if row == 2 then - return ({ [0] = "Full", "Medium", "Low", "Lowest" })[q.dip_Model or 0] - elseif row == 3 then - return (q.dip_Res == 0) and "Hi" or "Lo" - elseif row == 4 then - return (q.dip_Display == 0) and "Full" or "Basic" - elseif row == 5 then - return q.bScan and ({ [0] = "No", "Over text", "Under text" })[q.dip_Scanlines or 0] or "N/A" - elseif (row == 6) or (row == 7) then - return ((row == 6) and q.dip_ScType or q.dip_LivType) == 0 and "PNG" or "Rendered" - elseif row == 8 then - return ({ "Solid", "Shaded", "Blended" })[q.dip_FontQ] - end - - return nil -end - - --- The save screen's rows: load or save, the slot (the autosave loads only), PROCEED, and EXIT. A --- save made here keeps the lives the level began with, and the game type as the life bar or not, --- and is made only when the score has not fallen since the level began (as the games save). -function Q.serviceChangeSave(q, row, step) - if row == 1 then - q.dip_Loadsave = serviceStep(q.dip_Loadsave or 0, step, 0, 1) - if q.dip_Loadsave == 1 then - q.dip_Slot = 1 - end - elseif row == 2 then - -- Stepping on to the autosave turns saving off; stepping back round to it does not. - q.dip_Slot = (q.dip_Slot or 1) + step - if q.dip_Slot == 4 then - q.dip_Loadsave = 0 - end - q.dip_Slot = serviceStep(q.dip_Slot, 0, 1, 4) - elseif row == 3 then - if (q.dip_Loadsave or 0) == 0 then - Q.loadSave(q, q.dip_Slot or 1) - else - if q.iScore >= (q.iScoreSave or 0) then - Q.saveWrite(q, q.dip_Slot or 1, { q.iLevel, q.iScene, q.bCheckMove and (q.currentMove + 1) or q.currentMove, (q.dip_GameType == 1) and q.iLifeBar or q.iLiveSave, q.dip_Difficulty, q.iScore, (q.dip_GameType == 1) and 1 or 0, q.iScPlayed, q.iScDeath, q.iTotDeath, q.dip_PlayStyle }) - end - q.lvlState = QTE.lvlEnd - end - elseif row == 4 then - q.lvlState = QTE.lvlEnd - end -end - - --- The save screen's own start: its still, the timer and the HUD off. -local function saveScreenSetup(q) - discSkipToFrame(q.frameSave) - discPause() - q.timerLimit = nil - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowScene, q.bShowSkip = false, false, false, false, false - q.bShowCredits, q.bShowLCD, q.bShowGet, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false -end - - --- The exit screen's rows: QUIT ends Singe, BACK goes back to the game, INTRO to the attract loop, --- OPTIONS to the options screen. -function Q.serviceChangeExit(q, row) - if row == 1 then - singeQuit() - elseif row == 2 then - q.lvlState = QTE.lvlEnd - elseif row == 3 then - q.bInPlayExit = false - q.lvlState = QTE.lvlEnd - else - q.bInPlayExit = false - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelService - end -end - - --- The exit screen's own start: the hints still, the timer and the HUD off. -local function exitScreenSetup(q) - discSkipToFrame(q.frameHints) - discPause() - q.timerLimit = nil - q.bShowLvl, q.bShowScene, q.bShowScore, q.bShowLives, q.bShowSkip = false, false, false, false, false - q.bShowCredits, q.bShowGet, q.bShowLCD, q.bShowAction, q.bIgnoreJoy = false, false, false, false, false -end - - --- The service screens, as the shared loop and printer read them: the state and row they keep, the --- title row between arrows, the labels of the rows between it and the last two, how a row shows --- its value, and how it changes; the save and exit screens have rows of their own and draw --- themselves. -local SERVICE_SCREENS = { - [QTE.levelService] = { state = "lvlState", row = "optSel", setup = serviceSetup, labels = SERVICE_LABELS, value = serviceValue, change = function(q, row, step) Q.serviceChange(q, row, step) end }, - [QTE.levelGraphic] = { state = "altState", row = "optSelG", labels = { "Graphic Options", "Extra Movies", "Show Titles", "Show LCD", "Show Level", "Show Top Score", "Score Position", "Lives Position", "Coin Position", "Font", "Adjust Font Size", "Font Color", "Highlight Color" }, value = serviceValueGraphics, change = function(q, row, step) Q.serviceChangeGraphics(q, row, step) end }, - [QTE.levelPerf] = { state = "altState2", row = "optSelP", labels = { "Performances", "Configurations", "Overlay Res", "Display Scoring", "Scanlines", "Score Type", "Lives Type", "Font Quality" }, value = serviceValuePerformance, change = function(q, row, step) Q.serviceChangePerformance(q, row, step) end }, - [QTE.levelSave] = { state = "lvlState", row = "optSel", rows = 4, setup = saveScreenSetup, leave = serviceLeaveToGame, change = function(q, row, step) Q.serviceChangeSave(q, row, step) end, draw = function(q) Q.hudSave(q) end }, - [QTE.levelExit] = { state = "lvlState", row = "optSel", rows = 4, buttonOnly = true, setup = exitScreenSetup, leave = serviceLeaveToGame, change = function(q, row) Q.serviceChangeExit(q, row) end, draw = function(q) Q.hudExit(q) end } -} - - --- A screen of options: its title, the rows with the one chosen in the second colour, the first --- between arrows, the last two centred low (at the same height whatever the screen's length). -function Q.hudService(q, screen) - local h = q.hud - local rows = #screen.labels + 2 - local last = q.bInPlayExit and { "Restart", "BACK TO GAME" } or { "Default", "EXIT" } - - if q.ShowTitle then - Q.fontColour(q, q.mycolor2) - fontSelect(h.fontBig) - fontPrint(hudMiddle(h, "OPTIONS MENU"), h.LINEH * 2, "OPTIONS MENU") - end - fontSelect(h.fontGame) - for row = 1, rows do - local y = h.OVLH / 7.5 + (row - 1) * h.LINEH * 4.2 - local value = screen.value(q, row) - - Q.fontColour(q, (row == q[screen.row]) and q.mycolor2 or q.mycolor1) - if row == 1 then - spriteDraw(h.sprite.selectLeft, h.OVLW / 3 - h.SELCW, y) - spriteDraw(h.sprite.select, 2 * h.OVLW / 3, y) - fontPrint(hudMiddle(h, screen.labels[1]), y, screen.labels[1]) - elseif row >= rows - 1 then - local label = last[row - rows + 2] - - fontPrint(hudMiddle(h, label), h.OVLH / 8 + (row - rows + 14.5) * h.LINEH * 4.2, label) - else - fontPrint(h.OVLW / 4.8, y, screen.labels[row]) - end - if value then - fontPrint(h.OVLW / 1.6, y, value) - end - end -end - - --- A slot as the save screen lists it: empty, or the level, the scene (or the map), the move, the --- lives (or the bar), the difficulty, and the score; the fourth is the autosave, "A". -local function saveLine(q, k) - local save = q.saves[k] - local line = save and save.line or { 1, 1, 1 } - local label = (k < 4) and tostring(k) or "A" - local scene = tonumber(line[2]) or 0 - - if (tonumber(line[1]) == 1) and ((scene == 1) or (scene == 0)) and (tonumber(line[3]) == 1) then - return label .. ". Empty" - end - - return label .. ". Lvl:" .. line[1] .. ((scene <= 0) and ", Map" or (", Scn:" .. line[2])) .. ", Mov:" .. line[3] .. ((tonumber(line[7]) == 5) and ", Bar:" or ", Liv:") .. line[4] .. ", " .. (SERVICE_DIFFICULTY[tonumber(line[5])] or "") .. ", " .. line[6] -end - - --- The save screen: its title, the four slots, and LOAD or SAVE, the slot, PROCEED, and EXIT (or --- BACK TO GAME) with the one chosen in the second colour. -function Q.hudSave(q) - local h = q.hud - local slots = { "SLOT #1", "SLOT #2", "SLOT #3", "AUTOSAVE" } - local rows = { ((q.dip_Loadsave or 0) == 0) and "LOAD" or "SAVE", slots[q.dip_Slot or 1], "PROCEED", q.bInPlayExit and "BACK TO GAME" or "EXIT" } - - if q.ShowTitle then - Q.fontColour(q, q.mycolor2) - fontSelect(h.fontBig) - fontPrint(hudMiddle(h, "LOAD/SAVE MENU"), h.LINEH * 2, "LOAD/SAVE MENU") - end - Q.fontColour(q, q.mycolor2) - fontSelect(h.fontGame) - for k = 1, 4 do - fontPrint(h.OVLW / 6.55, h.OVLH / 5 + k * h.LINEH * 5, saveLine(q, k)) - end - fontSelect(h.fontLCD) - for k = 1, 4 do - Q.fontColour(q, (k == q.optSel) and q.mycolor2 or q.mycolor1) - fontPrint(hudMiddle(h, rows[k]), h.OVLH / 5 + (k + 6) * h.LINEH * 5, rows[k]) - end -end - - --- The exit screen: QUIT GAME? over QUIT, BACK, INTRO, and OPTIONS, the one chosen in the second --- colour. -function Q.hudExit(q) - local h = q.hud - local rows = { "QUIT", "BACK", "INTRO", "OPTIONS" } - - Q.fontColour(q, q.mycolor2) - fontSelect(h.fontBig) - fontPrint(hudMiddle(h, "QUIT GAME?"), h.LINEH * 2, "QUIT GAME?") - for k = 1, 4 do - Q.fontColour(q, (k == q.optSel) and q.mycolor2 or q.mycolor1) - fontPrint(hudMiddle(h, rows[k]), h.OVLH / 1.8 + k * h.LINEH * 7, rows[k]) - end -end - - --- What a screen draws of its own, before the HUD: the name's grid and the board of the high --- score screen, the three boards of the attract loop's rankings. -function Q.hudScreen(q) - if q.currentLevel == QTE.levelHighScore then - if (q.lvlState == QTE.lvlRunning) and Q.option(q, "nameEntry", true) then - Q.hudNameEntry(q) - elseif q.lvlState == QTE.branch02 then - Q.hudBoard(q) - end - elseif (q.currentLevel == QTE.levelIntro) and (q.lvlState == QTE.branch05) then - Q.hudBoardsAll(q) - elseif (q.currentLevel == QTE.levelIntro) and (q.lvlState == QTE.branch10) then - Q.hudPercent(q) - elseif SERVICE_SCREENS[q.currentLevel] and (q[SERVICE_SCREENS[q.currentLevel].state] == QTE.lvlRunning) then - local screen = SERVICE_SCREENS[q.currentLevel] - - if screen.draw then - screen.draw(q) - else - Q.hudService(q, screen) - end - end - fontSelect(AUTHOR_FONT) -end - - --- The HUD, in the games' order, over whatever the screen itself drew; none while the title clip --- plays and the game starts, as the games draw theirs only once it runs. -function Q.hudDraw(q) - if q.gameflow ~= "running" then - return - end - if q.hud == nil then - Q.hudLoad(q) - end - local h = q.hud - - -- Scanlines under the text (dip_Scanlines 2) or over it (1). - if (q.dip_Scanlines == 2) and h.sprite.scanlines then - spriteDraw(h.sprite.scanlines, 0, 0) - end - Q.hudScreen(q) - if q.bShowAction then - Q.hudAction(q) - else - q.hud.actionMove = nil - end - if q.bShowScore then - Q.hudScore(q) - end - if q.bShowLives then - Q.hudLives(q) - end - if q.bShowCredits then - Q.hudCredits(q) - else - q.hud.shown.credits = nil - end - if q.bShowSkip then - Q.hudSkip(q) - else - q.hud.shown.skip = nil - end - if q.diskVisible and h.sprite["disk" .. q.diskVisible] then - spriteDraw(h.sprite["disk" .. q.diskVisible], h.OVLW - h.DISKW - h.LINEW, h.ydskpos) - end - if q.bShowChoices then - Q.hudChoose(q) - end - if q.bPause then - Q.hudPause(q) - end - if q.bShowGet then - Q.hudGetReady(q) - else - q.hud.shown.getReady = nil - end - if q.bShowLvl then - Q.hudLevel(q) - end - if (q.dip_Scanlines == 1) and h.sprite.scanlines then - spriteDraw(h.sprite.scanlines, 0, 0) - end - colorForeground(255, 255, 255) -end - - --- The games' font colours (their toolbox's setFontColor), for the loops that do not --- load that file; a game that defines its own keeps it. -local FONT_COLOURS = { { 255, 255, 255 }, { 255, 0, 0 }, { 0, 0, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 255, 150, 0 }, { 252, 0, 148 }, { 127, 0, 255 }, { 30, 160, 250 }, { 128, 128, 128 }, { 1, 0, 0 } } - -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 settings (the gun games). - 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, - -- Every old form of it, as the engine's Framework.singe takes them: (x, y, frame, sprite), - -- (x, y, scale, frame, sprite), or (x, y, scaleX, scaleY, frame, sprite). The sprite is last, - -- the frame before it, and whatever sits between the position and the frame is the scale. - spriteDrawFrame = function(...) - local args = { ... } - local handle = table.remove(args) - local frame = table.remove(args) - local x = args[1] - local y = args[2] - local scale = { table.unpack(args, 3) } - - if isHandle(handle) and (type(frame) == "number") and (type(x) == "number") and (type(y) == "number") then - Q.queueDraw(q, function() spriteDrawFrame(handle, x, y, math.max(1, math.floor(frame)), table.unpack(scale)) end) - end - end, - fontPrint = function(x, y, text) - 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: the engine's spriteLoadFrames(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, - 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)) - -- The engine's Framework.singe wraps the sprite calls for a game that asks for the - -- old argument order. The shim's spriteDraw and spriteDrawFrame take that order - -- already, so those two wrappers would convert twice and make a y a sprite handle; - -- they go, and the shim's own are found again. The rest of the wrappers stay. - if (type(name) == "string") and name:lower():match("^singe[/\\]framework%.singe$") then - rawset(q, "spriteDraw", nil) - rawset(q, "spriteDrawFrame", nil) - end - end - end, - -- The port keeps no saves and reads no config of its own: a game's save, config, and - -- board files come from the description, so its file calls find nothing and write - -- nothing (lfs.attributes says the folder is not there). A read is the exception: a - -- game that reads its shipped config as its script loads (two of the gun games do) would - -- end its load on the empty answer, so a file that is there is - -- read; what it sets, the description sets again after the load. - require = function(name) - if name == "lfs" then - 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 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 (one title's does), and loading the built one here would be - -- loading the game inside itself. - local path = authorBeside(name) - local chunk, why = loadfile(path, "t", q.env) - - 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 ------------------------------------------------------------------------- - --- The loops the qte behaviour plays, by the style a description's qte.options names (FORGE.md 15.4). -local QTE_STYLES = {} - - -QTE_STYLES.windows = { - help = "Plays a laserdisc quick-time-event game from the tables under game.qte: attract, credits, levels of scenes with moves to answer in a window of frames (compound moves too: sequences and circles), death clips, continues, the settings, and the game's own add-ons. The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, qteFns = Q } - - -- How the windows style plays where the shared screens differ; a description's - -- qte.options overrides any of them. - q.optionDefaults = { - managedHud = true, -- The loop sets the HUD by the display setting (every framework copy does). - saveIcon = true -- A disk shows where the game would save. - } - - instance.q = q - instance.hearsAll = true - -- 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 - Q.applySnapshot(q, data) - -- The settings a service screen saved in an earlier run, over the shipped ones. - Q.configLoad(q) - -- The flags the games' config reader turns from 1 and 0 into true and false (a 0 is - -- true in Lua, so a switched-off one would show). - for _, name in ipairs(QTE_CONFIG_FLAGS) do - q[name] = (q[name] == 1) or (q[name] == true) - end - -- Where the compound kinds are in use, DOUBLE is numbered after them. - q.DOUBLE = Q.option(q, "doubleKind", q.DOUBLE) - q.env = Q.shim(q) - q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") - -- The tables the framework's globals declare before a game's script fills them. - 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.boardsLoad(q, data) - Q.savesLoad(q) - q.iTop = q.iTopN - Q.applyResolution(q) - Q.applyExtras(q) - q.iPenal = ({ [0] = 0, q.PenalNormal or 0, q.PenalHard or 0, q.PenalExtreme or 0 })[q.dip_Difficulty or 1] or 0 - q.BarMinT, q.BarBonusT = q.BarMin or 2, q.BarBonus or 3 - q.iCoins, q.iCredits, q.iScore, q.iScoreTemp, q.iBonus, q.iScene, q.iExtraLife = 0, 0, 0, 0, 0, 0, 0 - 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.dropDraws(q) - - -- What the game's own Lua queued to draw last frame: this loop has no look that draws it - -- (FORGE.md 15.4), and a queue nothing empties grows by every call, every frame. - Q.dropDraws(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. - -- The title plays paused: the switches do nothing until it ends. - if q.lvlState == QTE.lvlSetup then - Q.setupClip(q, q.offsetTitle, q.offsetTitleend) - q.bPause = true - q.lvlState = QTE.lvlRunning - elseif q.lvlState == QTE.lvlRunning then - if q.currentFrame >= q.iFrameEnd then - q.lvlState = QTE.lvlEnd - end - elseif q.lvlState == QTE.lvlEnd then - q.bPause = false - q.gameflow = "init" - end - return - end - if q.gameflow == "init" then - Q.initJob(q) - 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) - elseif SERVICE_SCREENS[q.currentLevel] then - Q.doServiceScreen(q, SERVICE_SCREENS[q.currentLevel]) - end - Q.afterStep(q) - Q.publishVars(q) - end, - on = function(instance, name, event) - local q = instance.q - local flag = qteFlagOf(event.switch or -1) - - -- Paused (the title), nothing; a release counts only once the game runs. - if (flag == nil) or q.bPause or ((name == "released") and (q.gameflow ~= "running")) then - return - end - if name == "pressed" then - q[flag] = true - -- COIN with START held, or START with COIN held, opens the exit screen (not at the - -- continue, where the coin buys one). - if ((flag == "p1COIN1") and q.p1START1) or ((flag == "p1START1") and q.p1COIN1) then - if q.currentLevel ~= QTE.levelContinue then - q[(flag == "p1COIN1") and "p1START1" or "p1COIN1"] = false - Q.leavePlay(q, true) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelExit - end - end - if (flag == "p1BUTTON1") and q.bTestMash then - q.iMash = q.iMash + 1 - end - 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 - -- mashAnySwitch: the third button and the directions count for either side of a mash. - if Q.option(q, "mashAnySwitch", false) and ((flag == "p1BUTTON3") or (flag == "p1UP") or (flag == "p1DOWN") or (flag == "p1LEFT") or (flag == "p1RIGHT")) then - if q.bTestMashR then - q.iMash = q.iMash + 1 - end - 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 == "p1SERVICE" then - -- SERVICE opens the options screen, or closes it back to the attract loop; from - -- play the game is autosaved first, to come back to. - q.p1SERVICE = false - Q.leavePlay(q, false) - q.lvlState = QTE.lvlSetup - if q.currentLevel == QTE.levelService then - q.currentLevel = QTE.levelIntro - else - q.bOneDiff = true - q.currentLevel = QTE.levelService - end - elseif flag == "p1TEST" then - -- TEST opens the save screen, or closes it back to the attract loop. - q.p1TEST = false - Q.leavePlay(q, false) - q.lvlState = QTE.lvlSetup - q.currentLevel = (q.currentLevel == QTE.levelSave) and QTE.levelIntro or QTE.levelSave - elseif flag == "p1CONSOLE" then - q.p1CONSOLE = false - Q.leavePlay(q, false) - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelExit - elseif flag:match("^p1SKILL[123]$") and (q.dip_GameType ~= 5) then - -- SKILL1 to SKILL3 save the game in play to that slot, or with START held load it. - local slot = tonumber(flag:sub(-1)) - - q[flag] = false - if (q.dip_GameType ~= 2) and (q.dip_GameType ~= 3) and (q.currentLevel == QTE.levelNormal) and (q.currentMove <= q.totalMoves) then - if q.p1START1 then - Q.loadSave(q, slot) - q.p1START1 = false - else - Q.autoSave(q, slot) - end - q["bShowDisk" .. slot] = true - q.altState = QTE.branch01 - end - elseif (flag == "p1COIN1") or (flag == "p1COIN2") then - q.p1COIN1, q.p1COIN2 = false, false - if (q.currentLevel ~= QTE.levelNormal) and (q.currentLevel ~= QTE.levelService) and (q.dip_CoinsPerCredit ~= QTE.DOPT_FREEPLAY) and (q.iCredits < 9) then - q.iCoins = q.iCoins + 1 - if q.iCoins >= q.dip_CoinsPerCredit then - q.iCoins = q.iCoins - q.dip_CoinsPerCredit - q.iCredits = q.iCredits + 1 - Q.sound(q, "sndcredit") - else - Q.sound(q, "sndcoin") - end - end - else - q[flag] = false - end - end - end -} - - --- ===== The held-input quick-time-event loop ==================================================== --- --- The held style plays a later generation of the games the windows style --- plays (FORGE.md section 14.11). The state machine, the screens, and the tables are --- the same family, so the Q helpers above serve both; what changed lives here under K: --- a move judged by a seven-bit mask of what is held (acombo, from the presses) against the mask --- the move asks for (gcombo), which gives combos, diagonals, a second accepted answer, and the --- multi and loop sequences; mash in up/down, left/right, and button-pair variants; a hold of a --- computed length; paths that jump to a later move or a death; yes/no and timed branches; way, --- wayout, toscene, and tolevel jumps; tilt; the life bar; rewind modes 0, 2, and 3; the "die --- and retry" game type that counts deaths instead of points; a new-game menu; a level select. --- The numbers are the later generation's own, and a run of the original and a run of the port trace alike. - -local HELD = { - -- The later generation's constants, as its globals.singe declares them; the states and screens are QTE's. - UP = 1, DOWN = 2, LEFT = 3, RIGHT = 4, BUTTON1 = 5, BUTTON2 = 6, BUTTON3 = 7, BUTTON4 = 8, - UPLEFT = 9, UPRIGHT = 10, DOWNLEFT = 11, DOWNRIGHT = 12, - COMBO = 20, MULTI = 21, LOOP = 22, MASH = 23, HOLD = 24, LETGO = 25, - 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 HELD_MASK = { - [1] = { 1, 0, 0, 0, 0, 0, 0 }, [2] = { 0, 1, 0, 0, 0, 0, 0 }, [3] = { 0, 0, 1, 0, 0, 0, 0 }, [4] = { 0, 0, 0, 1, 0, 0, 0 }, - [5] = { 0, 0, 0, 0, 1, 0, 0 }, [6] = { 0, 0, 0, 0, 0, 1, 0 }, [7] = { 0, 0, 0, 0, 0, 0, 1 }, - [9] = { 1, 0, 1, 0, 0, 0, 0 }, [10] = { 1, 0, 0, 1, 0, 0, 0 }, [11] = { 0, 1, 1, 0, 0, 0, 0 }, [12] = { 0, 1, 0, 1, 0, 0, 0 } -} -local HELD_BIT = { p1UP = 1, p1DOWN = 2, p1LEFT = 3, p1RIGHT = 4, p1BUTTON1 = 5, p1BUTTON2 = 6, p1BUTTON3 = 7 } - - -local function maskOf(kind) - local bits = { 0, 0, 0, 0, 0, 0, 0 } - - for i, bit in ipairs(HELD_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 - - --- The held style resets the same way everywhere, and judges through its move loop. -K.resetJudge = K.resetVar -K.resetMoveDone = K.resetVar - - -function K.resultMove(q) - local m = q.move[q.currentMove] - - if q.thisMove == m[QTE.correctMove] then - q.lvlState = QTE.branch04 - elseif q.thisMove ~= HELD.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] <= HELD.DOWNRIGHT then - if m[5] ~= nil then - q.g2combo = maskOf(m[5]) - if m[5] == HELD.UPRIGHT then - -- The framework's own slip: UP lands on the first mask, not the second. - q.g2combo[1] = 0 - q.gcombo[1] = 1 - end - end - elseif m[QTE.correctMove] == HELD.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 HELD.MOVEPENDING -end - - -function K.checkBasic(q, curMove) - local m = q.move[q.currentMove] - local z = HELD.MOVEPENDING - local count, extra = maskAgrees(q.acombo, q.gcombo) - local count2 = 0 - - if extra then - z = HELD.MOVEFAIL - end - if m[5] ~= nil then - local extra2 - - count2, extra2 = maskAgrees(q.acombo, q.g2combo) - if extra2 then - z = HELD.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 HELD.MOVEFAIL or HELD.MOVEPENDING -end - - -function K.checkHold(q, curMove) - local m = q.move[q.currentMove] - local z = HELD.MOVEPENDING - local count, extra = maskAgrees(q.acombo, q.gcombo) - - if extra then - z = HELD.MOVEFAIL - end - if (q.currentFrame == m[QTE.inputFrmStart]) and (count == 7) then - z = HELD.MOVEFAIL - elseif (q.currentFrame > m[QTE.inputFrmStart]) and (count == 7) then - if q.iLenHold >= q.lenCounter then - z = curMove - q.iLenHold = 0 - else - z = HELD.MOVEPENDING - if q.bTestHold and (q.currentFrame == q.lastHold) then - q.lastHold = q.currentFrame - elseif q.bTestHold and (q.currentFrame == q.lastHold + 1) then - q.iLenHold = q.iLenHold + 1 - q.lastHold = q.currentFrame - else - q.lastHold = q.currentFrame - end - end - elseif count == 5 then - if z ~= HELD.NOMOVE then - q.iLenHold = 0 - else - if q.iLenHold > 0 then - q.iLenHold = q.iLenHold - 1 - end - z = HELD.MOVEPENDING - end - end - - return z -end - - -function K.checkMash(q, curMove) - local z = HELD.MOVEPENDING - local count, extra = maskAgrees(q.acombo, q.gcombo) - - if extra then - q.iMash = 0 - z = HELD.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 = HELD.MOVEPENDING - - if (playerMove == HELD.BUTTON1) and q.bTestMashL then - q.bTestMashL, q.bTestMashR = false, true - if q.iMash >= q.mashCounter then - z = curMove - q.p1BUTTON1 = false - end - elseif (playerMove == HELD.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 ~= HELD.NOMOVE then - q.iMash = 0 - z = HELD.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 = HELD.MOVEPENDING - - if playerMove == HELD.LEFT then - q.bTestRunL, q.bTestRunR, q.p1LEFT = false, true, false - if q.iMash >= q.mashCounter then - z = curMove - end - elseif playerMove == HELD.RIGHT then - q.bTestRunL, q.bTestRunR, q.p1RIGHT = true, false, false - if q.iMash >= q.mashCounter then - z = curMove - end - elseif playerMove == HELD.UP then - q.p1UP = false - elseif playerMove == HELD.DOWN then - q.p1DOWN = false - else - if q.iMash > 0 then - q.iMash = q.iMash - q.unMash - end - if playerMove ~= HELD.NOMOVE then - q.iMash = 0 - z = HELD.MOVEFAIL - end - end - - return z -end - - -function K.checkMashUD(q, playerMove, curMove) - local z = HELD.MOVEPENDING - - if playerMove == HELD.UP then - q.bTestRunU, q.bTestRunD, q.p1UP = false, true, false - if q.iMash >= q.mashCounter then - z = curMove - end - elseif playerMove == HELD.DOWN then - q.bTestRunU, q.bTestRunD, q.p1DOWN = true, false, false - if q.iMash >= q.mashCounter then - z = curMove - end - elseif playerMove == HELD.LEFT then - q.p1LEFT = false - elseif playerMove == HELD.RIGHT then - q.p1RIGHT = false - else - if q.iMash > 0 then - q.iMash = q.iMash - q.unMash - end - if playerMove ~= HELD.NOMOVE then - q.iMash = 0 - z = HELD.MOVEFAIL - end - end - - return z -end - - -function K.checkMulti(q, curMove) - local m = q.move[q.currentMove] - local z = HELD.MOVEPENDING - local steps = q.multi[q.currentMove] - - if (m[6] ~= nil) and (q.currentFrame < m[QTE.inputFrmStart] + m[6]) and (m[QTE.inputFrmStart] + m[6] < m[QTE.inputFrmEnd]) then - return z - end - local count, extra = maskAgrees(q.acombo, q.mcombo) - - if extra then - z = HELD.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 = HELD.MOVEPENDING - - if q.p1UP or q.p1DOWN or q.p1LEFT or q.p1RIGHT or q.p1BUTTON1 or q.p1BUTTON2 or q.p1BUTTON3 then - z = HELD.MOVEFAIL - end - if (q.currentFrame == m[QTE.inputFrmEnd]) and (z == HELD.MOVEPENDING) then - z = curMove - end - - return z -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 > HELD.OUT then - m[QTE.moveDeath] = target - HELD.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.judge(q) - local m = q.move[q.currentMove] - local kind = m[QTE.correctMove] - local fps = q.MovieFPS - - if kind == HELD.HOLD then - q.bTestHold = true - if q.bCalc then - q.lenCounter = (m[QTE.inputFrmEnd] - m[QTE.inputFrmStart]) - (14 * (fps / 24) - q.dip_Difficulty) - end - K.fillMove(q, 5) - q.thisMove = K.checkHold(q, kind) - K.resultMove(q) - elseif kind == HELD.LETGO then - q.thisMove = Q.checkLet(q, Q.scanInput(q), kind) - K.resultMove(q) - elseif kind == HELD.MASH then - local with = m[5] - - q.thisMove = Q.scanInput(q) - if (with == nil) or (with == HELD.BUTTON1) or (with == HELD.BUTTON2) or (with == HELD.BUTTON3) then - q.bTestMash = true - elseif with == HELD.UP then - q.bTestRunU = true - elseif with == HELD.DOWN then - q.bTestRunD = true - elseif with == HELD.LEFT then - q.bTestRunL = true - elseif with == HELD.RIGHT then - q.bTestRunR = true - elseif with == HELD.UPLEFT then - q.bTestRunU, q.bTestRunL = true, true - elseif with == HELD.UPRIGHT then - q.bTestRunU, q.bTestRunR = true, true - elseif with == HELD.DOWNLEFT then - q.bTestRunD, q.bTestRunL = true, true - elseif with == HELD.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 >= HELD.UPLEFT) and (with <= HELD.DOWNRIGHT) then - q.mashCounter = q.mashCounter * 2 - q.unMash = q.unMash * 1.5 - elseif with == HELD.UD then - q.bTestRunU, q.bTestRunD = true, true - q.mashCounter = q.mashCounter * ((q.dip_MashtoRun == 1) and 1.5 or 2.5) - elseif with == HELD.LR then - q.bTestRunL, q.bTestRunR = true, true - q.mashCounter = q.mashCounter * ((q.dip_MashtoRun == 1) and 1.5 or 2.5) - elseif with == HELD.B1B2 then - q.bTestMashL, q.bTestMashR = true, true - q.mashCounter = q.mashCounter * 2.5 - end - end - if with == HELD.UD then - q.thisMove = K.checkMashUD(q, q.thisMove, kind) - elseif with == HELD.LR then - q.thisMove = K.checkMashLR(q, q.thisMove, kind) - elseif with == HELD.B1B2 then - q.thisMove = K.checkMashBB(q, q.thisMove, kind) - else - K.fillMove(q, 5) - q.thisMove = K.checkMash(q, kind) - end - K.resultMove(q) - elseif kind <= HELD.DOWNRIGHT then - q.thisMove = HELD.NOMOVE - K.fillMove(q, 3) - q.thisMove = K.checkBasic(q, kind) - K.resultMove(q) - elseif kind == HELD.ANYTHING then - q.thisMove = K.checkAny(q, kind) - K.resultMove(q) - elseif kind == HELD.NOTHING then - q.thisMove = K.checkNo(q, kind) - K.resultMove(q) - elseif kind == HELD.COMBO then - K.fillMove(q, 5) - q.thisMove = K.checkCombo(q, kind) - K.resultMove(q) - elseif (kind == HELD.MULTI) or (kind == HELD.LOOP) then - q.bTestCombo, q.bTestMulti = true, true - if kind == HELD.LOOP then - q.bCalc = false -- The framework picks a loop picture here; the port draws none. - end - q.thisMove = Q.scanInput(q) - q.mcombo = maskOf(q.multi[q.currentMove][q.iMulti]) - q.thisMove = K.checkMulti(q, kind) - K.resultMove(q) - elseif kind == HELD.PATH then - q.thisMove = Q.scanInput(q) - if q.thisMove ~= HELD.NOMOVE then - local p = q.path[q.currentMove] - - if q.thisMove == p[1] then - takeBranch(q, p[2], p[4] - 1, p[9]) - elseif q.thisMove == p[3] then - if p[5] == 0 then - takeBranch(q, p[4], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) - else - takeBranch(q, p[4], p[6] - 1, p[9]) - end - elseif q.thisMove == p[5] then - if p[7] == 0 then - takeBranch(q, p[6], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) - else - takeBranch(q, p[6], p[8] - 1, p[9]) - end - elseif q.thisMove == p[7] then - takeBranch(q, p[8], 0, (p[9] == HELD.OUT) and HELD.OUT or 0) - else - q.bShowAction = false - q.lvlState = QTE.branch02 - end - end - elseif kind == HELD.YESNO then - local p = q.path[q.currentMove] - - q.thisMove = Q.scanInput(q) - if q.thisMove ~= HELD.NOMOVE then - if q.thisMove == HELD.BUTTON1 then - takeBranch(q, p[1], p[2] - 1, p[3]) - else - q.bShowAction = false - q.lvlState = QTE.branch02 - end - elseif q.currentFrame == m[QTE.inputFrmEnd] then - takeBranch(q, p[2], 0, 0) - end - elseif kind == HELD.TIMED then - local i, j = m[5] or q.currentMove, m[6] or q.currentMove - - q.thisMove = Q.scanInput(q) - if q.thisMove ~= HELD.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 == HELD.SKIP then - q.thisMove = Q.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 HELD_WORD = { U = 1, D = 2, L = 3, R = 4, B1 = 5, B2 = 6, B3 = 7, UL = 9, UR = 10, DL = 11, DR = 12 } - -local function wordOf(text) - return HELD_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 == HELD.ACTUP then - m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.UP - elseif kind == HELD.ACTDOWN then - m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.DOWN - elseif kind == HELD.ACTLEFT then - m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.LEFT - elseif kind == HELD.ACTRIGHT then - m[3], m[5], m[6] = HELD.COMBO, HELD.BUTTON1, HELD.RIGHT - elseif kind == HELD.HOLDUP then - m[3], m[5] = HELD.HOLD, HELD.UP - elseif kind == HELD.HOLDDOWN then - m[3], m[5] = HELD.HOLD, HELD.DOWN - elseif kind == HELD.HOLDLEFT then - m[3], m[5] = HELD.HOLD, HELD.LEFT - elseif kind == HELD.HOLDRIGHT then - m[3], m[5] = HELD.HOLD, HELD.RIGHT - elseif kind == HELD.HOLDBUT then - m[3], m[5] = HELD.HOLD, HELD.BUTTON1 - elseif kind == HELD.RUN then - m[3], m[5] = HELD.MASH, HELD.LR - elseif kind == HELD.MASH2 then - m[3], m[5] = HELD.MASH, HELD.B1B2 - elseif kind == HELD.MASHMIN then - m[3], m[5], m[6] = HELD.MASH, HELD.BUTTON1, -2 - elseif kind == HELD.MASHMAX then - m[3], m[5], m[6] = HELD.MASH, HELD.BUTTON1, 2 - elseif kind == HELD.MASH2MIN then - m[3], m[5], m[6] = HELD.MASH, HELD.B1B2, -2 - elseif kind == HELD.MASH2MAX then - m[3], m[5], m[6] = HELD.MASH, HELD.B1B2, 2 - elseif (kind == HELD.MASH) and (m[5] == nil) then - m[5] = HELD.BUTTON1 - elseif (kind == HELD.MASH) and (m[5] == HELD.DU) then - m[5] = HELD.UD - elseif (kind == HELD.MASH) and (m[5] == HELD.RL) then - m[5] = HELD.LR - elseif kind == HELD.RUNMIN then - m[3], m[5], m[6] = HELD.MASH, HELD.LR, -2 - elseif kind == HELD.RUNMAX then - m[3], m[5], m[6] = HELD.MASH, HELD.LR, 2 - elseif (kind == HELD.LOOP) or (kind == HELD.LOOPLEFT) or (kind == HELD.LOOPRIGHT) then - -- "U,+,1,B2": the first direction, the way round, the turns (Q a quarter, H a half), - -- and a button to finish on. - if kind == HELD.LOOPLEFT then - m[3], m[5] = HELD.LOOP, "L,+,1" - elseif kind == HELD.LOOPRIGHT then - m[3], m[5] = HELD.LOOP, "R,-,1" - end - local text = m[5] - local turns = string.sub(text, 5, 5) - local steps = { wordOf(string.sub(text, 1, 1)) } - local count = (turns == "Q") and 2 or ((turns == "H") and 3 or (4 * tonumber(turns) + 1)) - local clockwise = { [HELD.UP] = HELD.RIGHT, [HELD.RIGHT] = HELD.DOWN, [HELD.DOWN] = HELD.LEFT, [HELD.LEFT] = HELD.UP } - local widdershins = { [HELD.UP] = HELD.LEFT, [HELD.LEFT] = HELD.DOWN, [HELD.DOWN] = HELD.RIGHT, [HELD.RIGHT] = HELD.UP } - local turn = (string.sub(text, 3, 3) == "+") and clockwise or widdershins - - for i = 2, count do - steps[i] = turn[steps[i - 1]] - end - local button = wordOf(string.sub(text, 7, 8)) - - if button and (button >= HELD.BUTTON1) and (button <= HELD.BUTTON3) then - steps[count + 1] = button - end - q.multi[at] = steps - elseif (kind == HELD.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 == HELD.MULTI then - local steps = {} - - for i, word in ipairs(wordsOf(m[5])) do - steps[i] = wordOf(word) or HELD.BUTTON1 - end - q.multi[at] = steps - elseif (kind == HELD.PATH) and (m[5] ~= nil) then - -- "U,10,D,11,L,12,R,14": a direction and the move it leads to, in pairs; the ninth - -- slot is where the branch rejoins, or OUT. - local p = { 0, 0, 0, 0, 0, 0, 0, 0, 0 } - 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 HELD.OUT - q.path[at] = p - elseif (kind == HELD.YESNO) and (m[5] ~= nil) then - local p = { 0, 0, 0 } - - for i, word in ipairs(wordsOf(m[5])) do - p[i] = tonumber(word) - end - p[3] = m[6] or HELD.OUT - q.path[at] = p - elseif kind == HELD.DOUBLE then - m[3], q.multi[at] = HELD.MULTI, { HELD.BUTTON1, HELD.BUTTON1 } - elseif kind == HELD.UD then - m[3], q.multi[at] = HELD.MULTI, { HELD.UP, HELD.DOWN } - elseif kind == HELD.DU then - m[3], q.multi[at] = HELD.MULTI, { HELD.DOWN, HELD.UP } - elseif kind == HELD.LR then - m[3], q.multi[at] = HELD.MULTI, { HELD.LEFT, HELD.RIGHT } - elseif kind == HELD.RL then - m[3], q.multi[at] = HELD.MULTI, { HELD.RIGHT, HELD.LEFT } - end -end - - -local HELD_SIDE = { [HELD.LEFT] = HELD.RIGHT, [HELD.RIGHT] = HELD.LEFT, [HELD.UPLEFT] = HELD.UPRIGHT, [HELD.UPRIGHT] = HELD.UPLEFT, [HELD.DOWNLEFT] = HELD.DOWNRIGHT, [HELD.DOWNRIGHT] = HELD.DOWNLEFT } - -local function mirrored(value) - return HELD_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] >= HELD.CHOOSE) and (m[3] <= HELD.YESNO)) then - m[3] = HELD.BUTTON1 - end - else - K.getShortcuts(q, at) - if q.dip_Difficulty == 0 then - if m[3] == HELD.HOLD then - m[3] = m[5] - elseif (m[3] == HELD.MASH) or (m[3] == HELD.LETGO) then - m[3] = HELD.BUTTON1 - elseif (m[3] == HELD.LOOP) or (m[3] == HELD.MULTI) then - m[3], m[5] = q.multi[at][1], nil - elseif m[3] == HELD.COMBO then - m[3] = m[5] - end - else - if q.dip_MashtoRun == 2 then - if (m[3] == HELD.MASH) and ((m[5] == HELD.UD) or (m[5] == HELD.LR)) then - m[5] = HELD.B1B2 - end - elseif q.dip_MashtoRun == 3 then - if (m[3] == HELD.MASH) and ((m[5] == HELD.UD) or (m[5] == HELD.LR) or (m[5] == HELD.B1B2)) then - m[3], m[5] = HELD.MASH, HELD.BUTTON1 - end - end - if (q.dip_HoldtoLoop == 1) and (m[3] == HELD.LOOP) then - local way = string.sub(m[5], 3, 3) - - if way == "+" then - m[3], m[5] = HELD.HOLD, HELD.RIGHT - elseif way == "-" then - m[3], m[5] = HELD.HOLD, HELD.LEFT - end - end - end - end - if q.bFlip then - if HELD_SIDE[m[3]] then - m[3] = HELD_SIDE[m[3]] - elseif ((m[3] == HELD.HOLD) or (m[3] == HELD.MASH)) and HELD_SIDE[m[5]] then - m[5] = HELD_SIDE[m[5]] - elseif m[3] == HELD.PATH then - local p = q.path[at] - - for _, slot in ipairs({ 1, 3, 5, 7 }) do - if (p[slot] == HELD.LEFT) or (p[slot] == HELD.RIGHT) then - p[slot] = HELD_SIDE[p[slot]] - end - end - elseif m[3] == HELD.TIMED then - local from, to = m[5] or at, m[6] or at - - for p = from, to do - local t = q.timed[p] - - if (t[1] == HELD.LEFT) or (t[1] == HELD.RIGHT) then - t[1] = HELD_SIDE[t[1]] - end - t[2] = t[2] + q.offsetFlip - t[3] = t[3] + q.offsetFlip - end - elseif m[3] == HELD.COMBO then - m[5] = mirrored(m[5]) - m[6] = mirrored(m[6]) - elseif m[3] == HELD.MULTI then - for i, step in ipairs(q.multi[at]) do - q.multi[at][i] = mirrored(step) - end - elseif m[3] == HELD.LOOP then - for i, step in ipairs(q.multi[at]) do - if (step == HELD.LEFT) or (step == HELD.RIGHT) then - q.multi[at][i] = HELD_SIDE[step] - end - end - end - end - -- The frames the move plays from and to: after the move before, unless a jump kind - -- put that one later than this begins. - local jump = (m[3] == HELD.WAY) or (m[3] == HELD.WAYOUT) or (m[3] == HELD.TOSCENE) or (m[3] == HELD.TOLEVEL) - local before = move[at - 1] - - if (at == 1) or jump then - m[7] = m[1] - elseif ((before[3] == HELD.WAY) or (m[3] == HELD.WAYOUT) or (m[3] == HELD.TOSCENE) or (m[3] == HELD.TOLEVEL)) and (before[8] > m[1]) then - m[7] = move[at - 2][2] + 1 - else - m[7] = before[2] + 1 - 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 - - --- ----- Scoring, deaths, and the next level ---------------------------------------------------- - --- ----- 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. --- 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 > HELD.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 - Q.setupDeathClip(q) - end - else - Q.setupDeathClip(q) - end - end -end - - --- ----- The screens --------------------------------------------------------------------------- - --- 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 - q.qteFns.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.arcadeEdition and q.ArcadeMode then - q.dip_StartLevel, q.dip_StartScene = 1, 1 - end - if (q.dip_GameType == 2) or (q.dip_GameType == 3) then - q.qteFns.startGame(q) - elseif q.dip_GameType == 6 then - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelMovie - elseif free and (q.dip_PlayStyle ~= 4) then - q.lvlState = QTE.lvlSetup - q.currentLevel = HELD.levelNG - else - q.lvlState = QTE.lvlSetup - q.qteFns.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 = HELD.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.saves[slot] -end - - --- The autosave, resumed: the framework's loadSave, without the writing. -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 - Q.livesForType(q) - end - q.iTop = Q.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 - Q.livesForType(q) - else - q.iLives = q.dip_LivesPerCredit - end - q.iTop = Q.topFor(q) - q.currentLevel = QTE.levelMap - q.lvlState = QTE.lvlSetup - q.bShowScore, q.bShowLives, q.bShowLvl, q.bShowTop, q.bShowAction, q.bShowNext = false, false, false, false, false, false - 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 - q.qteFns.startGame(q) - elseif index == 3 then - if autosaveIsFresh(q) then - Q.sound(q, "sndwrong") - else - Q.loadSave(q, 4) - end - elseif index == 4 then - q.gameflow = "init" - elseif index == 10 then - q.qteFns.startGame(q) - end -end - - -function K.doNG(q) - local optMax = 4 - - if q.lvlState == QTE.lvlSetup then - if autosaveIsFresh(q) then - q.qteFns.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[QTE.SELECTSTILL] ~= nil then - discSkipToFrame(q.RelativeFrames and (level[QTE.INTROCLIP] + level[QTE.SELECTSTILL]) or level[QTE.SELECTSTILL]) - else - discSkipToFrame(level[QTE.INTROCLIP] + 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 - Q.toLevelSelect(q) - else - q.iWrongMv = 0 - Q.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 - q.qteFns.startGame(q) - end - end - 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 - Q.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 - - -QTE_STYLES.held = { - help = "Plays a laserdisc quick-time-event game whose moves are judged by what is held, from the tables under game.qte: the attract loop, a new-game menu, levels of scenes with moves judged by what is held -- combos, mashes, holds, sequences, paths, timed and yes/no branches -- tilt, the life bar, the die-and-retry count, and the game's own add-ons. The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, qteStyle = "held" } - - instance.q = q - instance.hearsAll = true - -- The constants -- the later generation's over the older ones where the numbers differ -- - -- then the description's snapshot of the game's settings, under the game's own names. - for key, value in pairs(QTE) do - if (key ~= "FLAG_OF") and (key ~= "BRANCH") then - q[key] = value - end - end - for key, value in pairs(HELD) do - q[key] = value - end - Q.applySnapshot(q, data) - for _, name in ipairs(QTE_CONFIG_FLAGS) do - q[name] = (q[name] == 1) or (q[name] == true) - end - q.qteFns = HELD_FNS - -- How the held style plays where the shared screens differ; a description's qte.options - -- overrides any of them. - q.optionDefaults = { - barBonusRestarts = true, -- The count to the life bar's bonus restarts, bar full. - deathCountGameType = 4, -- Game type 4 counts deaths: no life lost, no points, - -- fewest ranked. - extraDeathClipSkips = true, -- BUTTON1 cuts the extra death clip short. - gameOverAfterContinue = "seek", -- The game over is always sought after a continue. - hintedDeathCounts = true, -- A death after the hints counts as the wrong move. - keepsSaves = false, -- Not yet: the held framework's slots (4 to 6) are unported. - levelSelect = true, -- Game types 2 and 4, and play style 4, choose a level. - managedHud = true, -- The loop sets the HUD by the display setting. - nameEntry = false, -- Not yet checked against the held framework's. - noGameOverGameType = 2, -- Game type 2 goes from the board to the attract. - outEndsSceneNow = true, -- The out jump ends the scene in the same step. - pendingIsUnanswered = true, -- A move pending as its window closes counts as none. - rankingsAfterBoard = "seek", -- The rankings are always sought after the board. - saveIcon = true, -- A disk shows where the game would save. - waitForWindowStart = true, -- A window that ends before it starts waits for it. - wayOutPlaysTarget = true -- A way out plays the scene it names, done or not. - } - q.env = Q.shim(q) - -- A game on the later copy of the engine (an LED panel, an arcade mode) differs from the - -- others in a few things a player can see; its settings say which. - q.arcadeEdition = (q.ArcadeMode ~= nil) - q.MYDIR = (AUTHOR_SOURCE_DIR or AUTHOR_DIR or ""):gsub("[/\\]$", "") - for _, name in ipairs({ "Level", "Death", "Tiers", "PlayOrder", "LvlOrder", "LvlMap", "move", "choice", "path", "timed", "multi", "stage", "scene", "sprite", "sprArrow", "sprNUM", "Group1", "Group2", "Group3", "Group4" }) do - q[name] = q[name] or {} - 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.boardsLoad(q, data) - Q.savesLoad(q) - q.hsDR = data.hsDR or { 100, 100, 100, 100 } - q.iTop = q.iTopN - Q.applyResolution(q) - Q.applyExtras(q) - q.iPenal = ({ [0] = 0, q.PenalNormal or 0, q.PenalHard or 0, q.PenalExtreme or 0 })[q.dip_Difficulty or 1] or 0 - q.BarMinT, q.BarBonusT = q.BarMin or 2, q.BarBonus or 3 - -- initJob's defaults for the frames a game may not name. - 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 = HELD.NOMOVE, 0, 0, 0, 0, 0 - q.bOneDiff, q.bRes, q.bPath, q.bTime, q.bCalc = true, false, false, true, true - q.bSave, q.bSwap, q.bGOAlt, q.bShowCredits, q.bIgnoreJoy, q.bAllowSave, q.bExtendedPlay = false, false, false, true, false, false, false - q.bShowNext, q.bNoScreen, q.bUnlockSel, q.bLvlJump, q.bScnJump, q.bInPlayExit, q.bPause, q.bCheckMove = false, false, false, false, false, false, false, false - 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.dropDraws(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 - Q.setLang(q) -- initJob's readConfig - return - end - local level = q.currentLevel - - if level == QTE.levelIntro then - K.doIntro(q) - elseif level == QTE.levelNormal then - Q.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 - Q.doDiffSelect(q) - elseif level == HELD.levelSelect then - K.doLvlSelect(q) - elseif level == QTE.levelContinue then - Q.doContinue(q) - elseif level == QTE.levelGameOver then - Q.doGameOver(q) - elseif level == QTE.levelHighScore then - Q.doHighScore(q) - elseif level == QTE.levelFinish then - if q.dip_GameType == 2 then - K.doFinish(q) - else - K.doClear(q) - end - elseif level == HELD.levelNG then - K.doNG(q) - elseif level == QTE.levelExit then - K.doExit(q) - elseif (level == QTE.levelMovie) or (level == HELD.level2P) or (level == HELD.level2PEnd) then - -- The movie player and the two-player screens are not played: back to the attract loop. - q.lvlState = QTE.lvlSetup - q.currentLevel = QTE.levelIntro - end - Q.publishVars(q) - AUTHOR_VARS.deaths = q.iTotDeath + q.iScDeath - AUTHOR_VARS.lifeBar = q.iLifeBar - AUTHOR_VARS.tilt = q.iTilt - 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 = HELD_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.arcadeEdition and 99 or 9)) then - q.iCoins = q.iCoins + 1 - if q.iCoins >= q.dip_CoinsPerCredit then - q.iCoins = q.iCoins - q.dip_CoinsPerCredit - 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 = HELD_BIT[flag] - - if bit and q.bTestCombo then - q.acombo[bit] = 0 - end - end - end - end -} - - --- ===== The segmented quick-time-event loop ==================================================== --- --- The segments style plays a game of the oldest lineage, the ancestor of the two --- loops above (FORGE.md section 14.12): the same states and screens, a simpler judging (one --- input, a mash, a button with a direction, two directions, a skip), levels made of segments, --- and a map. A game of this lineage keeps its data as functions of its own main.singe -- --- 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 = {} --- The segmented lineage's functions for the shared screens: its own start, the board test in its --- game's own Lua, and a step back over segments; the shared ones otherwise. -local SEG_FNS = setmetatable({ - newScore = function(q, score) return q.newScore(score) end, - stepBack = function(q) - if q.iSegPointer > 0 then - q.iSegPointer = q.iSegPointer - 1 - end - end -}, { __index = function(_, name) return R[name] or Q[name] end }) - - -function R.addPoints(q, thisMuch) - 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 --- (a shooting game's shot). -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 PAIR_SWITCHES = nil - -local function pairFor(q, kind) - if PAIR_SWITCHES == nil then - PAIR_SWITCHES = { [q.ACTUP] = { "p1BUTTON1", "p1UP" }, [q.ACTDOWN] = { "p1BUTTON1", "p1DOWN" }, [q.ACTLEFT] = { "p1BUTTON1", "p1LEFT" }, [q.ACTRIGHT] = { "p1BUTTON1", "p1RIGHT" }, - [q.UPLEFT] = { "p1UP", "p1LEFT" }, [q.UPRIGHT] = { "p1UP", "p1RIGHT" }, [q.DOWNLEFT] = { "p1DOWN", "p1LEFT" }, [q.DOWNRIGHT] = { "p1DOWN", "p1RIGHT" } } - end - - return PAIR_SWITCHES[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 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 Q.clipEnded(q) or q.p1BUTTON1 or (q.myButton == SWITCH_BUTTON3) then - q.p1BUTTON1 = false - q.bShowLvl, q.bShowSkip = false, false - if q.currentFrame ~= q.iFrameEnd then - 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 Q.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 Q.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 Q.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, as in a shooting game: the button with the pointer inside it. - local shot = (kind == q.ACTDOWN) and (m[7] ~= nil) - - for _, name in ipairs({ "p1UP", "p1DOWN", "p1LEFT", "p1RIGHT", "p1BUTTON1", "p1BUTTON2", "p1BUTTON3" }) do - 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 Q.clipEnded(q) then - if q.later and q.ShowSupDeath then - -- The later copies can play a second clip after the death. - Q.setupClip(q, q.offsetSupDeath, q.offsetSupDeathEnd) - 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 Q.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 Q.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 - - --- 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 Q.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.qteStyle == "trophies" then - -- The trophy loop'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 ------------------------------------------------------------------------- - -QTE_STYLES.segments = { - help = "Plays a laserdisc quick-time-event game of levels of segments: a move an input or a mash or a pair of inputs in a window, a map between levels. The game's own files are loaded and its level, move, death-clip, order, and map functions run as they are; the loop is this. The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, qteStyle = "segments", 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 of the lineage declare a level order; they number - -- some states differently, test clip ends with "at or past", and add a game type. - q.later = (q.LvlOrder ~= nil) - -- How the lineage plays the screens it shares with the other styles. - q.optionDefaults = { - clipEndExact = not q.later, -- The first copies test a clip's end with "at". - diffChoiceAfterGameOver = false, -- A continue's "no difficulty" outlives the game over. - continueDeclines = false, -- BUTTON2 does not turn the continue down. - gameOverAfterContinue = "seek" -- The game over is always sought. - } - q.qteFns = SEG_FNS - if q.later then - q.iPenal = ({ [0] = 0, [1] = q.PenalNorm, [2] = q.PenalHard })[q.dip_Difficulty] or 0 - end - -- 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.dropDraws(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 Q.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 - Q.doContinue(q) - elseif level == QTE.levelGameOver then - Q.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 gun games' crosshair and recoil, as their frame loops --- draw them after the level ran: the crosshair where the pointer is, and for the frames of --- the recoil the flash where the shot went; an edition that swaps the crosshair's sprite for --- the flash's puts it back by the crosshair dip when the recoil ends. --- opts.highScore the recoil is left alone on the board --- opts.cannon no crosshair while the cannon fires --- 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 two-player gun games' two guns: for each, the crosshair, the recoil as a sheet --- of frames counted after the level ran and frozen where the shot went, and the hand's --- animation the same way. The second gun's names end in 2. --- opts.cannon no crosshair while the cannon fires --- opts.highScore the recoil is left alone on the boards --- opts.resets the counts go back to zero when done --- opts.reload the folder of the crosshair pictures, swapped in by the dip through the --- recoil (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 - - --- The qteHud look's drawing, reached by name: the looks come before the loops in this file. -function authorQteHud(q) - Q.hudDraw(q) -end - - -function authorFlushDraws(q) - Q.flushDraws(q) - fontSelect(AUTHOR_FONT) -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 checkpoint quick-time-event loop ==================================================== --- --- One HD remake keeps a compact loop of its own, not the map-mode --- one: a level's moves come from arrayEasy, arrayNormal, or arrayHard by difficulty (mirrored --- by a random draw), play has checkpoints resumed after a death, a time-stop is a choice of --- three, a tally pays a bonus by deaths, a quota gives a life, and chooseLevel picks the next --- 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 - - -QTE_STYLES.checkpoints = { - help = "Plays a laserdisc quick-time-event game of levels with checkpoints: moves from a difficulty array (mirrored by a random draw), checkpoints, a time-stop choice of three, a tally by deaths, a life per quota, and a level order by tiers, at random, or in sequence. The game's own files are loaded and its data and order functions run as they are; the loop is this. The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - local data = AUTHOR_GAME.qte or {} - local q = { instance = instance, snapshot = data, qteStyle = "checkpoints", 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.dropDraws(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 trophy quick-time-event loop ======================================================== --- --- One hybrid: map-mode data (the level, scene, death-clip, order, and map functions of its --- main.singe, run as they are through the shim as the segmented loop runs them) under a level loop --- of the qte loop's shape (branch11 the right move, branch02 a wrong one with the hints --- frame in branch03, holds, mashes, doubles, paths, and choices, though its rows use only the --- five plain kinds), with screens of its own: a difficulty select, a level clear with a --- rolling bonus, a finish with a rolling percent, trophies, and two hardcoded level orders --- (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.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 trophyWrong(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 trophyPath(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 trophyAfterDeath(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 trophyStartScene(q, thisLevel) - if not q.stage[thisLevel][q.LEVELSTARTED] then - q.stage[thisLevel][q.LEVELSTARTED] = true - if q.bSave and (q.currentMove ~= 1) then - 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 - trophyWrong(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 - trophyAfterDeath(q, thisLevel) - end - elseif q.lvlState == q.branch08 then - -- The "get ready" clip has ended: the scene begins. - if q.currentFrame == q.iFrameEnd then - trophyStartScene(q, thisLevel) - end - elseif q.lvlState == q.branch09 then - -- The "get ready" clip after a death has ended: back to the move after it. - 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 = Q.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 = Q.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 - trophyPath(q, p[4], p[5] - 1, p[7]) - elseif q.thisMove == p[2] then - if p[3] == 0 then - trophyPath(q, p[5], 0, 0) - else - trophyPath(q, p[5], p[6] - 1, p[7]) - end - elseif q.thisMove == p[3] then - trophyPath(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 - trophyPath(q, p[4], p[5] - 1, p[7]) - else - q.setupDeathClip(q.thisMove) - end - elseif q.currentFrame >= m[q.inputFrmEnd] then - trophyPath(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 - trophyAfterDeath(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.trophyOrder 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 qte loop's 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 - - -QTE_STYLES.trophies = { - help = "Plays a laserdisc quick-time-event game of levels, scenes, and trophies: map-mode levels, scenes, death clips, order, and map from the game's own files, judged as the qte behaviour judges, with its difficulty select, level clear, finish, and trophies. The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - -- The state the segmented loop keeps, and then this game's own. - QTE_STYLES.segments.attach(instance, params) - local q = instance.q - - q.qteStyle, q.later = "trophies", false - q.optionDefaults.clipEndExact = true - q.optionDefaults.mashClearsButton = false - q.percent = {} - for k, entry in ipairs(q.snapshot.percents or {}) do - q.percent[k] = { entry[1], entry[2] } - 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.dropDraws(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 - Q.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. - QTE_STYLES.segments.on(instance, name, event) - end -} - - --- The one quick-time-event behaviour (FORGE.md 15.4): the description's qte.options.style picks --- the loop, and everything else is the loop's own. The loops are folded into one a piece at a --- time (step 4 of 15.8); until then each style keeps its own. -AUTHOR.behaviours.qte = { - help = "Plays a laserdisc quick-time-event game from the tables under game.qte: an attract loop, credits, levels of scenes with moves to answer in a window of frames, death clips, continues, the settings, and the game's own add-ons. qte.options.style says how its moves are judged and its levels run: windows (the default: a move answered in a window, compound moves too), held (moves judged by what is held together: combos, mashes, holds, sequences, paths, timed and yes/no branches, tilt, a life bar, die and retry, a new-game menu, a level select), segments (levels of segments, a map between them, the game's own level functions run as they are), checkpoints (checkpoints, a time-stop choice of three, a tally by deaths, a life per quota), or trophies (segmented levels judged the windows way, a difficulty select, a level-clear screen, trophies). The port converter writes the tables from an existing game's script.", - params = {}, - attach = function(instance, params) - local options = (AUTHOR_GAME.qte or {}).options or {} - local style = options.style or "windows" - local loop = QTE_STYLES[style] - - if loop == nil then - error("qte: no style called " .. tostring(style) .. " (windows, held, segments, checkpoints, or trophies)") - end - instance.qteLoop = loop - loop.attach(instance, params) - end, - step = function(instance) - instance.qteLoop.step(instance) - end, - on = function(instance, name, event) - instance.qteLoop.on(instance, name, event) - end -} diff --git a/forge/AuthorCompile.singe b/forge/AuthorCompile.singe deleted file mode 100644 index 3693d1d59..000000000 --- a/forge/AuthorCompile.singe +++ /dev/null @@ -1,896 +0,0 @@ ---[[ - * - * Singe 3 - * Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com> - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 3 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA - * 02110-1301, USA. - * - * ---]] - - - --- The compiler: a game description to a Singe game (FORGE.md). --- --- A description is data -- types, rooms, and rules -- and what comes out is a Lua script that --- calls the runtime in Author.singe. The rules become real functions: a condition is a Lua --- expression, an action a statement, and a rule with an action that takes time a coroutine. The --- description travels with the game so it can be opened again; nothing is lost in either --- direction. --- --- Written in Lua rather than as a util/ script because the editor is itself a Singe game and has to --- compile what it is editing without leaving the engine. It lives with Forge rather than in Singe/ --- because only building needs it; Author.singe, the runtime a built game loads, travels with the --- game -- a copy is written beside it when it is built -- so a game handed to someone who has never --- installed Forge still runs. - --- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy. -AUTHOR_RUNTIME = "Forge/Author.singe" - -dofile(AUTHOR_RUNTIME) - - -local INDENT = "\t" - - --- ===== Expressions ============================================================================ --- --- A small, safe language where a number goes: numbers, strings, arithmetic, comparison, and, or, --- not, self.var, other.var, event.field, an entity's field by name, the game's vars by name, and a --- few functions. Anything else is a compile error naming the expression, and the lua action is --- the way past it. - -local FUNCTIONS = { - count = "authorCount", - random = "authorRandom", - distance = "authorDistance", - has = "authorHas", - abs = "math.abs", - min = "math.min", - max = "math.max", - floor = "math.floor", - time = "authorTime", - frame = "discGetFrame", - pointerX = "authorPointerX", - pointerY = "authorPointerY" -} - -local NAMES = { - self = "self", - other = "other", - event = "event", - time = "authorTime()", - frame = "discGetFrame()" -} - -local OPERATORS = { - ["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">", - ["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".." -} - - --- The keys of a table in name order, so anything written from one is written the same way each time. -local function sortedKeys(t) - local keys = {} - - for key in pairs(t or {}) do - keys[#keys + 1] = key - end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - - return keys -end - - -local function tokenize(text) - local tokens = {} - local at = 1 - - while at <= #text do - local c = text:sub(at, at) - - if c:match("%s") then - at = at + 1 - elseif c:match("[%d%.]") and text:sub(at):match("^%d") or text:sub(at):match("^%.%d") then - local number = text:sub(at):match("^%d*%.?%d+[eE][-+]?%d+") or text:sub(at):match("^%d*%.?%d+") or text:sub(at):match("^%d+") - - tokens[#tokens + 1] = { kind = "number", value = number } - at = at + #number - elseif c:match("[%a_]") then - local name = text:sub(at):match("^[%a_][%w_]*") - - tokens[#tokens + 1] = { kind = "name", value = name } - at = at + #name - elseif (c == '"') or (c == "'") then - -- A backslash escapes the next character, so a line can hold its own quote marks. - local value = {} - local scan = at + 1 - - while (scan <= #text) and (text:sub(scan, scan) ~= c) do - local ch = text:sub(scan, scan) - - if ch == "\\" then - local escaped = text:sub(scan + 1, scan + 1) - - value[#value + 1] = (escaped == "n") and "\n" or escaped - scan = scan + 2 - else - value[#value + 1] = ch - scan = scan + 1 - end - end - if scan > #text then - error("unterminated string in: " .. text) - end - tokens[#tokens + 1] = { kind = "string", value = table.concat(value) } - at = scan + 1 - else - local two = text:sub(at, at + 1) - - if OPERATORS[two] then - tokens[#tokens + 1] = { kind = "op", value = two } - at = at + 2 - elseif OPERATORS[c] or c == "(" or c == ")" or c == "," or c == "." then - tokens[#tokens + 1] = { kind = "op", value = c } - at = at + 1 - else - error("cannot read '" .. c .. "' in: " .. text) - end - end - end - - return tokens -end - - --- Recursive descent, lowest precedence first. Every level returns Lua source. -local function parser(text) - local tokens = tokenize(text) - local at = 1 - local p = {} - - local function peek(kind, value) - local token = tokens[at] - - return token and (token.kind == kind) and ((value == nil) or (token.value == value)) - end - - local function take() - at = at + 1 - return tokens[at - 1] - end - - local function expect(kind, value) - if not peek(kind, value) then - error("expected " .. (value or kind) .. " in: " .. text) - end - return take() - end - - function p.primary() - local token = take() - - if token == nil then - error("expression ends early: " .. text) - end - if token.kind == "number" then - return token.value - elseif token.kind == "string" then - return string.format("%q", token.value) - elseif token.kind == "op" and token.value == "(" then - local inner = p.expression() - - expect("op", ")") - return "(" .. inner .. ")" - elseif token.kind == "op" and token.value == "-" then - return "(-" .. p.unary() .. ")" - elseif token.kind == "name" then - local name = token.value - - if name == "true" or name == "false" or name == "nil" then - return name - end - if name == "not" then - return "(not " .. p.unary() .. ")" - end - if peek("op", "(") then - local target = FUNCTIONS[name] - local args = {} - - if target == nil then - error("no function called " .. name .. " in: " .. text) - end - take() - if not peek("op", ")") then - args[#args + 1] = p.expression() - while peek("op", ",") do - take() - args[#args + 1] = p.expression() - end - end - expect("op", ")") - return target .. "(" .. table.concat(args, ", ") .. ")" - end - if peek("op", ".") then - take() - local field = expect("name").value - - if name == "event" then - return "event[" .. string.format("%q", field) .. "]" - elseif NAMES[name] and (name == "self" or name == "other") then - return "authorField(" .. name .. ", " .. string.format("%q", field) .. ")" - end - return "authorField(authorEntity(" .. string.format("%q", name) .. "), " .. string.format("%q", field) .. ")" - end - if NAMES[name] then - return NAMES[name] - end - return "AUTHOR_VARS[" .. string.format("%q", name) .. "]" - end - error("unexpected '" .. tostring(token.value) .. "' in: " .. text) - end - - function p.unary() - return p.primary() - end - - function p.power() - local left = p.unary() - - while peek("op", "^") do - take() - left = "(" .. left .. " ^ " .. p.unary() .. ")" - end - return left - end - - function p.product() - local left = p.power() - - while peek("op", "*") or peek("op", "/") or peek("op", "%") do - local op = take().value - - left = "(" .. left .. " " .. op .. " " .. p.power() .. ")" - end - return left - end - - function p.sum() - local left = p.product() - - while peek("op", "+") or peek("op", "-") or peek("op", "..") do - local op = take().value - - left = "(" .. left .. " " .. op .. " " .. p.product() .. ")" - end - return left - end - - function p.comparison() - local left = p.sum() - - while peek("op") and OPERATORS[tokens[at].value] and tokens[at].value:match("[=<>~!]") do - local op = OPERATORS[take().value] - - left = "(" .. left .. " " .. op .. " " .. p.sum() .. ")" - end - return left - end - - function p.conjunction() - local left = p.comparison() - - while peek("name", "and") do - take() - left = "(" .. left .. " and " .. p.comparison() .. ")" - end - return left - end - - function p.expression() - local left = p.conjunction() - - while peek("name", "or") do - take() - left = "(" .. left .. " or " .. p.conjunction() .. ")" - end - return left - end - - local out = p.expression() - - if at <= #tokens then - error("unexpected '" .. tostring(tokens[at].value) .. "' in: " .. text) - end - - return out -end - - --- An expression as Lua. A number or a boolean given as a value is itself. -function authorExpression(value) - if type(value) == "number" then - return tostring(value) - elseif type(value) == "boolean" then - return tostring(value) - elseif value == nil then - return "nil" - end - - return parser(tostring(value)) -end - - --- ===== Parameters ============================================================================= --- --- A parameter's value as a Lua fragment, by the type the manifest declares for it. - -local function fragment(kind, value) - if value == nil then - return nil - end - if kind == "number" or kind == "expression" then - return authorExpression(value) - elseif kind == "boolean" then - return value and "true" or "false" - elseif kind == "entity" then - if value == "self" or value == "other" then - return value - end - return "authorEntity(" .. string.format("%q", value) .. ")" - elseif kind == "scancode" then - return "SCANCODE." .. value .. ".value" - elseif kind == "switch" then - return value - elseif kind == "lua" then - return value - end - - return string.format("%q", tostring(value)) -end - - --- A part's parameters as Lua. One the author left empty takes the manifest's default for it, --- and one with neither becomes nil, which the checker has already pointed out. -local function fragments(entry, item) - local out = {} - - for key, kind in pairs(entry.params or {}) do - local value = item[key] - - if (value == nil) and entry.defaults then - value = entry.defaults[key] - end - out[key] = fragment(kind, value) or "nil" - end - - return out -end - - --- ===== Rules ================================================================================== - -local function emitOne(set, item, what) - local name = item[1] - local entry = set[name] - - if entry == nil then - debugPrint("Author: no " .. what .. " called '" .. tostring(name) .. "'; skipped") - return nil - end - - return entry.emit(fragments(entry, item)) -end - - --- A condition list is an AND; an entry named any is an OR of what it holds, and an entry named --- all an AND, nesting as deep as the author likes. -local function emitConditions(list) - local parts = {} - - for _, item in ipairs(list or {}) do - local test = emitOne(AUTHOR.conditions, item, "condition") - - if test ~= nil then - parts[#parts + 1] = test - end - end - if list and list.any then - local inner = {} - - for _, item in ipairs(list.any) do - if item[1] then - local test = emitOne(AUTHOR.conditions, item, "condition") - - if test then - inner[#inner + 1] = test - end - else - inner[#inner + 1] = emitConditions(item) - end - end - if #inner > 0 then - parts[#parts + 1] = "(" .. table.concat(inner, " or ") .. ")" - end - end - if list and list.all then - parts[#parts + 1] = emitConditions(list.all) - end - if #parts == 0 then - return "true" - end - - return table.concat(parts, " and ") -end - - -local function ruleWaits(rule) - for _, item in ipairs(rule.act or {}) do - local entry = AUTHOR.actions[item[1]] - - if entry and entry.waits then - return true - end - end - - return false -end - - -local function emitRule(out, rule, index) - local event = AUTHOR.events[rule.on or "frame"] - local filter = {} - - if event == nil then - debugPrint("Author: no event called '" .. tostring(rule.on) .. "'; rule " .. index .. " skipped") - return - end - for key, kind in pairs(event.filter) do - if rule[key] ~= nil then - filter[#filter + 1] = key .. " = " .. fragment(kind, rule[key]) - end - end - out[#out + 1] = INDENT .. "{" - out[#out + 1] = INDENT .. INDENT .. string.format("note = %q, on = %q,", rule.note or ("rule " .. index), rule.on or "frame") - out[#out + 1] = INDENT .. INDENT .. "filter = { " .. table.concat(filter, ", ") .. " }," - out[#out + 1] = INDENT .. INDENT .. string.format("each = %s, room = %s, waits = %s, controls = %s, interrupt = %s, guarded = %s,", - rule.each and string.format("%q", rule.each) or "nil", - rule.room and string.format("%q", rule.room) or "nil", - tostring(ruleWaits(rule)), - tostring(rule.controls ~= false), - tostring(rule.interrupt == true), - tostring((rule.when ~= nil) and ((#rule.when > 0) or (rule.when.any ~= nil) or (rule.when.all ~= nil)))) - out[#out + 1] = INDENT .. INDENT .. "run = function(self, other, event)" - out[#out + 1] = INDENT .. INDENT .. INDENT .. "if " .. emitConditions(rule.when) .. " then" - for _, item in ipairs(rule.act or {}) do - local statement = emitOne(AUTHOR.actions, item, "action") - - if statement ~= nil then - out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. statement - end - end - out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. "return true" - out[#out + 1] = INDENT .. INDENT .. INDENT .. "end" - out[#out + 1] = INDENT .. INDENT .. INDENT .. "return false" - out[#out + 1] = INDENT .. INDENT .. "end" - out[#out + 1] = INDENT .. "}," -end - - --- A dialogue: its start and its nodes; a choice's when is an expression and its act a list of --- actions, compiled as a rule's are, with self and other absent. -local function emitDialogue(out, name, dialogue) - out[#out + 1] = INDENT .. string.format("[%q] = { start = %q, nodes = {", name, dialogue.start or "start") - -- Nodes in name order, so the same description compiles to the same text every time. - for _, nodeName in ipairs(sortedKeys(dialogue.nodes or {})) do - local node = dialogue.nodes[nodeName] - - out[#out + 1] = INDENT .. INDENT .. string.format("[%q] = { name = %q, who = %s, text = %s, seconds = %s, next = %s, choices = {", - nodeName, nodeName, - node.who and string.format("%q", node.who) or "nil", - node.text and string.format("%q", node.text) or "nil", - node.seconds and tostring(node.seconds) or "nil", - node.next and string.format("%q", node.next) or "nil") - for _, choice in ipairs(node.choices or {}) do - local parts = { string.format("text = %q", choice.text or "..."), "next = " .. (choice.next and string.format("%q", choice.next) or "nil"), "once = " .. tostring(choice.once == true) } - - if choice.when then - parts[#parts + 1] = "when = function() local self, other, event = nil, nil, {} return " .. emitConditions(type(choice.when) == "table" and choice.when or { { "test", expr = choice.when } }) .. " end" - end - if choice.act then - local body = {} - - for _, item in ipairs(choice.act) do - local statement = emitOne(AUTHOR.actions, item, "action") - - if statement then - body[#body + 1] = statement - end - end - parts[#parts + 1] = "run = function() local self, other, event = nil, nil, {} " .. table.concat(body, " ") .. " end" - end - out[#out + 1] = INDENT .. INDENT .. INDENT .. "{ " .. table.concat(parts, ", ") .. " }," - end - out[#out + 1] = INDENT .. INDENT .. "} }," - end - out[#out + 1] = INDENT .. "} }," -end - - --- ===== Reading and writing a description ====================================================== --- --- The editor produces descriptions, so the format has to survive a round trip: load, change --- nothing, save, and the result must compile to the same game. - -local function literal(value) - if type(value) == "string" then - return string.format("%q", value) - end - - return tostring(value) -end - - -local function indentOf(depth) - return string.rep(INDENT, depth) -end - - --- Whether a table holds only leaves, in which case it is written on one line. A condition reads --- far better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed. -local function isLeaf(t) - for _, value in pairs(t) do - if type(value) == "table" then - return false - end - end - - return true -end - - --- A value written back as Lua source. A condition or an action is a mixed table -- an array part --- naming it, plus named parameters -- so both parts have to be written or the name survives and --- the parameters do not. The array part keeps its order; the named keys are sorted, because a --- description that reorders itself on every save makes every diff unreadable. -local function valueSource(value, depth) - local parts = {} - local keys = {} - local count = 0 - - if type(value) ~= "table" then - return literal(value) - end - count = #value - for _, item in ipairs(value) do - parts[#parts + 1] = valueSource(item, depth + 1) - end - for key in pairs(value) do - -- Skip the array part, which has already been written in order. - if not (type(key) == "number" and key >= 1 and key <= count and key == math.floor(key)) then - keys[#keys + 1] = key - end - end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - for _, key in ipairs(keys) do - local name = tostring(key) - - if not name:match("^[%a_][%w_]*$") then - name = "[" .. literal(key) .. "]" - end - parts[#parts + 1] = string.format("%s = %s", name, valueSource(value[key], depth + 1)) - end - if #parts == 0 then - return "{}" - end - if isLeaf(value) then - return "{ " .. table.concat(parts, ", ") .. " }" - end - - return "{\n" .. indentOf(depth + 1) .. table.concat(parts, ",\n" .. indentOf(depth + 1)) .. "\n" .. indentOf(depth) .. "}" -end - - -function authorLoad(path) - local chunk, problem = loadfile(path) - - if chunk == nil then - debugPrint("Author: cannot read " .. tostring(path) .. ": " .. tostring(problem)) - return nil - end - - return chunk() -end - - -function authorSave(game, path) - local file = assert(io.open(path, "w")) - local copy = {} - - -- source says where a description came from and is set by the loader, so writing it back out - -- would make a description that had been through the editor differ from one that had not. - for key in pairs(game) do - if key ~= "source" then - copy[key] = game[key] - end - end - file:write("-- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine.\n") - file:write("return " .. valueSource(copy, 0) .. "\n") - file:close() - - return path -end - - -function authorCopy(fromPath, toPath) - local input = io.open(fromPath, "rb") - local output - - if input == nil then - return false - end - output = io.open(toPath, "wb") - if output == nil then - input:close() - return false - end - output:write(input:read("a")) - input:close() - output:close() - - return true -end - - --- ===== Checking =============================================================================== --- --- What the compiler can tell an author before the game runs: a type that is not declared, a --- condition the manifest does not have, an expression that does not parse. Returns a list of --- messages, empty when the description is sound. - -function authorCheck(game) - local problems = {} - local types = game.types or {} - - local function problem(text) - problems[#problems + 1] = text - end - - local function checkItems(set, list, what, where) - for _, item in ipairs(list or {}) do - local entry = set[item[1]] - - if entry == nil then - problem(where .. ": no " .. what .. " called '" .. tostring(item[1]) .. "'") - else - for key, kind in pairs(entry.params) do - if (kind == "number" or kind == "expression") and (type(item[key]) == "string") then - local ok, err = pcall(parser, item[key]) - - if not ok then - problem(where .. ": " .. tostring(err)) - end - elseif (kind == "type") and (item[key] ~= nil) and (types[item[key]] == nil) then - problem(where .. ": no type called '" .. tostring(item[key]) .. "'") - elseif (item[key] == nil) and (kind ~= "entity") and ((entry.defaults or {})[key] == nil) and not (entry.optional or {})[key] then - problem(where .. ": " .. key .. " is empty") - end - end - end - end - end - - -- A field the manifest does not declare is a misspelling nine times in ten, and it is - -- otherwise silently ignored. - local function checkFields(t, declared, skip, where) - for key in pairs(t or {}) do - if (type(key) == "string") and (declared[key] == nil) and not skip[key] then - problem(where .. ": '" .. key .. "' is not something it takes") - end - end - end - - for name, entityType in pairs(types) do - local look = entityType.look and AUTHOR.looks[entityType.look.kind] or nil - - if entityType.look and (look == nil) then - problem("type " .. name .. ": no look called '" .. tostring(entityType.look.kind) .. "'") - elseif look then - checkFields(entityType.look, look.params, { kind = true }, "type " .. name .. "'s look") - end - for _, b in ipairs(entityType.behaviours or {}) do - local behaviour = AUTHOR.behaviours[b.kind] - - if behaviour == nil then - problem("type " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'") - else - checkFields(b, behaviour.params, { kind = true }, "type " .. name .. "'s " .. b.kind) - end - end - checkFields(entityType, { look = true, vars = true, behaviours = true }, {}, "type " .. name) - end - if #(game.rooms or {}) == 0 then - problem("the game has no rooms") - end - for _, room in ipairs(game.rooms or {}) do - for _, entry in ipairs(room.entities or {}) do - if types[entry.type] == nil then - problem("room " .. tostring(room.name) .. ": no type called '" .. tostring(entry.type) .. "'") - end - end - -- A waves track's keys name a type, and are at a moment -- or at a stop, when keyed by one. - for _, track in ipairs(room.tracks or {}) do - for index, key in ipairs(track.spawns or {}) do - local where = "room " .. tostring(room.name) .. ", track " .. tostring(track.name) .. ", wave " .. index - - if types[key.type] == nil then - problem(where .. ": no type called '" .. tostring(key.type) .. "'") - end - if (track.key == "stop") and (type(key.at) ~= "string") then - problem(where .. ": at should name a stop") - elseif (track.key ~= "stop") and (type(key.at) ~= "number") then - problem(where .. ": at should be a number") - end - end - end - end - for index, rule in ipairs(game.rules or {}) do - local where = "rule " .. index .. " (" .. tostring(rule.note or "") .. ")" - - if rule.on and (AUTHOR.events[rule.on] == nil) then - problem(where .. ": no event called '" .. tostring(rule.on) .. "'") - elseif AUTHOR.events[rule.on or "frame"] then - checkFields(rule, AUTHOR.events[rule.on or "frame"].filter, { note = true, on = true, each = true, room = true, when = true, act = true, controls = true, interrupt = true }, where) - end - if rule.each and (types[rule.each] == nil) then - problem(where .. ": no type called '" .. tostring(rule.each) .. "'") - end - checkItems(AUTHOR.conditions, rule.when, "condition", where) - checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where) - checkItems(AUTHOR.actions, rule.act, "action", where) - end - - return problems -end - - --- ===== The whole game ========================================================================= - --- The whole game, as source. Returns a string; the caller decides where it goes. --- --- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing --- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is --- the engine's, as it is for every game ever written for Singe. -function authorCompile(game) - local out = {} - local data = {} - - for key, value in pairs(game) do - if (key ~= "rules") and (key ~= "source") and (key ~= "dialogues") then - data[key] = value - end - end - -- 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] = "--" - out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then" - out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other." - out[#out + 1] = "" - out[#out + 1] = 'dofile("Singe/Framework.singe")' - -- The game finds its own directory rather than trusting DIR. Framework sets DIR from the - -- script the *engine was launched with*, which is this file only when the game is played - -- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would - -- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the - -- chunk actually running, either way. - out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")' - out[#out + 1] = 'dofile(here .. "Author.singe")' - out[#out + 1] = "AUTHOR_DIR = here -- Files the description names are looked for here first." - -- 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) .. ")" - out[#out + 1] = "" - out[#out + 1] = "-- The rules. Each is a trigger, its filter, and a function that tests the conditions and" - out[#out + 1] = "-- does the actions; one with an action that waits runs as a coroutine." - out[#out + 1] = "authorRules({" - for index, rule in ipairs(game.rules or {}) do - emitRule(out, rule, index) - end - out[#out + 1] = "})" - out[#out + 1] = "" - if game.dialogues then - out[#out + 1] = "-- The dialogues: nodes of a line and choices, each choice's condition and actions as code." - out[#out + 1] = "authorDialogues({" - for _, name in ipairs(sortedKeys(game.dialogues)) do - emitDialogue(out, name, game.dialogues[name]) - end - out[#out + 1] = "})" - out[#out + 1] = "" - end - out[#out + 1] = "onKeyPressed = authorKeyDown" - out[#out + 1] = "onKeyReleased = authorKeyUp" - out[#out + 1] = "onInputPressed = authorSwitchDown" - out[#out + 1] = "onInputReleased = authorSwitchUp" - out[#out + 1] = "onMouseMoved = authorMouseMoved" - out[#out + 1] = "onCollision = authorCollision" - out[#out + 1] = "onTrigger = authorTrigger" - out[#out + 1] = "onNavArrived = authorNavArrived" - out[#out + 1] = "onMidiMessage = authorMidi" - out[#out + 1] = "onSoundCompleted = authorSoundDone" - out[#out + 1] = "" - out[#out + 1] = "function onOverlayUpdate()" - out[#out + 1] = INDENT .. "authorFrame()" - out[#out + 1] = "" - out[#out + 1] = INDENT .. "return OVERLAY_UPDATED" - out[#out + 1] = "end" - out[#out + 1] = "" - - return table.concat(out, "\n") -end - - -local function directoryOf(path) - return (string.match(path, "^(.*[/\\])") or "") -end - - --- Compiles a description file to a game beside it, with the runtime beside that, because that is --- what the result loads. Returns the path written. -function authorBuild(descriptionFile, outputFile) - local chunk = assert(loadfile(descriptionFile)) - local game = chunk() - local file - - game.source = descriptionFile - -- 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 - file = assert(io.open(outputFile, "w")) - file:write(authorCompile(game)) - file:close() - authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe") - - return outputFile -end diff --git a/forge/Forge.rml b/forge/Forge.rml deleted file mode 100644 index 41f4f83ff..000000000 --- a/forge/Forge.rml +++ /dev/null @@ -1,63 +0,0 @@ -<rml> -<!-- - Singe 3 - Copyright (C) 2026 Scott Duensing <scott@kangaroopunch.com> - - Forge's chrome. Only the panels are RmlUi; the canvas beside them is drawn - into the overlay, because that is where Singe already tracks a pointer against rectangles. - pointer-events keeps the canvas clickable through the document. - Licensed under the GNU General Public License, version 3 or later. ---> -<head> - <title>forge - - - - -
-
≡ Entities
-
-
-
-
- - diff --git a/forge/Forge.singe b/forge/Forge.singe deleted file mode 100644 index 0cc09f93a..000000000 --- a/forge/Forge.singe +++ /dev/null @@ -1,5248 +0,0 @@ ---[[ - * - * Singe 3 - * Copyright (C) 2006-2026 Scott Duensing - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 3 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA - * 02110-1301, USA. - * - * ---]] - - - - --- Forge: the authoring tool (FORGE.md). --- --- It is a Singe game. Nothing here is a preview: the canvas is the same overlay, at the same --- coordinates, that the game will be played in, so what is placed is what is seen. --- --- Chrome is an RmlUi document, which is what RmlUi is good at; the canvas beside it is drawn into --- the overlay and picked with collidePointRect, which is what Singe has always done with a pointer --- and a set of rectangles. They compose because the engine offers a button to the GUI first and --- passes on what it did not use, while pointer motion is never consumed at all. --- --- The panel shows one of five lists -- the room's entities, the types, the rules, the room's tracks --- -- and TAB cycles them. Whatever is selected is edited the same way: ENTER walks its fields and --- each is typed. The fields come from the manifest in Author.singe, so a new look, behaviour, --- condition, action, or event is editable the moment it is declared. --- --- The file is two things. Everything up to the last section is a library: a scene that drives --- the editor sets FORGE_LIBRARY before loading it and supplies its own callbacks. The last section --- is Forge as a game -- the file chooser, the engine callbacks, and the play button -- and runs only --- when nothing has said otherwise. - -dofile("Singe/Framework.singe") -dofile("Forge/AuthorCompile.singe") - -local lfs = require("lfs") - - -FORGE = { - game = nil, -- The description being edited. - path = nil, - room = 1, -- Index into game.rooms. - mode = "entities", -- entities, types, rules, tracks, or dialogues. - selected = nil, -- Index into the room's entities. - typeName = nil, -- The selected type. - rule = 1, -- Selected rule, - part = 0, -- and the selected condition or action within it; 0 is the rule itself. - track = 1, -- Selected track in the room, - key = 0, -- and the selected key within it; 0 is the track itself. - cursor = 0, -- The timeline's position: a disc frame or a time. - dirty = false, - gui = nil, - document = nil, - held = nil, -- The entity being dragged, or { key = ... } for a track box. - grabX = 0, - grabY = 0, - panelX = 0, -- The chrome slides sideways so nothing is permanently under it. - panelDrag = nil, -- Offset from the panel's left edge while it is being dragged. - lastField = nil, -- The field ENTER edited last, so the next ENTER moves on from it. - editing = nil, -- { key, text, was, set }: see "Typing a value". - picker = nil, -- A list being chosen from: see "Pickers". - undo = {}, -- Copies of the description, newest last. - redo = {}, -- What undo took away, newest last; any other change empties it. - sprites = {}, -- Sprite looks loaded for drawing, by file name; false when missing. - chooser = { files = {}, at = 1 }, -- What the chooser lists, and which is lit. - stash = {}, -- Behaviours and layers typed away, by owner, so typing them back keeps their values. - sheets = {}, -- The same files cut into frames, by file and frame count, for turned sheets. - video = nil, -- The disc layer's file, loaded so the timeline can scrub it. - videoFile = nil, - scene = nil, -- The 3D preview: { nodes, camera, yaw, pitch, distance, stamp }. - dialogue = nil, -- The selected dialogue's name, - dnode = nil, -- the selected node's name within it, - dchoice = 0, -- and the selected choice (0 is the node itself). - dact = 0, -- The selected action of that choice (0 is the choice itself). - drawing = nil, -- A polygon being drawn on the canvas: { points }. - closing = false, -- ESC has been pressed once on an unsaved description. - message = "loaded" -} - -local PANEL_W = 190 -- Matches Forge.rml; the canvas starts to the right of it. -local HANDLE = 6 -- Half the size of the square drawn at an entity's own position. -local TAB_W = 16 -- The panel's drag tab, drawn on the canvas just outside its edge. -local TAB_H = 54 -local TAB_Y = 10 -local UNDO_MAX = 40 -- Steps kept. A description is small; forty copies are nothing. -local NEW_W = 40 -- The box a new type wears until it is given a look. -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, singe = true } -local FILE_DEPTH = 2 -- How far under the game's directory the file picker looks. -local FILE_NONE = "(none)" -- The file picker's first entry: no file. --- Who made the game, as the description's credits table carries it: the field, and the games.dat --- key a release writes it under, which is what the menu's details panel shows. notes (lines of --- credit the fields have no place for) and licenses (files that travel with the release) are the --- two lists beside these. -local CREDIT_FIELDS = { - { field = "developer", key = "DEVELOPER" }, - { field = "publisher", key = "PUBLISHER" }, - { field = "year", key = "YEAR" }, - { field = "platform", key = "PLATFORM" }, - { field = "genre", key = "GENRE" }, - { field = "creator", key = "CREATOR" }, - { field = "source", key = "SOURCE" }, - { field = "description", key = "DESCRIPTION" } -} -local FRAME_STEP = 1 -local FRAME_LEAP = 10 -local PICK_REACH = 30 -- Pixels a press may be from an entity's projected position to pick it. -local GIZMO_LEN = 1.5 -- World units each gizmo arm reaches from the selected entity. -local GIZMO_GRAB = 10 -- Pixels a press may be from an arm's tip to take it. -local TURN_REACH = 18 -- Pixels the 2D turn handle stands off the selected entity's box. -local THUMB = 24 -- The types panel's thumbnails fit a square this many pixels across. -local ORBIT_STEP = 10 -- Degrees the editor camera turns per key. -local PICK_ROWS = 16 -- Rows a picker shows at once: the scancodes are two hundred and more. -local DROP_ROWS = 6 -- Rows a field's dropdown shows, floating under its row. -local ZOOM_STEP = 1.15 -- How much closer or farther per key. -local sceneBegun = false -- The 3D scene is switched on once per run of the editor. - -FORGE_DEFAULTS = { -- What a new parameter starts as, by the manifest's type: only what - entity = "self", -- must be there to mean anything. A number, a string, a file, a room, - scancode = "SPACE", -- or a flag left empty takes its own default when the game is built. - switch = "SWITCH_BUTTON1", - expression = '""', - lua = "-- your Lua here", - state = "idle" -} - - -local function element(id) - return rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id) -end - - -function forgeRoom() - return FORGE.game.rooms[FORGE.room] -end - - --- A sprite look's image, loaded once and kept, so the canvas can show it. A file that is not --- there answers nil rather than ending the editor the way spriteLoad would: the entity is drawn --- as a box until the name is right. --- Where a file the description names is, for the editor: beside the description first (a --- released game carries its files beside itself), then as the engine sees it from the games --- directory, which is how a description in the data directory names the kit (Forge/kit/hero.png). -function forgeFile(name) - local beside = (FORGE.path or ""):match("^(.*[/\\])") - - if beside and not name:match("^[/\\]") and not name:match("^%a:") and (lfs.attributes(beside .. name, "mode") == "file") then - return beside .. name - end - - return name -end - - -local function spriteFor(file) - if FORGE.sprites[file] == nil then - if (file ~= "") and (lfs.attributes(forgeFile(file), "mode") == "file") then - FORGE.sprites[file] = spriteLoad(forgeFile(file)) - else - FORGE.sprites[file] = false - end - end - - return FORGE.sprites[file] or nil -end - - --- The same file cut into frames, for drawing one of them turned. -local function sheetFor(file, frames) - local key = file .. "#" .. frames - - if FORGE.sheets[key] == nil then - FORGE.sheets[key] = spriteFor(file) and spriteLoadFrames(frames, forgeFile(file)) or false - end - - return FORGE.sheets[key] or nil -end - - --- The sorted keys of a table, so fields always read in the same order. -local function sortedKeys(t) - local keys = {} - - for key in pairs(t or {}) do - keys[#keys + 1] = key - end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - - return keys -end - - -local function copyOf(value) - local out - - if type(value) ~= "table" then - return value - end - out = {} - for key, item in pairs(value) do - out[key] = copyOf(item) - end - - return out -end - - --- The behaviour of a type that it carries, if any. -local fillDefaults - -local function behaviourOf(entityType, name) - for _, b in ipairs(entityType.behaviours or {}) do - if b.kind == name then - return b - end - end - - return nil -end - - --- A list of kinded entries -- a type's behaviours, the game's layers -- rebuilt from a typed --- comma list. One already there keeps its values; one typed away and typed back gets them --- back too, since the setter runs on every keystroke and "pl" has no platformer in it yet; a --- new one starts with the manifest's defaults; a name the manifest lacks is refused. stash is --- where the dropped ones wait, per owner. -local function listFrom(text, list, manifest, stash, what) - local made = {} - - for _, entry in ipairs(list or {}) do - stash[entry.kind] = entry - end - for want in string.gmatch(tostring(text), "[^,%s]+") do - local entry = stash[want] or { kind = want } - - if manifest[want] ~= nil then - fillDefaults(entry, manifest[want].params, true) - made[#made + 1] = entry - else - FORGE.message = "no " .. what .. " called " .. want - end - end - - return made -end - - --- The names in a kinded list, as one line. -local function listText(list) - local names = {} - - for _, entry in ipairs(list or {}) do - names[#names + 1] = entry.kind - end - - return table.concat(names, ", ") -end - - --- A table parameter as text and back. "r=80, g=80, b=100" is a map; "100,-20; 300,-20" a list of --- points (x, y, and z when given); "name=head, bone=b_Head_05, w=0.5; name=body, ..." a list --- of records, one per semicolon; "look=look|examine, take=take|get" a map whose values are --- lists; "the; a; an" a list of words. Enough for a light, a fog, a spawner's points, a --- target's zones, a parser's words, and a hud's bind; the description can hold anything, and --- what this cannot say it leaves alone. -local function tableText(t) - if type(t) ~= "table" then - return t - end - if t[1] ~= nil then - local items = {} - - for _, item in ipairs(t) do - if type(item) ~= "table" then - items[#items + 1] = tostring(item) - elseif (item.x ~= nil) and (item.name == nil) then - items[#items + 1] = tostring(item.x) .. "," .. tostring(item.y or 0) .. ((item.z ~= nil) and ("," .. tostring(item.z)) or "") - elseif item[1] ~= nil then - items[#items + 1] = tostring(item[1]) .. "," .. tostring(item[2] or 0) .. ((item[3] ~= nil) and ("," .. tostring(item[3])) or "") - else - items[#items + 1] = tableText(item) - end - end - return table.concat(items, "; ") - end - local parts = {} - - for _, key in ipairs(sortedKeys(t)) do - local value = t[key] - - if type(value) == "table" then - local words = {} - - for _, word in ipairs(value) do - words[#words + 1] = tostring(word) - end - value = table.concat(words, "|") - end - parts[#parts + 1] = key .. "=" .. tostring(value) - end - - return table.concat(parts, ", ") -end - - --- A typed value: a number, true, false, a list of words split on |, or the text itself. -local function typedWord(value) - value = value:gsub("^%s+", ""):gsub("%s+$", "") - if value:find("|", 1, true) then - local words = {} - - for word in string.gmatch(value, "[^|]+") do - words[#words + 1] = typedWord(word) - end - return words - end - - return tonumber(value) or ((value == "true") and true) or ((value == "false") and false) or value -end - - --- One "k=v, k=v" record as a map. -local function recordFrom(text) - local map = {} - - for name, value in string.gmatch(text, "([%w_]+)%s*=%s*([^,]*)") do - map[name] = typedWord(value) - end - - return map -end - - -local function tableFrom(text) - text = tostring(text) - -- One record with no semicolons is a map: a colour, a bind, a parser's words. - if text:find("=") and not text:find(";", 1, true) then - return recordFrom(text) - end - local items = {} - - for item in string.gmatch(text, "[^;]+") do - if item:find("=") then - items[#items + 1] = recordFrom(item) - else - local numbers = {} - - for number in string.gmatch(item, "-?[%d.]+") do - numbers[#numbers + 1] = tonumber(number) - end - if #numbers >= 2 then - items[#items + 1] = { x = numbers[1], y = numbers[2], z = numbers[3] } - elseif #numbers == 1 then - items[#items + 1] = numbers[1] - else - local word = item:match("^%s*(.-)%s*$") - - if word ~= "" then - items[#items + 1] = word - end - end - end - end - - return items -end - - --- Text on its way into the document, with RML's special characters made harmless. A rule's note --- and a Lua action's code both come from the author and both can contain them. -local function escape(text) - local out = tostring(text or "") - - out = string.gsub(out, "&", "&") - out = string.gsub(out, "<", "<") - out = string.gsub(out, ">", ">") - - return out -end - - --- A path RmlUi takes as it is: the vfs resolves a document's images against the document, so a --- game file is named from the game's own directory, which is where the editor runs. -local function rooted(file) - if file:match("^[/\\]") or file:match("^%a:") then - return file - end - - return lfs.currentdir() .. "/" .. forgeFile(file) -end - - --- What a type looks like, small, for its row in the panel: the sprite itself (one frame of a --- sheet), or a swatch of the box's colour. -local function thumbnail(look) - if look.kind == "sprite" then - local sprite = spriteFor(look.file or "") - - if sprite ~= nil then - local w, h = spriteGetWidth(sprite) / math.max(1, look.frames or 1), spriteGetHeight(sprite) - local fit = THUMB / math.max(1, w, h) - local rect = ((look.frames or 1) > 1) and string.format(' rect="0 0 %d %d"', math.floor(w), h) or "" - - return string.format('', escape(rooted(look.file)), rect, math.floor(w * fit + 0.5), math.floor(h * fit + 0.5)) - end - elseif look.kind == "text" then - return 'Aa' - elseif (look.kind == "none") or (look.kind == "grid") then - return '..' - end - - return string.format('', look.r or 180, look.g or 180, look.b or 190) -end - - --- ===== Entities on the canvas ================================================================= - --- What an entity looks like: its type's look, with anything the entry overrides. -function forgeLook(entry) - local entityType = FORGE.game.types[entry.type] - - return (entityType and entityType.look) or { kind = "none" } -end - - --- An entity's box in overlay coordinates. A look without a size still gets one, so that a text --- entity can be picked up and moved like anything else; a sprite is as big as its image. -function forgeBounds(entry) - local look = forgeLook(entry) - local w = look.w or 40 - local h = look.h or 20 - - if look.kind == "sprite" then - local sprite = spriteFor(look.file or "") - - if sprite ~= nil then - w = spriteGetWidth(sprite) / math.max(1, look.frames or 1) - h = spriteGetHeight(sprite) - end - elseif look.kind == "text" then - w = math.max(60, #((entry.vars and entry.vars.text) or look.text or "") * 9) - end - - w = w * (entry.scale or 1) - h = h * (entry.scale or 1) - -- A line of text hangs from its point, its top left, as the game draws it; a look that - -- stands on its point (anchor feet) is drawn above it. - if look.kind == "text" then - return entry.x or 0, entry.y or 0, w, h - end - if look.anchor == "feet" then - return (entry.x or 0) - w / 2, (entry.y or 0) - h, w, h - end - - return (entry.x or 0) - w / 2, (entry.y or 0) - h / 2, w, h -end - - -function forgeSelect(index) - FORGE.selected = index - FORGE.lastField = nil - forgeRefresh() -end - - --- The topmost entity under a point, so overlapping things pick the one drawn last. -function forgePick(x, y) - local entities = forgeRoom().entities - - for index = #entities, 1, -1 do - local bx, by, bw, bh = forgeBounds(entities[index]) - - if collidePointRect(x, y, bx, by, bw, bh) then - return index - end - end - - return nil -end - - --- ===== The panel ============================================================================== - --- The fields of whatever is selected, one row each, with the one ENTER is on marked. This is the --- detail panel for an entity, a type, a rule, and a key alike: the same rows, the same keys. --- Every row the panel lists is a div with an id, and a click on it goes to forgeRowClick with --- that id: the ids made while a list is built are wired once the list is on screen, since --- setting inner_rml makes new elements. -local pendingRows = {} - -local function row(id, class, inner) - pendingRows[#pendingRows + 1] = id - - return string.format('
%s
', id, class, inner) -end - - -local function wireRows() - for _, id in ipairs(pendingRows) do - local rowId = id - - guiSetHandler(FORGE.gui, FORGE.document, rowId, "click", function() - forgeRowClick(rowId) - end) - end - pendingRows = {} -end - - --- A field's name as the panel shows it: an abbreviation in capitals, the rest as it is. -local function fieldLabel(name) - return (name:gsub("^id$", "ID"):gsub("%.id$", ".ID")) -end - - --- The picker's rows: a window of PICK_ROWS around the one chosen, with what lies past it said, --- and the line typed to narrow it. class is the row's style: a list taking the panel over --- wears the panel's rows, a field's dropdown its own. -local function pickerRows(class, window) - local rows = {} - local shown = forgePickItems() - local from = math.max(1, math.min(FORGE.picker.at - math.floor(window / 2), #shown - window + 1)) - local to = math.min(#shown, from + window - 1) - - if from > 1 then - rows[#rows + 1] = '
' .. (from - 1) .. " more above
" - end - for at = from, to do - rows[#rows + 1] = row("p" .. at, (at == FORGE.picker.at) and (class .. " selected") or class, escape(shown[at].name)) - end - if to < #shown then - rows[#rows + 1] = '
' .. (#shown - to) .. " more below
" - end - rows[#rows + 1] = '
' .. (FORGE.picker.free and "type to narrow, or a new one: " or "type to narrow: ") .. escape(FORGE.picker.filter) .. "_
" - - return table.concat(rows), shown -end - - --- A field's list unfolds under its own row, as a dropdown does, with the help of the row chosen. -local function dropdown() - local rows, shown = pickerRows("choice", DROP_ROWS) - local help = (shown[FORGE.picker.at] or {}).help - - -- Hidden until forgePlace has measured it against the panel: it opens upward when there is - -- no room below. - FORGE.placing = FORGE.frame or 0 - - return '" -end - - -local function fieldRows(fields) - local rows = {} - - for index, name in ipairs(fields.names) do - local class = (name == FORGE.lastField) and "part here" or "part" - local shown = tostring(fields.get(name)) - - -- The field being typed shows its text in its own row: selected whole, so typing - -- replaces it, or with the caret where the arrows put it. One with a list has it - -- unfolded under the row. - if FORGE.editing and (FORGE.editing.key == name) then - local editing = FORGE.editing - - if FORGE.picker and FORGE.picker.field then - FORGE.dropRow = "f" .. index - rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown)) .. dropdown() - else - if editing.selected then - shown = '' .. escape(editing.text) .. "" - else - shown = escape(editing.text:sub(1, editing.cursor)) .. "|" .. escape(editing.text:sub(editing.cursor + 1)) - end - rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. shown) - end - else - rows[#rows + 1] = row("f" .. index, class, escape(fieldLabel(name)) .. ": " .. escape(shown)) - end - end - - return table.concat(rows) -end - - -local function detail(fields, head) - local rows = head or "" - - if fields ~= nil then - rows = rows .. fieldRows(fields) - end - element("detail").inner_rml = rows .. '
' .. escape(FORGE.message) .. "
" - wireRows() -end - - -local function refreshEntities() - local rows = {} - local room = forgeRoom() - - for index, entry in ipairs(room.entities) do - local class = (index == FORGE.selected) and "row selected" or "row" - - rows[#rows + 1] = row("e" .. index, class, escape((entry.id or "?") .. " (" .. tostring(entry.type) .. ")")) - end - if #rows == 0 then - rows[1] = '
no entities here: A adds one
' - end - element("list").inner_rml = table.concat(rows) - detail(forgeFields(), string.format('
room %d of %d: %s
', FORGE.room, #FORGE.game.rooms, escape(room.name))) -end - - -local function refreshKinds() - local rows = {} - - for index, name in ipairs(sortedKeys(FORGE.game.types)) do - local class = (name == FORGE.typeName) and "row selected" or "row" - - rows[#rows + 1] = row("t" .. index, class, thumbnail(FORGE.game.types[name].look or {}) .. escape(name)) - end - if #rows == 0 then - rows[1] = '
no types yet: A adds one
' - end - element("list").inner_rml = table.concat(rows) - detail(forgeFields()) -end - - --- The event sheet. The selected rule is opened in place and its conditions and actions listed --- under it, because a rule only means anything as a whole: seeing "when" without "then" is no use. -local function refreshRules() - local rows = {} - - for index, rule in ipairs(FORGE.game.rules) do - local class = (index == FORGE.rule) and "row selected" or "row" - - rows[#rows + 1] = row("r" .. index, class, index .. ". " .. escape(rule.note or "rule")) - if index == FORGE.rule then - local parts = forgeParts(rule) - - rows[#rows + 1] = row("r" .. index .. "p0", "part", "on " .. escape(rule.on or "frame") .. (rule.each and (" each " .. escape(rule.each)) or "")) - for at, part in ipairs(parts) do - local mark = (at == FORGE.part) and "part here" or "part" - - rows[#rows + 1] = row("r" .. index .. "p" .. at, mark, ((part.kind == "when") and "when " or "then ") .. escape(forgePartText(part.item))) - end - if #parts == 0 then - rows[#rows + 1] = '
(C adds a condition, T an action)
' - end - end - end - if #rows == 0 then - rows[1] = '
no rules yet: N adds one
' - end - element("list").inner_rml = table.concat(rows) - detail(forgeFields(), string.format('
rule %d of %d
', FORGE.rule, #FORGE.game.rules)) -end - - -local function refreshTracks() - local rows = {} - local tracks = forgeRoom().tracks or {} - - for index, track in ipairs(tracks) do - local class = (index == FORGE.track) and "row selected" or "row" - - rows[#rows + 1] = row("k" .. index, class, escape(track.name or "?") .. (track.spawns and " (waves by " or " (by ") .. escape(track.key or "frame") .. ")") - if index == FORGE.track then - for at, key in ipairs(forgeTrackKeys(track)) do - local mark = (at == FORGE.key) and "part here" or "part" - - if track.spawns then - rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s x %s%s%s", tostring(key.at), tostring(key.count or 1), escape(tostring(key.type)), key.from and (" from " .. escape(tostring(key.from))) or "", key.every and (" every " .. tostring(key.every)) or "")) - elseif track.points then - rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s, %s, %s%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.z), key.stop and (" stop " .. escape(key.stop)) or "")) - else - rows[#rows + 1] = row("k" .. index .. "y" .. at, mark, string.format("at %s: %s,%s %sx%s", tostring(key.at), tostring(key.x), tostring(key.y), tostring(key.w), tostring(key.h))) - end - end - if #forgeTrackKeys(track) == 0 then - rows[#rows + 1] = '
(K adds a key at the cursor)
' - end - end - end - if #rows == 0 then - rows[1] = '
no tracks here: N adds one
' - end - element("list").inner_rml = table.concat(rows) - detail(forgeFields(), string.format('
cursor at %s (, . step [ ] leap)
', tostring(FORGE.cursor))) -end - - --- A picker takes the list over while it is up: the manifest is what is being chosen from, and --- the panel is too narrow to show it beside anything else. -local function refreshPicker() - local rows, shown = pickerRows("row", PICK_ROWS) - - element("list").inner_rml = rows - element("detail").inner_rml = escape(FORGE.picker.title) .. "
" .. escape((shown[FORGE.picker.at] or {}).help or "") .. '
UP / DOWN choose, ENTER or a click takes it, ESC leaves it
' - wireRows() -end - - --- The dialogues, as an outline: every dialogue, the selected one's nodes under it, the selected --- node's choices under that, and a choice's actions under that. -function forgeDialogueRows() - local rows = {} - - for _, name in ipairs(sortedKeys(FORGE.game.dialogues)) do - local dialogue = FORGE.game.dialogues[name] - - rows[#rows + 1] = { level = "dialogue", dialogue = name, label = name } - if name == FORGE.dialogue then - for _, nodeName in ipairs(sortedKeys(dialogue.nodes)) do - local node = dialogue.nodes[nodeName] - - rows[#rows + 1] = { level = "node", dialogue = name, node = nodeName, label = nodeName .. ((nodeName == dialogue.start) and " (start)" or "") .. ": " .. (node.who and (node.who .. ": ") or "") .. tostring(node.text or "") } - if nodeName == FORGE.dnode then - for index, choice in ipairs(node.choices or {}) do - rows[#rows + 1] = { level = "choice", dialogue = name, node = nodeName, choice = index, label = index .. ". " .. tostring(choice.text or "") .. (choice.next and (" -> " .. choice.next) or "") } - if index == FORGE.dchoice then - for at, item in ipairs(choice.act or {}) do - rows[#rows + 1] = { level = "act", dialogue = name, node = nodeName, choice = index, act = at, label = "then " .. forgePartText(item) } - end - end - end - end - end - end - end - - return rows -end - - -local dialogueSelect - -local function dialogueRowSelected(row) - if row.level == "dialogue" then - return (row.dialogue == FORGE.dialogue) and (FORGE.dnode == nil) - elseif row.level == "node" then - return (row.node == FORGE.dnode) and (FORGE.dchoice == 0) - elseif row.level == "choice" then - return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (FORGE.dact == 0) - end - - return (row.node == FORGE.dnode) and (row.choice == FORGE.dchoice) and (row.act == FORGE.dact) -end - - -local function refreshDialogues() - local rows = {} - - for index, item in ipairs(forgeDialogueRows()) do - local class = ({ dialogue = "row", node = "part", choice = "part", act = "part" })[item.level] - - if dialogueRowSelected(item) then - class = (item.level == "dialogue") and "row selected" or "part here" - end - rows[#rows + 1] = row("d" .. index, class, escape(item.label)) - end - if #rows == 0 then - rows[1] = '
no dialogues yet: A adds one
' - end - element("list").inner_rml = table.concat(rows) - detail(forgeFields()) -end - - --- Moves the dialogue selection to a row of the outline. -dialogueSelect = function(row) - FORGE.dialogue = row.dialogue - FORGE.dnode = row.node - FORGE.dchoice = row.choice or 0 - FORGE.dact = row.act or 0 - FORGE.lastField = nil -end - - --- A step up or down the outline. -function forgeDialogueStep(by) - local rows = forgeDialogueRows() - local at = 0 - - for index, row in ipairs(rows) do - if dialogueRowSelected(row) then - at = index - end - end - at = math.max(1, math.min(#rows, at + by)) - if rows[at] then - dialogueSelect(rows[at]) - end - forgeRefresh() -end - - --- A name no dialogue, or no node of a dialogue, has. -local function uniqueName(set, stem) - local n = 1 - - if set[stem] == nil then - return stem - end - while set[stem .. n] ~= nil do - n = n + 1 - end - - return stem .. n -end - - --- A adds at the level selected: a dialogue, a node in it, or a choice in the node. -function forgeDialogueAdd() - FORGE.game.dialogues = FORGE.game.dialogues or {} - forgeRemember() - if FORGE.dialogue == nil or FORGE.game.dialogues[FORGE.dialogue] == nil then - local name = uniqueName(FORGE.game.dialogues, "talk") - - FORGE.game.dialogues[name] = { start = "start", nodes = { start = { who = "", text = "...", choices = {} } } } - FORGE.dialogue = name - FORGE.dnode = nil - FORGE.message = "added dialogue " .. name - elseif FORGE.dnode == nil then - local dialogue = FORGE.game.dialogues[FORGE.dialogue] - local name = uniqueName(dialogue.nodes, "node") - - dialogue.nodes[name] = { who = "", text = "...", choices = {} } - FORGE.dnode = name - FORGE.dchoice = 0 - FORGE.message = "added node " .. name - else - local node = FORGE.game.dialogues[FORGE.dialogue].nodes[FORGE.dnode] - - node.choices = node.choices or {} - node.choices[#node.choices + 1] = { text = "..." } - FORGE.dchoice = #node.choices - FORGE.dact = 0 - FORGE.message = "added a choice" - end - FORGE.dirty = true - FORGE.lastField = nil - forgeRefresh() -end - - -function forgeDialogueDelete() - local dialogues = FORGE.game.dialogues or {} - local dialogue = dialogues[FORGE.dialogue or ""] - - if dialogue == nil then - return false - end - forgeRemember() - if FORGE.dnode == nil then - dialogues[FORGE.dialogue] = nil - FORGE.dialogue = sortedKeys(dialogues)[1] - FORGE.message = "dialogue deleted" - elseif FORGE.dchoice == 0 then - dialogue.nodes[FORGE.dnode] = nil - FORGE.dnode = nil - FORGE.message = "node deleted" - elseif FORGE.dact == 0 then - table.remove(dialogue.nodes[FORGE.dnode].choices, FORGE.dchoice) - FORGE.dchoice = 0 - FORGE.message = "choice deleted" - else - table.remove(dialogue.nodes[FORGE.dnode].choices[FORGE.dchoice].act, FORGE.dact) - FORGE.dact = 0 - FORGE.message = "action deleted" - end - FORGE.dirty = true - forgeRefresh() - - return true -end - - --- T adds an action to the selected choice, from the vocabulary. -function forgeDialogueAct(name) - local dialogue = FORGE.game.dialogues[FORGE.dialogue or ""] - local node = dialogue and dialogue.nodes[FORGE.dnode or ""] - local choice = node and node.choices and node.choices[FORGE.dchoice] - local item = forgeMakePart(AUTHOR.actions, name) - - if (choice == nil) or (item == nil) then - return false - end - forgeRemember() - choice.act = choice.act or {} - choice.act[#choice.act + 1] = item - FORGE.dact = #choice.act - FORGE.lastField = nil - FORGE.dirty = true - FORGE.message = "added " .. name - forgeRefresh() - - return true -end - - --- The fields of the selected dialogue, node, choice, or action. -function forgeDialogueFields() - local dialogues = FORGE.game.dialogues or {} - local dialogue = dialogues[FORGE.dialogue or ""] - - if dialogue == nil then - return nil - end - if FORGE.dnode == nil then - return { - names = { "name", "start" }, - get = function(key) return (key == "name") and FORGE.dialogue or dialogue.start end, - set = function(key, value) - if (key == "name") and (value ~= "") and (dialogues[value] == nil) then - dialogues[value] = dialogue - dialogues[FORGE.dialogue] = nil - FORGE.dialogue = value - elseif key == "start" then - dialogue.start = value - end - FORGE.dirty = true - forgeRefresh() - end - } - end - local node = dialogue.nodes[FORGE.dnode] - - if node == nil then - return nil - end - if FORGE.dchoice == 0 then - return { - names = { "name", "who", "text", "seconds", "next" }, - get = function(key) return (key == "name") and FORGE.dnode or node[key] end, - set = function(key, value) - if key == "name" then - if (value ~= "") and (dialogue.nodes[value] == nil) then - dialogue.nodes[value] = node - dialogue.nodes[FORGE.dnode] = nil - if dialogue.start == FORGE.dnode then - dialogue.start = value - end - FORGE.dnode = value - end - elseif (key == "next" or key == "who") and (value == "") then - node[key] = nil - else - node[key] = value - end - FORGE.dirty = true - forgeRefresh() - end - } - end - local choice = node.choices and node.choices[FORGE.dchoice] - - if choice == nil then - return nil - end - if FORGE.dact == 0 then - return { - names = { "text", "when", "next", "once" }, - get = function(key) return (key == "once") and (choice.once == true) or choice[key] end, - set = function(key, value) - if key == "once" then - choice.once = (value == true) or nil - elseif (key == "when" or key == "next") and (value == "") then - choice[key] = nil - else - choice[key] = value - end - FORGE.dirty = true - forgeRefresh() - end - } - end - local item = choice.act and choice.act[FORGE.dact] - - if item == nil then - return nil - end - - return { - names = forgeFieldNames(item), - get = function(key) return item[key] end, - set = function(key, value) - item[key] = value - FORGE.dirty = true - forgeRefresh() - end - } -end - - -function forgeRefresh() - if FORGE.gui == nil then - return - end - FORGE.placing = FORGE.frame or 0 -- Placed on the first draw after this frame. - element("title").inner_rml = escape(FORGE.game.title or "Untitled") .. (FORGE.dirty and " *" or "") - element("grip").inner_rml = "≡ " .. FORGE.mode:sub(1, 1):upper() .. FORGE.mode:sub(2) - if (FORGE.picker ~= nil) and not FORGE.picker.field then - refreshPicker() - elseif FORGE.mode == "types" then - refreshKinds() - elseif FORGE.mode == "rules" then - refreshRules() - elseif FORGE.mode == "tracks" then - refreshTracks() - elseif FORGE.mode == "dialogues" then - refreshDialogues() - else - refreshEntities() - end -end - - --- ===== Opening and closing ==================================================================== - --- 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 - - for _, layer in ipairs(FORGE.game.layers or {}) do - if (layer.kind == "disc") and layer.file then - file = layer.file - end - end - if file ~= FORGE.videoFile then - if FORGE.video then - videoUnload(FORGE.video) - FORGE.video = nil - end - FORGE.videoFile = file - FORGE.videoStart = 0 - if file and (lfs.attributes(forgeFile(file), "mode") == "file") then - 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 - - -function forgeBegin(path) - FORGE.path = path - FORGE.game = authorLoad(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 {} - FORGE.game.rooms = FORGE.game.rooms or {} - if #FORGE.game.rooms == 0 then - FORGE.game.rooms[1] = { name = "main", entities = {} } - end - for _, room in ipairs(FORGE.game.rooms) do - room.entities = room.entities or {} - room.tracks = room.tracks or {} - end - FORGE.room = 1 - FORGE.mode = "entities" - FORGE.selected = nil - FORGE.typeName = sortedKeys(FORGE.game.types)[1] - FORGE.dialogue = sortedKeys(FORGE.game.dialogues or {})[1] - FORGE.dnode = nil - FORGE.dchoice = 0 - FORGE.dact = 0 - FORGE.drawing = nil - FORGE.rule = 1 - FORGE.part = 0 - FORGE.track = 1 - FORGE.key = 0 - FORGE.cursor = 0 - FORGE.lastField = nil - FORGE.editing = nil - FORGE.picker = nil - FORGE.undo = {} - FORGE.redo = {} - FORGE.dirty = false - FORGE.closing = false - FORGE.message = "loaded" - FORGE.gui = guiNew(overlayGetWidth(), overlayGetHeight()) - FORGE.document = guiLoad(FORGE.gui, "Forge/Forge.rml") - guiSetInput(FORGE.gui, true) - guiSetHandler(FORGE.gui, FORGE.document, "grip", "mousedown", function() - local x = mouseGetPosition(0) - - forgePanelGrab(x) - end) - guiSetHandler(FORGE.gui, FORGE.document, "grip", "mouseup", function() - FORGE.panelDrag = nil - forgeRefresh() - end) - -- The game's name at the top is the way back to the game's own form: nothing selected. - guiSetHandler(FORGE.gui, FORGE.document, "title", "click", function() - forgeMode("entities") - forgeSelect(nil) - end) - loadVideo() - forgeSceneBegin() - forgePanelTo(0) - forgeRefresh() - - return true -end - - --- Puts the editor away. The description is dropped, saved or not: the caller decides that first. -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 - spriteUnload(sprite) - end - end - end - if FORGE.video then - videoUnload(FORGE.video) - end - if FORGE.scene then - forgeSceneClear() - nodeDelete(FORGE.scene.camera) - cameraSet(-1) - FORGE.scene = nil - end - FORGE.gui = nil - FORGE.document = nil - FORGE.game = nil - FORGE.path = nil - FORGE.sprites = {} - FORGE.sheets = {} - FORGE.video = nil - FORGE.videoFile = nil - FORGE.editing = nil - FORGE.picker = nil - FORGE.held = nil - FORGE.undo = {} - FORGE.redo = {} -end - - --- Moves an entity. The description is the truth; nothing is cached anywhere else, so saving is --- only writing it back out. -function forgeMove(index, x, y) - local entry = forgeRoom().entities[index] - - entry.x = math.floor(x) - entry.y = math.floor(y) - FORGE.dirty = true - forgeRefresh() -end - - -function forgeSave() - forgeIdFor(FORGE.game) - authorSave(FORGE.game, FORGE.path) - FORGE.dirty = false - FORGE.closing = false - FORGE.message = "saved" - forgeRefresh() - - return FORGE.path -end - - --- The video a description plays over. Its disc layer's when it names one, the menu's otherwise. -function forgeVideo() - for _, layer in ipairs(FORGE.game.layers or {}) do - if (layer.kind == "disc") and layer.file then - return layer.file - end - end - - return "Singe/menuBackground.mkv" -end - - --- Everything a released game needs, in a directory of its own: the compiled script, the runtime --- it loads, its games.dat, and the description it was built from so it can be opened again. --- --- Named export rather than release because forgeRelease is the mouse button coming up; "release" --- is a verb here and a noun there, and the two collided. --- --- The runtime is copied out of Forge, which is the only place it exists: nothing of Forge ships --- inside Singe, so a game carries the copy it needs and stands on its own from then on. --- --- The result is a directory --pack turns into a .game, and it runs on a machine that has never had --- Forge on it. -function forgeExport(folder, name) - local title = FORGE.game.title or "Untitled" - local script = name or string.gsub(title, "[^%w]", "") - local dat - - if script == "" then - script = "Game" - end - -- lfs.mkdir answers false when the directory is already there, which is not a failure. - lfs.mkdir(folder) - if lfs.attributes(folder, "mode") ~= "directory" then - FORGE.message = "cannot make " .. folder - forgeRefresh() - return nil - end - - FORGE.game.source = FORGE.path - local file = io.open(folder .. "/" .. script .. ".singe", "w") - - if file == nil then - FORGE.message = "cannot write into " .. folder - forgeRefresh() - return nil - end - file:write(authorCompile(FORGE.game)) - file:close() - - if not authorCopy(AUTHOR_RUNTIME, folder .. "/Author.singe") then - FORGE.message = "could not copy the runtime" - forgeRefresh() - return nil - end - - -- Every file the description names goes with it, at the same relative name, which is where - -- the runtime looks first; the description itself is unchanged by the trip. - for _, name in ipairs(forgeFilesNamed(FORGE.game)) do - if not forgeCopyInto(name, folder) then - FORGE.message = "could not copy " .. name - forgeRefresh() - return nil - end - end - - -- The description travels with the game, so the release can be opened and edited again. - 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, - -- which is how a packed copy addresses its files too. A game over a disc names its video in - -- the description, and the copy made above is what the entry names; anything else plays over - -- the menu's, since the engine wants one and the game draws over all of it anyway. - local base = folder:match("([^/\\]+)[/\\]*$") or folder - local video = forgeVideo() - - if not video:match("^Singe[/\\]") and not video:match("^[/\\]") and not video:match("^%a:") then - video = base .. "/" .. video - end - -- The id travels with the release: it is what the master service keys scores and updates - -- on, and a game without one cannot be published. Every description has one from the moment - -- it is first saved (forgeIdFor). - -- The credits go with it, under the keys the menu's details panel shows, so whoever made the - -- game is named wherever it is played; lines the keys have no place for go in CREDITS.txt. - local credits = FORGE.game.credits or {} - local lines = { - string.format("\t\tTITLE = %q,", title), - string.format("\t\tGAME_ID = %q,", forgeIdFor(FORGE.game)), - string.format("\t\tSCRIPT = %q,", base .. "/" .. script .. ".singe"), - string.format("\t\tVIDEO = %q,", video), - string.format("\t\tRESOLUTION_X = %d,", overlayGetWidth()), - string.format("\t\tRESOLUTION_Y = %d,", overlayGetHeight()) - } - - -- What the game needs of a machine, as far as its layers say (PLAN.md section 57): a machine - -- that lacks one is told so in the menu rather than failing at start. --pack adds whatever a - -- run of the game used beyond these. - local needs = {} - - for _, layer in ipairs(FORGE.game.layers or {}) do - if (layer.kind == "scene3d") and not needs.SCENE then - needs.SCENE = true - needs[#needs + 1] = "\"SCENE\"" - elseif (layer.kind == "hud") and not needs.GUI then - needs.GUI = true - needs[#needs + 1] = "\"GUI\"" - end - end - if #needs > 0 then - lines[#lines + 1] = "\t\tREQUIRES = { " .. table.concat(needs, ", ") .. " }," - end - for _, credit in ipairs(CREDIT_FIELDS) do - local value = credits[credit.field] - - if type(value) == "number" then - lines[#lines + 1] = string.format("\t\t%s = %d,", credit.key, value) - elseif (type(value) == "string") and (value ~= "") then - lines[#lines + 1] = string.format("\t\t%s = %q,", credit.key, value) - end - end - dat = io.open(folder .. "/games.dat", "w") - if dat ~= nil then - dat:write("-- Written by Forge.\nGAMES = {\n\t{\n" .. table.concat(lines, "\n") .. "\n\t}\n}\n") - dat:close() - end - if credits.notes and (#credits.notes > 0) then - dat = io.open(folder .. "/CREDITS.txt", "w") - if dat ~= nil then - dat:write(title .. "\n\n" .. table.concat(credits.notes, "\n") .. "\n") - dat:close() - end - end - - FORGE.message = "released to " .. folder - forgeRefresh() - - return folder -end - - --- Moves a packed game into place, safely: what is there goes aside first, so a rename that fails --- leaves the previous export where it was rather than a hole where it used to be. The old copy is --- let go once the new one is in place -- pressing the key again rebuilds an export from the --- description, so there is nothing here worth keeping a second copy of. The name it goes aside --- under stops ending in ".game" on purpose, so the menu cannot list it as a second game. -function forgeReplaceFile(path, incoming) - local kept = nil - - if lfs.attributes(path) then - local target = path .. ".old" - local n = 1 - - while lfs.attributes(target) and (n < 100) do - n = n + 1 - target = path .. ".old-" .. n - end - if os.rename(path, target) then - kept = target - end - end - if not os.rename(incoming, path) then - if kept then - os.rename(kept, path) - end - return false - end - if kept then - os.remove(kept) - end - return true -end - - --- Publish: export, pack, and send the package to the master service for review, in one press. --- The service keeps it hidden until the operator approves it, and says which version it was --- given; a later press sends a later version, which is reviewed on its own. The account is the --- machine's, signed in through the menu's Online Account tool, so Forge signs nothing in itself. -function forgePublish() - if forgePublishing then - return - end - if masterSignedIn == nil then - dofile("Singe/Net.singe") - dofile("Singe/Master.singe") - masterLoad() - end - if not masterSignedIn() then - FORGE.message = "sign in first, in the menu's Online Account tool, then press P again" - forgeRefresh() - return - end - local folder = forgeExport(forgeReleaseFolder()) - if not folder then - return - end - -- A file named .game is a container to the engine's own file lookup, and a script cannot read - -- one as a file; so the package is written under a working name, sent, and only then renamed - -- to what it is. - local file = folder .. ".game" - local working = file .. ".part" - if not singePack(folder, working) then - FORGE.message = "could not pack " .. folder - forgeRefresh() - return - end - forgePublishing = true - FORGE.message = "uploading " .. file - forgeRefresh() - masterUpload(working, nil, - function(sent, total) - FORGE.message = string.format("uploading: %d%%", total and math.floor(sent * 100 / total) or 0) - forgeRefresh() - end, - function(ok, reply) - forgePublishing = false - -- The package that was just sent takes its proper name, put there safely so a failure - -- leaves the previous export intact. - forgeReplaceFile(file, working) - if ok then - FORGE.message = string.format("sent for review as version %d; the service will list it once the operator approves it", reply.version or 0) - else - FORGE.message = "not sent: " .. tostring(reply) - end - forgeRefresh() - end) -end - - --- Where X releases to: a folder in the data directory named for the game, which is also what --- the files inside a packed copy are named under (CoinRun/CoinRun.singe), so --pack is given the --- folder and its name. -function forgeReleaseFolder() - local stem = string.gsub(FORGE.game.title or "Untitled", "[^%w]", "") - - return singeGetDataPath() .. ((stem ~= "") and stem or "Game") -end - - --- Every file a description names, by the manifest's word for what takes one: layers, looks, --- behaviours, and actions with a "file" parameter, wherever they occur (rules, and the choices --- of dialogues). The engine's own files (Singe/...) and absolute paths are not the game's to carry. -function forgeFilesNamed(game) - local seen = {} - local found = {} - - local function take(name) - if (type(name) == "string") and (name ~= "") and not seen[name] and not name:match("^Singe[/\\]") and not name:match("^[/\\]") and not name:match("^%a:") then - seen[name] = true - found[#found + 1] = name - end - end - - local function fromParams(entry, params) - for key, kind in pairs(params or {}) do - if kind == "file" then - take(entry[key]) - end - end - end - - local function fromActs(acts) - for _, act in ipairs(acts or {}) do - fromParams(act, (AUTHOR.actions[act[1]] or {}).params) - end - end - - take(game.vocabulary) - for _, name in ipairs((game.credits or {}).licenses or {}) do - take(name) - end - for _, layer in ipairs(game.layers or {}) do - fromParams(layer, (AUTHOR.layers[layer.kind] or {}).params) - end - for _, entityType in pairs(game.types or {}) do - if entityType.look then - fromParams(entityType.look, (AUTHOR.looks[entityType.look.kind] or {}).params) - end - for _, behaviour in ipairs(entityType.behaviours or {}) do - fromParams(behaviour, (AUTHOR.behaviours[behaviour.kind] or {}).params) - end - end - for _, rule in ipairs(game.rules or {}) do - fromActs(rule.act) - end - for _, dialogue in pairs(game.dialogues or {}) do - for _, node in ipairs(dialogue.nodes or dialogue) do - if type(node) == "table" then - fromActs(node.act) - for _, choice in ipairs(node.choices or {}) do - fromActs(choice.act) - end - end - end - end - table.sort(found) - - return found -end - - --- Copies a named file into a folder at the same relative name, making the directories on the way. -function forgeCopyInto(name, folder) - local at = folder - - for part in name:gmatch("([^/\\]+)[/\\]") do - at = at .. "/" .. part - lfs.mkdir(at) - end - - return authorCopy(forgeFile(name), folder .. "/" .. name) -end - - --- Compiles what is on screen and hands back the path, so the caller can dofile it and play. The --- runtime goes beside it, because that is where the compiled game looks for it. What the checker --- has to say goes to the console and the panel. -function forgeBuild(outputFile, fromRoom) - local problems = authorCheck(FORGE.game) - local game = FORGE.game - - FORGE.game.source = FORGE.path - -- Playing from here: a copy with the current room first, so the game begins in it. - if fromRoom and (fromRoom > 1) and FORGE.game.rooms[fromRoom] then - game = {} - for key, value in pairs(FORGE.game) do - game[key] = value - end - game.rooms = {} - for index = 0, #FORGE.game.rooms - 1 do - game.rooms[index + 1] = FORGE.game.rooms[((fromRoom - 1 + index) % #FORGE.game.rooms) + 1] - end - end - local file = assert(io.open(outputFile, "w")) - - file:write(authorCompile(game)) - file:close() - authorCopy(AUTHOR_RUNTIME, (string.match(outputFile, "^(.*[/\\])") or "") .. "Author.singe") - for _, problem in ipairs(problems) do - debugPrint("Forge: " .. problem) - end - FORGE.message = (#problems == 0) and "built" or ("built with " .. #problems .. " problems: " .. problems[1]) - forgeRefresh() - - return outputFile -end - - --- ===== The pointer on the canvas ============================================================== - -function forgePress(x, y) - -- Whatever is under the chrome belongs to it, wherever it has been dragged to. The GUI has - -- already had its chance at this click; checking again is belt and braces for the case where - -- an element did not claim it. - -- The tab first: it sits outside the panel, so it is reachable, and it is what moves the panel. - local tx, ty, tw, th = forgeTab() - - if not FORGE.panelHidden and collidePointRect(x, y, tx, ty, tw, th) then - forgePanelGrab(x) - return - end - if forgeOverPanel(x) then - return - end - -- A polygon being drawn takes every press as a corner. - if FORGE.drawing then - FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(x) - FORGE.drawing.points[#FORGE.drawing.points + 1] = math.floor(y) - FORGE.message = (#FORGE.drawing.points / 2) .. " corners; V or ENTER closes it, ESC drops it" - forgeRefresh() - return - end - -- In the tracks, a press on the selected track's box picks the box up: the key at the cursor, - -- made if there is none. - if FORGE.mode == "tracks" then - local box = forgeCursorBox() - - if box and collidePointRect(x, y, box.x, box.y, box.w, box.h) then - forgeRemember() - FORGE.held = { key = forgeKeyAtCursor(true) } - FORGE.grabX = x - box.x - FORGE.grabY = y - box.y - forgeRefresh() - end - return - end - -- A gizmo handle first: it moves or turns the selected entity, whatever lies under it. - local handle = forgeGizmoAt(x, y) - - if handle then - forgeGizmoHold(handle, x, y) - return - end - if FORGE.scene then - FORGE.held = forgePick3D(x, y) - if FORGE.held ~= nil then - local entry = forgeRoom().entities[FORGE.held] - local gx, gz = forgeGroundPoint(x, y, entry.y or 0) - - forgeRemember() - FORGE.grabX = gx and (gx - (entry.x or 0)) or 0 - FORGE.grabY = gz and (gz - (entry.z or 0)) or 0 - FORGE.mode = "entities" - forgeSelect(FORGE.held) - end - return - end - FORGE.held = forgePick(x, y) - if FORGE.held ~= nil then - local entry = forgeRoom().entities[FORGE.held] - - -- One undo step for the whole drag, taken before it starts. - forgeRemember() - FORGE.grabX = x - entry.x - FORGE.grabY = y - entry.y - FORGE.mode = "entities" - forgeSelect(FORGE.held) - elseif FORGE.mode == "entities" then - -- A press on nothing selects nothing, which is the game's own form. - forgeSelect(nil) - end -end - - -function forgeRelease() - FORGE.held = nil - FORGE.panelDrag = nil -end - - --- A click on a row of the panel, by the row's id: e3 an entity, t2 a type, r1 a rule and r1p2 --- its second part, k1 a track and k1y2 its second key, d4 the fourth row of the dialogue outline, --- p3 the third thing a picker offers, f2 the second field of the form. A row selects what it --- names, as the arrows would; a field starts typing it, as ENTER would after walking to it. -function forgeRowClick(id) - local letter, first, second = id:match("^(%a)(%d+)%a?(%d*)$") - local room = forgeRoom() - - first = tonumber(first) - second = tonumber(second) - if letter == "e" then - FORGE.mode = "entities" - forgeSelect(first) - elseif letter == "t" then - FORGE.typeName = sortedKeys(FORGE.game.types)[first] or FORGE.typeName - FORGE.lastField = nil - forgeRefresh() - elseif letter == "r" then - if FORGE.game.rules[first] ~= nil then - FORGE.rule = first - FORGE.part = math.min(second or 0, #forgeParts(FORGE.game.rules[first])) - end - FORGE.lastField = nil - forgeRefresh() - elseif letter == "k" then - if room.tracks[first] ~= nil then - local keys = forgeTrackKeys(room.tracks[first]) - - FORGE.track = first - FORGE.key = ((second or 0) <= #keys) and (second or 0) or 0 - if FORGE.key > 0 then - forgeCursorTo(keys[FORGE.key].at) - end - end - FORGE.lastField = nil - forgeRefresh() - elseif letter == "d" then - local item = forgeDialogueRows()[first] - - if item then - dialogueSelect(item) - end - forgeRefresh() - elseif letter == "p" then - local picker = FORGE.picker - local shown = picker and forgePickItems() or {} - - if shown[first] then - FORGE.picker = nil - picker.choose(shown[first].name) - end - elseif letter == "f" then - forgeEditField(first) - end -end - - --- Starts typing one field of the form, by its place in it: the field before it is made the last --- one walked, and the walk goes on from there. -function forgeEditField(index) - local fields = forgeFields() - - if (fields == nil) or (fields.names[index] == nil) then - return false - end - if FORGE.editing ~= nil then - forgeEditCommit() - end - FORGE.lastField = (index > 1) and fields.names[index - 1] or nil - - return forgeEditNext() -end - - --- Whether an x lies under the chrome as it is placed right now. -function forgeOverPanel(x) - return not FORGE.panelHidden and (x >= FORGE.panelX) and (x < FORGE.panelX + PANEL_W) -end - - --- Hides the chrome, or shows it again: a polygon is drawn with the whole canvas bare. -function forgePanelShow(on) - FORGE.panelHidden = not on - if FORGE.gui ~= nil then - element("panel").style.display = on and "block" or "none" - end -end - - --- Moves the chrome. Clamped so a panel dragged off the edge can always be got back. -function forgePanelTo(x) - local limit = overlayGetWidth() - PANEL_W - - FORGE.panelX = math.max(0, math.min(math.floor(x), limit)) - if FORGE.gui ~= nil then - element("panel").style.left = FORGE.panelX .. "px" - end -end - - --- The tab that drags the panel, in overlay coordinates. It is deliberately drawn on the *canvas*, --- just outside the panel, rather than being an element in the document: a press on the canvas is --- the one pointer path this editor has been shown to receive reliably, whereas whether a document --- gets the press at all depends on the GUI routing in _guiPointer, which the spike in FORGE.md --- section 1 found silent under a window manager. Drawing the tab ourselves needs none of that. -function forgeTab() - return FORGE.panelX + PANEL_W, TAB_Y, TAB_W, TAB_H -end - - -function forgePanelGrab(x) - FORGE.panelDrag = x - FORGE.panelX -end - - -function forgeDrag(x, y) - if FORGE.panelDrag ~= nil then - forgePanelTo(x - FORGE.panelDrag) - elseif (type(FORGE.held) == "table") and FORGE.held.gizmo then - -- How far along the arm the pointer has gone, as a fraction of the arm, is how far along - -- the axis the entity goes. - local held = FORGE.held - local reach = held.dx * held.dx + held.dy * held.dy - local entry = forgeRoom().entities[FORGE.selected] - - if (held.gizmo == "ry") or (held.gizmo == "rz") then - -- The pointer's angle round the entity on screen, from where the press was, turns it: - -- with the screen's y downward, clockwise on the canvas is a positive rz, while ry - -- turns the other way seen from above. - local angle = math.atan(y - held.sy, x - held.sx) - local turn = math.deg(angle - held.angle) - - entry[held.gizmo] = math.floor(held.from + ((held.gizmo == "rz") and turn or -turn) + 0.5) % 360 - if entry[held.gizmo] == 0 then - entry[held.gizmo] = nil - end - FORGE.dirty = true - forgeRefresh() - elseif (reach > 0) and entry then - local t = ((x - held.x) * held.dx + (y - held.y) * held.dy) / reach - - entry[held.gizmo] = math.floor((held.from + t * GIZMO_LEN) * 10 + 0.5) / 10 - FORGE.dirty = true - forgeRefresh() - end - elseif type(FORGE.held) == "table" then - FORGE.held.key.x = math.floor(x - FORGE.grabX) - FORGE.held.key.y = math.floor(y - FORGE.grabY) - FORGE.dirty = true - forgeRefresh() - elseif (FORGE.held ~= nil) and FORGE.scene then - local entry = forgeRoom().entities[FORGE.held] - local gx, gz = forgeGroundPoint(x, y, entry.y or 0) - - if gx then - forgeMove3D(FORGE.held, gx - FORGE.grabX, entry.y or 0, gz - FORGE.grabY) - end - elseif FORGE.held ~= nil then - forgeMove(FORGE.held, x - FORGE.grabX, y - FORGE.grabY) - end -end - - --- Moves an entity in the scene, to a tenth of a unit. -function forgeMove3D(index, x, y, z) - local entry = forgeRoom().entities[index] - - entry.x = math.floor(x * 10 + 0.5) / 10 - entry.y = math.floor(y * 10 + 0.5) / 10 - entry.z = math.floor(z * 10 + 0.5) / 10 - FORGE.dirty = true - forgeRefresh() -end - - --- ===== The 3D preview ========================================================================= --- --- A room in a game with a scene3d layer is shown as the scene itself: the room's entities are --- built from their types' looks, exactly as the runtime builds them, under an editor camera that --- orbits whatever is selected. Nothing steps -- no behaviours, no physics -- so it is the --- description that is on screen, not a running game. - -function forgeIs3D() - for _, layer in ipairs(FORGE.game.layers or {}) do - if layer.kind == "scene3d" then - return layer - end - end - - return nil -end - - --- The entities as a string, so the preview is rebuilt only when what they are changes; where --- they are is synced every frame instead. -local function sceneStamp() - local parts = {} - - for _, entry in ipairs(forgeRoom().entities) do - local entityType = FORGE.game.types[entry.type] or {} - local look = entityType.look or {} - - parts[#parts + 1] = tostring(entry.type) .. ":" .. tostring(look.kind) .. ":" .. tostring(look.file) .. ":" .. tostring(look.shape) .. ":" .. tostring(look.w) .. ":" .. tostring(look.h) .. ":" .. tostring(look.d) .. ":" .. tostring(look.scale) .. ":" .. tostring(look.r) .. tostring(look.g) .. tostring(look.b) - end - - return table.concat(parts, "|") .. "#" .. FORGE.room -end - - -function forgeSceneClear() - if FORGE.scene == nil then - return - end - for _, node in pairs(FORGE.scene.nodes) do - nodeDelete(node) - end - FORGE.scene.nodes = {} - FORGE.scene.stamp = nil -end - - -function forgeSceneBegin() - local layer = forgeIs3D() - - if layer == nil then - return - end - if not sceneBegun then - AUTHOR.layers.scene3d.begin(layer) - sceneBegun = true - end - FORGE.scene = { nodes = {}, camera = nodeNew(), yaw = 30, pitch = 25, distance = 12, stamp = nil } - cameraSet(FORGE.scene.camera) - forgeSceneSync() -end - - --- Builds what is missing and moves what is there. Called every frame the preview is drawn. -function forgeSceneSync() - local scene = FORGE.scene - local room = forgeRoom() - - if scene == nil then - return - end - if scene.stamp ~= sceneStamp() then - forgeSceneClear() - for index, entry in ipairs(room.entities) do - local entityType = FORGE.game.types[entry.type] or {} - local look = entityType.look or { kind = "none" } - local instance = { node = nodeNew(), look = look, vars = { text = (entry.vars and entry.vars.text) or look.text }, nodes = {}, preview = true } - local maker = AUTHOR.looks[look.kind] - - if maker and maker.load and (look.kind ~= "sprite") and (look.kind ~= "text") then - local ok, err = pcall(maker.load, instance) - - if not ok then - debugPrint("Forge: " .. tostring(entry.id) .. " could not be shown: " .. tostring(err)) - end - end - scene.nodes[index] = instance.node - end - scene.stamp = sceneStamp() - end - for index, entry in ipairs(room.entities) do - if scene.nodes[index] then - nodeSetPosition(scene.nodes[index], entry.x or 0, entry.y or 0, entry.z or 0) - nodeSetRotation(scene.nodes[index], entry.rx or 0, entry.ry or 0, entry.rz or 0) - nodeSetScale(scene.nodes[index], entry.scale or 1) - end - end - forgeSceneCamera() -end - - --- What the editor camera looks at: the selected entity, or the room's middle. -local function sceneFocus() - local entry = forgeRoom().entities[FORGE.selected] - - if entry then - return entry.x or 0, entry.y or 0, entry.z or 0 - end - - return 0, 0, 0 -end - - -function forgeSceneCamera() - local scene = FORGE.scene - local fx, fy, fz = sceneFocus() - local yaw = math.rad(scene.yaw) - local pitch = math.rad(scene.pitch) - - nodeSetPosition(scene.camera, fx + math.sin(yaw) * math.cos(pitch) * scene.distance, fy + math.sin(pitch) * scene.distance, fz + math.cos(yaw) * math.cos(pitch) * scene.distance) - nodeLookAt(scene.camera, fx, fy, fz) -end - - -function forgeOrbit(yawBy, pitchBy, zoomBy) - local scene = FORGE.scene - - scene.yaw = scene.yaw + yawBy - scene.pitch = math.max(-85, math.min(85, scene.pitch + pitchBy)) - scene.distance = math.max(0.5, scene.distance * zoomBy) - forgeSceneCamera() -end - - --- The gizmo: three arms from the selected entity along the world axes, each ending in a handle --- that drags the entity along that axis alone. Arms are found by where their ends project. -function forgeGizmoHandles() - local entry = forgeRoom().entities[FORGE.selected] - - if entry == nil then - return {} - end - local x, y, z = entry.x or 0, entry.y or 0, entry.z or 0 - local sx, sy, _, inFront = sceneProject(x, y, z) - local handles = {} - - if not inFront then - return {} - end - for _, axis in ipairs({ { name = "x", dx = 1, dy = 0, dz = 0, r = 255, g = 90, b = 90 }, { name = "y", dx = 0, dy = 1, dz = 0, r = 90, g = 255, b = 90 }, { name = "z", dx = 0, dy = 0, dz = 1, r = 90, g = 140, b = 255 } }) do - local tx, ty, _, tipFront = sceneProject(x + axis.dx * GIZMO_LEN, y + axis.dy * GIZMO_LEN, z + axis.dz * GIZMO_LEN) - - if tipFront then - handles[#handles + 1] = { axis = axis.name, sx = sx, sy = sy, tx = tx, ty = ty, r = axis.r, g = axis.g, b = axis.b } - end - end - -- The turn handle: a fourth arm the way the entity faces (its -Z, turned by ry), dragged - -- round the entity to turn it about Y. - local yaw = math.rad(entry.ry or 0) - local fx, fz = -math.sin(yaw) * GIZMO_LEN * 0.7, -math.cos(yaw) * GIZMO_LEN * 0.7 - local tx, ty, _, tipFront = sceneProject(x + fx, y, z + fz) - - if tipFront then - handles[#handles + 1] = { axis = "ry", sx = sx, sy = sy, tx = tx, ty = ty, r = 255, g = 200, b = 60 } - end - - return handles -end - - --- The gizmo handle under a screen point, if any: the scene's arms, or the 2D turn handle. -function forgeGizmoAt(x, y) - local handles = FORGE.scene and forgeGizmoHandles() or { forgeTurnHandle() } - - for _, handle in ipairs(handles) do - if math.sqrt((handle.tx - x) ^ 2 + (handle.ty - y) ^ 2) <= GIZMO_GRAB then - return handle - end - end - - return nil -end - - --- Takes a gizmo handle: where the press was, along the arm and round the entity, is what a drag --- is measured from. -function forgeGizmoHold(handle, x, y) - local entry = forgeRoom().entities[FORGE.selected] - - forgeRemember() - FORGE.held = { gizmo = handle.axis, from = entry[handle.axis] or 0, x = x, y = y, dx = handle.tx - handle.sx, dy = handle.ty - handle.sy, sx = handle.sx, sy = handle.sy, angle = math.atan(y - handle.sy, x - handle.sx) } - FORGE.message = ((handle.axis == "ry") or (handle.axis == "rz")) and "turning" or ("moving along " .. handle.axis) -end - - --- An arm from the entity to its handle, the handle a small box named for its axis. -function forgeDrawHandle(handle) - if handle == nil then - return - end - colorForeground(handle.r, handle.g, handle.b, 255) - overlayLine(handle.sx, handle.sy, handle.tx, handle.ty) - overlayBox(handle.tx - 4, handle.ty - 4, handle.tx + 4, handle.ty + 4) - fontPrint(handle.tx + 6, handle.ty - 8, handle.axis) -end - - --- The turn handle on the 2D canvas: an arm from the selected entity the way its rz points, past --- its box, dragged round the entity to turn it. The counterpart of the scene's ry arm. -function forgeTurnHandle() - local entry = forgeRoom().entities[FORGE.selected] - - if (entry == nil) or FORGE.scene then - return nil - end - local _, _, w, h = forgeBounds(entry) - local reach = math.max(w, h) / 2 + TURN_REACH - local turn = math.rad(entry.rz or 0) - - return { axis = "rz", sx = entry.x, sy = entry.y, tx = entry.x + math.cos(turn) * reach, ty = entry.y + math.sin(turn) * reach, r = 255, g = 200, b = 60 } -end - - --- The entity nearest a screen point, within reach, by where it projects. -function forgePick3D(x, y) - local best, bestFar = nil, PICK_REACH - - for index, entry in ipairs(forgeRoom().entities) do - local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0) - - if inFront then - local far = math.sqrt((sx - x) ^ 2 + (sy - y) ^ 2) - - if far < bestFar then - best, bestFar = index, far - end - end - end - - return best -end - - --- Where the pointer's ray crosses the horizontal plane through a height, so a drag moves an --- entity across the floor it stands on rather than toward the camera. -function forgeGroundPoint(x, y, height) - local x0, y0, z0 = sceneUnproject(x, y, 0) - local x1, y1, z1 = sceneUnproject(x, y, 10) - local dy = y1 - y0 - - if math.abs(dy) < 0.0001 then - return nil - end - local t = (height - y0) / dy - - if t < 0 then - return nil - end - - return x0 + (x1 - x0) * t, z0 + (z1 - z0) * t -end - - --- ===== Drawing ================================================================================ - -local function fill(x, y, w, h) - for row = math.floor(y), math.floor(y + h) do - overlayLine(x, row, x + w, row) - end -end - - --- Draws the description. Note that this draws the *description*, not a running game: there are --- no physics here, which is why an entity can be dragged through a wall. --- The first element under one with a class, searched depth first, or nil. -local function findClass(under, class) - for index = 1, #under.child_nodes do - local child = under.child_nodes[index] - - if child:IsClassSet(class) then - return child - end - local found = findClass(child, class) - - if found then - return found - end - end - - return nil -end - - --- Where an element's top is within a box it is inside, in the box's content coordinates: a --- scrolling box is an offset parent, so offsets are summed up the chain until the box. -local function offsetWithin(what, box) - local top = what.offset_top - local parent = what.offset_parent - - while parent and (parent.id ~= box.id) do - top = top + parent.offset_top - parent = parent.offset_parent - end - - return top -end - - --- Scrolls a row into its box's view, moving the box as little as possible. -local function scrollTo(box, row) - local top = offsetWithin(row, box) - local bottom = top + row.offset_height - - if top < box.scroll_top then - box.scroll_top = top - elseif bottom > box.scroll_top + box.client_height then - box.scroll_top = bottom - box.client_height - end -end - - --- What can only be done once the panel is laid out, which the engine does at the end of the --- frame the refresh happened in: the chosen row of the list and the field being edited are --- scrolled into view, and a dropdown that would run off the bottom of the panel opens upward --- instead. Run at the start of every draw, and done on the first draw after the frame the --- refresh happened in: a key or a click is handled before that frame's draw, and the elements --- it made measure nothing until the engine has laid them out. A refresh asks for it again. -function forgePlace() - FORGE.frame = (FORGE.frame or 0) + 1 - if not FORGE.placing or (FORGE.gui == nil) or (FORGE.frame <= FORGE.placing + 1) then - return - end - FORGE.placing = false - local list = element("list") - local detail = element("detail") - local chosen = findClass(list, "selected") or findClass(list, "here") - local field = findClass(detail, "here") - local drop = element("drop") - - if chosen then - scrollTo(list, chosen) - end - if field then - scrollTo(detail, field) - end - if drop and FORGE.dropRow and element(FORGE.dropRow) then - local row = element(FORGE.dropRow) - local panel = element("panel") - local top = offsetWithin(detail, panel) + offsetWithin(row, detail) - detail.scroll_top - local bottom = top + row.offset_height + drop.offset_height - - if (bottom > panel.offset_height) and (top - drop.offset_height >= 0) then - -- Up: a negative top margin lifts it from its place under the row to above it, - -- and it stays with the row when the box scrolls. - drop.style["margin-top"] = tostring(-(drop.offset_height + row.offset_height)) .. "px" - end - drop.style.visibility = "visible" - end -end - - -function forgeDraw(pointerX, pointerY) - local room = forgeRoom() - - forgePlace() - if FORGE.scene then - forgeDraw3D(pointerX, pointerY) - return - end - colorBackground(18, 18, 26, 255) - overlayClear() - if FORGE.video then - videoDraw(FORGE.video, 0, 0, overlayGetWidth(), overlayGetHeight()) - end - -- Everything is drawn, including whatever is under the chrome. The panel is opaque and - -- slides, so it covers rather than hides: drag it aside and what was beneath it is there, - -- in the right place, and can be picked up. Clipping was the earlier answer and it lied. - for index, entry in ipairs(room.entities) do - local bx, by, bw, bh = forgeBounds(entry) - local look = forgeLook(entry) - local sprite = (look.kind == "sprite") and spriteFor(look.file or "") or nil - - if look.kind == "text" then - local text = (entry.vars and entry.vars.text) or look.text or "" - - colorForeground(look.r or 235, look.g or 235, look.b or 245, 255) - if text ~= "" then - fontPrint(entry.x, entry.y, text) - end - overlayBox(bx, by, bx + bw, by + bh) - elseif sprite ~= nil then - -- Turned as the entity's rz says, on the shared image: the canvas has time to spare. - -- A sheet shows its first frame, turned the same way. - if (look.frames or 1) > 1 then - spriteRotateFrame(sheetFor(look.file, look.frames), entry.rz or 0, 1) - spriteDrawRotatedFrame(sheetFor(look.file, look.frames), entry.x, entry.y, entry.scale or 1) - else - spriteRotate(sprite, entry.rz or 0) - spriteScale(sprite, entry.scale or 1) - spriteDraw(sprite, entry.x, entry.y, true) - end - elseif look.kind == "none" then - colorForeground(120, 120, 150, 255) - overlayBox(bx, by, bx + bw, by + bh) - overlayLine(bx, by, bx + bw, by + bh) - else - colorForeground(look.r or 180, look.g or 180, look.b or 190, 255) - fill(bx, by, bw, bh) - end - if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then - colorForeground(255, 210, 70, 255) - overlayBox(bx - 2, by - 2, bx + bw + 2, by + bh + 2) - overlayBox(entry.x - HANDLE, entry.y - HANDLE, entry.x + HANDLE, entry.y + HANDLE) - forgeDrawHandle(forgeTurnHandle()) - end - end - -- The walk areas, as outlines, so a room's floor can be seen against its picture; hotspot - -- outlines the same; and the polygon being drawn, open at its last corner. - colorForeground(90, 200, 120, 255) - for _, polygon in ipairs(room.walk or {}) do - for at = 1, #polygon - 1, 2 do - local nx = polygon[at + 2] or polygon[1] - local ny = polygon[at + 3] or polygon[2] - - overlayLine(polygon[at], polygon[at + 1], nx, ny) - end - end - colorForeground(200, 160, 90, 255) - for _, entry in ipairs(room.entities) do - local entityType = FORGE.game.types[entry.type] - local spot = entityType and behaviourOf(entityType, "hotspot") or nil - local polygon = spot and authorNumbers(spot.polygon) or nil - - for at = 1, (polygon and #polygon - 1 or 0), 2 do - overlayLine(polygon[at], polygon[at + 1], polygon[at + 2] or polygon[1], polygon[at + 3] or polygon[2]) - end - end - if FORGE.drawing then - local points = FORGE.drawing.points - - colorForeground(255, 230, 120, 255) - for at = 1, #points - 3, 2 do - overlayLine(points[at], points[at + 1], points[at + 2], points[at + 3]) - end - for at = 1, #points - 1, 2 do - overlayBox(points[at] - 3, points[at + 1] - 3, points[at] + 3, points[at + 1] + 3) - end - end - -- Every track's box at the cursor, the selected one brighter, and the strip along the bottom. - for index, track in ipairs(room.tracks) do - local box = authorTrackBoxAt(track, FORGE.cursor) - - if box then - if (FORGE.mode == "tracks") and (index == FORGE.track) then - colorForeground(255, 90, 90, 255) - else - colorForeground(150, 70, 70, 255) - end - overlayBox(box.x, box.y, box.x + box.w, box.y + box.h) - end - end - if FORGE.mode == "tracks" then - forgeDrawStrip() - end - -- The panel's drag tab. - local tx, ty, tw, th = forgeTab() - - colorForeground(70, 70, 96, 255) - fill(tx, ty, tw, th) - colorForeground(255, 207, 74, 255) - overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) - overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) - overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) - - -- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it. - guiDraw(FORGE.gui) - if pointerX ~= nil then - colorForeground(255, 220, 60, 255) - overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) - overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) - end -end - - --- The scene draws itself; the overlay carries the selection, the rail, the tab, and the chrome. -function forgeDraw3D(pointerX, pointerY) - local room = forgeRoom() - - forgeSceneSync() - -- The overlay is clear glass over the scene, whatever a flat room left the ground set to. - colorBackground(0, 0, 0, 0) - overlayClear() - for index, entry in ipairs(room.entities) do - local sx, sy, _, inFront = sceneProject(entry.x or 0, entry.y or 0, entry.z or 0) - - if inFront then - if (FORGE.mode ~= "tracks") and (index == FORGE.selected) then - colorForeground(255, 210, 70, 255) - overlayBox(sx - HANDLE - 2, sy - HANDLE - 2, sx + HANDLE + 2, sy + HANDLE + 2) - else - colorForeground(120, 120, 150, 255) - end - overlayBox(sx - HANDLE, sy - HANDLE, sx + HANDLE, sy + HANDLE) - fontPrint(sx + HANDLE + 3, sy - HANDLE, tostring(entry.id)) - end - end - -- The gizmo on the selected entity. - if FORGE.mode ~= "tracks" then - for _, handle in ipairs(forgeGizmoHandles()) do - forgeDrawHandle(handle) - end - end - -- A rail is drawn as its polyline, its stops marked; every track with points is shown, the - -- selected one brighter. - for index, track in ipairs(room.tracks) do - local points = track.points or {} - local bright = (FORGE.mode == "tracks") and (index == FORGE.track) - - for at = 1, #points - 1 do - local a, b = points[at], points[at + 1] - - lineDraw(a.x or 0, a.y or 0, a.z or 0, b.x or 0, b.y or 0, b.z or 0, bright and 255 or 120, bright and 90 or 60, bright and 90 or 60) - end - for at, point in ipairs(points) do - local sx, sy, _, inFront = sceneProject(point.x or 0, point.y or 0, point.z or 0) - - if inFront then - if bright and (at == FORGE.key) then - colorForeground(255, 210, 70, 255) - elseif point.stop then - colorForeground(255, 90, 90, 255) - else - colorForeground(150, 70, 70, 255) - end - overlayBox(sx - 4, sy - 4, sx + 4, sy + 4) - if point.stop then - fontPrint(sx + 6, sy - 6, tostring(point.stop)) - end - end - end - end - if FORGE.mode == "tracks" then - forgeDrawStrip() - end - local tx, ty, tw, th = forgeTab() - - colorForeground(70, 70, 96, 255) - fill(tx, ty, tw, th) - colorForeground(255, 207, 74, 255) - overlayLine(tx + 4, ty + 16, tx + tw - 4, ty + 16) - overlayLine(tx + 4, ty + 26, tx + tw - 4, ty + 26) - overlayLine(tx + 4, ty + 36, tx + tw - 4, ty + 36) - -- The pointer goes on after the chrome: what is drawn after guiDraw is composited over it. - guiDraw(FORGE.gui) - if pointerX ~= nil then - colorForeground(255, 220, 60, 255) - overlayLine(pointerX - 7, pointerY, pointerX + 7, pointerY) - overlayLine(pointerX, pointerY - 7, pointerX, pointerY + 7) - end -end - - --- The timeline: a ruler along the bottom with the selected track's keys as ticks and the cursor. --- The ruler runs from the first key to the last, or over a screen's worth when there are none. -function forgeDrawStrip() - local track = forgeRoom().tracks[FORGE.track] - local left = FORGE.panelX + PANEL_W + TAB_W + STRIP_PAD - local right = overlayGetWidth() - STRIP_PAD - local top = overlayGetHeight() - STRIP_H - local first = 0 - local last = 100 - - if left > right - 40 then - left = STRIP_PAD - end - local keys = track and forgeTrackKeys(track) or nil - - if keys and #keys > 0 then - first = math.huge - last = -math.huge - for _, key in ipairs(keys) do - first = math.min(first, tonumber(key.at) or 0) - last = math.max(last, tonumber(key.at) or 0) - end - if last == first then - last = first + 1 - end - end - local span = last - first - - first = math.min(first, FORGE.cursor) - last = math.max(last, FORGE.cursor) - span = math.max(last - first, 1) - colorForeground(30, 30, 44, 255) - fill(left - STRIP_PAD, top, right - left + STRIP_PAD * 2, STRIP_H) - colorForeground(110, 110, 140, 255) - overlayLine(left, top + STRIP_H / 2, right, top + STRIP_H / 2) - for at, key in ipairs(keys or {}) do - local x = left + ((tonumber(key.at) or 0) - first) / span * (right - left) - - if at == FORGE.key then - colorForeground(255, 210, 70, 255) - else - colorForeground(255, 90, 90, 255) - end - overlayLine(x, top + 6, x, top + STRIP_H - 6) - end - local cx = left + (FORGE.cursor - first) / span * (right - left) - - colorForeground(255, 255, 255, 255) - overlayLine(cx, top + 2, cx, top + STRIP_H - 2) - fontPrint(left, top + 4, tostring(FORGE.cursor)) -end - - --- ===== Undo and redo ========================================================================== --- --- The description is small and plain data, so undo is a copy of the whole thing taken before --- each change, and undoing is putting the last copy back. Nothing has to know how to reverse --- itself, which is what keeps every later feature from having to. Redo is the same the other --- way: what undo took out is kept until something else changes. - --- Keeps a copy on one of the two stacks, oldest dropped once it is full. -local function keep(stack) - stack[#stack + 1] = copyOf(FORGE.game) - if #stack > UNDO_MAX then - table.remove(stack, 1) - end -end - - --- Call before changing the description. A drag is one step, a typed value is one step. A change --- made by hand is the end of the redo history: what was undone before it no longer applies. -function forgeRemember() - keep(FORGE.undo) - FORGE.redo = {} -end - - --- Puts a copy back in place of the description, keeping the current one on the other stack. -local function swapTo(copy, other, message) - keep(other) - FORGE.game = copy - FORGE.editing = nil -- Whatever was being typed referred to the description just replaced. - FORGE.held = nil - FORGE.room = math.max(1, math.min(FORGE.room, #FORGE.game.rooms)) - local room = forgeRoom() - - FORGE.selected = FORGE.selected and math.min(FORGE.selected, #room.entities) or nil - if FORGE.selected == 0 then - FORGE.selected = nil - end - if FORGE.game.types[FORGE.typeName or ""] == nil then - FORGE.typeName = sortedKeys(FORGE.game.types)[1] - end - FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules)) - FORGE.part = math.min(FORGE.part, #forgeParts(FORGE.game.rules[FORGE.rule] or {})) - FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks)) - FORGE.key = math.min(FORGE.key, #((room.tracks[FORGE.track] or {}).boxes or {})) - FORGE.dirty = true - FORGE.message = message - loadVideo() - forgeRefresh() -end - - -function forgeUndo() - local was = table.remove(FORGE.undo) - - if was == nil then - FORGE.message = "nothing to undo" - forgeRefresh() - return false - end - swapTo(was, FORGE.redo, "undone") - - return true -end - - -function forgeRedo() - local again = table.remove(FORGE.redo) - - if again == nil then - FORGE.message = "nothing to redo" - forgeRefresh() - return false - end - swapTo(again, FORGE.undo, "redone") - - return true -end - - --- ===== Kinds ================================================================================== --- --- A type is a look, behaviours, and vars. All of it is edited the way a rule's parameters are: --- ENTER walks the fields, and each is typed. The fields come from the manifest -- a look's --- parameters, a behaviour's parameters -- so a new look or behaviour is editable the moment it is --- declared. - --- A name no other type has, from a stem. -local function uniqueType(stem) - local n = 1 - - if FORGE.game.types[stem] == nil then - return stem - end - while FORGE.game.types[stem .. n] ~= nil do - n = n + 1 - end - - return stem .. n -end - - -function forgeTypeAdd() - local name = uniqueType("thing") - - forgeRemember() - FORGE.game.types[name] = { look = { kind = "box", w = NEW_W, h = NEW_H, r = 180, g = 180, b = 190 }, vars = {}, behaviours = {} } - FORGE.typeName = name - FORGE.lastField = nil - FORGE.dirty = true - FORGE.message = "added type " .. name - forgeRefresh() - - return name -end - - -function forgeTypeDuplicate() - local entityType = FORGE.game.types[FORGE.typeName or ""] - local name - - if entityType == nil then - return nil - end - name = uniqueType(FORGE.typeName) - forgeRemember() - FORGE.game.types[name] = copyOf(entityType) - FORGE.typeName = name - FORGE.lastField = nil - FORGE.dirty = true - FORGE.message = "copied as " .. name - forgeRefresh() - - return name -end - - --- A type still placed somewhere cannot go: the entities would have nothing to be. -function forgeTypeDelete() - local name = FORGE.typeName - - if (name == nil) or (FORGE.game.types[name] == nil) then - return false - end - for _, room in ipairs(FORGE.game.rooms) do - for _, entry in ipairs(room.entities) do - if entry.type == name then - FORGE.message = name .. " is placed in " .. room.name .. "; delete those first" - forgeRefresh() - return false - end - end - end - forgeRemember() - FORGE.game.types[name] = nil - FORGE.typeName = sortedKeys(FORGE.game.types)[1] - FORGE.dirty = true - FORGE.message = "deleted type " .. name - forgeRefresh() - - return true -end - - --- Renaming a type renames every entity of it and every rule that names it. -local function renameType(from, to) - for _, room in ipairs(FORGE.game.rooms) do - for _, entry in ipairs(room.entities) do - if entry.type == from then - entry.type = to - end - end - end - for _, rule in ipairs(FORGE.game.rules) do - if rule.each == from then - rule.each = to - end - for key, kind in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do - if (kind == "type") and (rule[key] == from) then - rule[key] = to - end - end - for _, list in ipairs({ rule.when or {}, rule.act or {} }) do - for _, item in ipairs(list) do - local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] - - for key, kind in pairs(entry and entry.params or {}) do - if (kind == "type") and (item[key] == from) then - item[key] = to - end - end - end - end - end - for _, entityType in pairs(FORGE.game.types) do - for _, b in ipairs(entityType.behaviours or {}) do - local entry = AUTHOR.behaviours[b.kind] - - for key, type_ in pairs(entry and entry.params or {}) do - if (type_ == "type") and (b[key] == from) then - b[key] = to - end - end - end - end -end - - --- Fills in whatever a look or a behaviour declares and the table does not yet have, so a type --- that was just typed in has a value for every parameter and compiles at once. --- Fills in what a new thing must have to mean anything -- the entity self, a key, a switch, the --- lit type -- and leaves the rest empty, so a shake shakes and a clip plays at full volume until --- the author says otherwise. --- A behaviour or a layer (sparse) fills in less than a rule's part: a key or a switch it is --- not given it defaults for itself when the game runs -- a gun fires on the mouse button, a --- pressed event needs the one it is for. -fillDefaults = function(t, params, sparse) - for key, kind in pairs(params or {}) do - if t[key] == nil then - if kind == "type" then - t[key] = FORGE.typeName or "" - elseif (FORGE_DEFAULTS[kind] ~= nil) and not (sparse and ((kind == "scancode") or (kind == "switch"))) then - t[key] = FORGE_DEFAULTS[kind] - end - end - end -end - - --- A type's fields, in the order the panel shows them: its name, its look and what that takes, its --- vars, then its behaviours and what each of those takes. A behaviour's parameter is named --- behaviour.parameter, which is how it is typed as well. -function forgeTypeFieldNames(entityType) - local names = { "name", "look" } - local look = AUTHOR.looks[(entityType.look or {}).kind] - - for _, key in ipairs(sortedKeys(look and look.params or entityType.look)) do - if key ~= "kind" then - names[#names + 1] = "look." .. key - end - end - names[#names + 1] = "vars" - names[#names + 1] = "behaviours" - for _, b in ipairs(entityType.behaviours or {}) do - local entry = AUTHOR.behaviours[b.kind] - - for _, key in ipairs(sortedKeys(entry and entry.params or b)) do - if key ~= "kind" then - names[#names + 1] = b.kind .. "." .. key - end - end - end - - return names -end - - --- Vars as one line: name=value, name=value. -local function varsText(vars) - local parts = {} - - for _, key in ipairs(sortedKeys(vars)) do - parts[#parts + 1] = key .. "=" .. tostring(vars[key]) - end - - return table.concat(parts, ", ") -end - - --- And back: a number is a number, true and false are themselves, anything else is text. -local function varsFrom(text) - local vars = {} - - for name, value in string.gmatch(tostring(text), "([%w_]+)%s*=%s*([^,]*)") do - value = value:gsub("%s+$", "") - if tonumber(value) then - vars[name] = tonumber(value) - elseif value == "true" then - vars[name] = true - elseif value == "false" then - vars[name] = false - else - vars[name] = value - end - end - - return vars -end - - -function forgeTypeGet(entityType, name) - local group, key = string.match(name, "^([^.]+)%.(.+)$") - - if name == "name" then - return FORGE.typeName - elseif name == "look" then - return (entityType.look or {}).kind - elseif name == "vars" then - return varsText(entityType.vars) - elseif name == "behaviours" then - return listText(entityType.behaviours) - elseif group == "look" then - return tableText((entityType.look or {})[key]) - elseif group ~= nil then - local b = behaviourOf(entityType, group) - - return b and tableText(b[key]) or nil - end - - return nil -end - - --- What a typed value becomes for a parameter of a manifest type: tables are parsed, the rest --- is as typed. -local function valueFor(params, key, value) - if ((params or {})[key] == "table") and (type(value) == "string") then - return tableFrom(value) - end - - return value -end - - -function forgeTypeSet(entityType, name, value) - local group, key = string.match(name, "^([^.]+)%.(.+)$") - - entityType.look = entityType.look or {} - if name == "name" then - if (value ~= "") and (value ~= FORGE.typeName) and (FORGE.game.types[value] == nil) then - renameType(FORGE.typeName, value) - FORGE.game.types[value] = entityType - FORGE.game.types[FORGE.typeName] = nil - FORGE.typeName = value - end - elseif name == "look" then - if value == entityType.look.kind then - return - elseif AUTHOR.looks[value] ~= nil then - -- A new look starts clean: a box's size and colour mean nothing to a sprite. - entityType.look = { kind = value } - fillDefaults(entityType.look, AUTHOR.looks[value].params, true) - else - FORGE.message = "no look called " .. tostring(value) - end - elseif name == "vars" then - entityType.vars = varsFrom(value) - elseif name == "behaviours" then - FORGE.stash[entityType] = FORGE.stash[entityType] or {} - entityType.behaviours = listFrom(value, entityType.behaviours, AUTHOR.behaviours, FORGE.stash[entityType], "behaviour") - elseif group == "look" then - entityType.look[key] = valueFor((AUTHOR.looks[entityType.look.kind] or {}).params, key, value) - elseif group ~= nil then - local b = behaviourOf(entityType, group) - - if b ~= nil then - b[key] = valueFor((AUTHOR.behaviours[group] or {}).params, key, value) - end - end - FORGE.dirty = true - forgeRefresh() -end - - --- ===== Entities =============================================================================== - --- A name no entity in the room has, from a stem. -local function uniqueId(stem) - local taken = {} - local n = 1 - - for _, entry in ipairs(forgeRoom().entities) do - taken[entry.id or ""] = true - end - if not taken[stem] then - return stem - end - while taken[stem .. n] do - n = n + 1 - end - - return stem .. n -end - - --- A new entity of the selected type in the middle of the canvas, clear of the panel, selected and --- ready to be moved. With no types yet, one is made first. -function forgeEntityAdd(x, y) - local room = forgeRoom() - local typeName = FORGE.typeName - - if (typeName == nil) or (FORGE.game.types[typeName] == nil) then - typeName = forgeTypeAdd() - end - forgeRemember() - if FORGE.scene then - -- In a scene the new entity stands on the floor under the middle of the canvas, or at the - -- origin when nothing is there to stand on. - local gx, gz = forgeGroundPoint((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2, overlayGetHeight() / 2, 0) - - room.entities[#room.entities + 1] = { - type = typeName, - id = uniqueId(typeName), - x = gx and (math.floor(gx * 10 + 0.5) / 10) or 0, - y = 0, - z = gz and (math.floor(gz * 10 + 0.5) / 10) or 0 - } - else - room.entities[#room.entities + 1] = { - type = typeName, - id = uniqueId(typeName), - x = math.floor(x or ((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2)), - y = math.floor(y or (overlayGetHeight() / 2)) - } - end - FORGE.dirty = true - FORGE.message = "added " .. room.entities[#room.entities].id - forgeSelect(#room.entities) - - return #room.entities -end - - --- A copy of the selected entity, a little to one side so the two can be told apart. -function forgeEntityDuplicate() - local room = forgeRoom() - local entry = room.entities[FORGE.selected] - local twin - - if entry == nil then - return nil - end - forgeRemember() - twin = copyOf(entry) - twin.id = uniqueId(entry.type) - twin.x = entry.x + 20 - twin.y = entry.y + 20 - room.entities[#room.entities + 1] = twin - FORGE.dirty = true - FORGE.message = "copied " .. entry.id .. " as " .. twin.id - forgeSelect(#room.entities) - - return #room.entities -end - - -function forgeEntityDelete() - local room = forgeRoom() - local entry = room.entities[FORGE.selected] - - if entry == nil then - return false - end - forgeRemember() - table.remove(room.entities, FORGE.selected) - FORGE.dirty = true - FORGE.message = "deleted " .. tostring(entry.id) - if #room.entities == 0 then - forgeSelect(nil) - else - forgeSelect(math.min(FORGE.selected, #room.entities)) - end - - return true -end - - --- An entity's fields: what every entity has, then the vars its type declares, overridable here. -function forgeEntityFieldNames(entry) - local names = { "id", "type", "x", "y", "z" } - - if FORGE.scene then - names[#names + 1] = "rx" - names[#names + 1] = "ry" - end - names[#names + 1] = "rz" - names[#names + 1] = "scale" - local entityType = FORGE.game.types[entry.type] or {} - - for _, key in ipairs(sortedKeys(entityType.vars)) do - names[#names + 1] = "vars." .. key - end - - return names -end - - -function forgeEntityGet(entry, name) - local key = string.match(name, "^vars%.(.+)$") - - if key ~= nil then - local entityType = FORGE.game.types[entry.type] or {} - - if entry.vars and entry.vars[key] ~= nil then - return entry.vars[key] - end - - return (entityType.vars or {})[key] - end - if name == "scale" then - return entry.scale or 1 - end - - return entry[name] -end - - --- Renaming an entity renames it in every rule that talks about it, which is what the author meant. -local function renameEverywhere(from, to) - for _, rule in ipairs(FORGE.game.rules) do - for _, list in ipairs({ rule.when or {}, rule.act or {} }) do - for _, item in ipairs(list) do - local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] - - for key, kind in pairs(entry and entry.params or {}) do - if (kind == "entity") and (item[key] == from) then - item[key] = to - end - end - end - end - end -end - - -function forgeEntitySet(entry, name, value) - local key = string.match(name, "^vars%.(.+)$") - - if name == "id" then - if (value ~= "") and (value ~= entry.id) then - renameEverywhere(entry.id, value) - entry.id = value - end - elseif name == "type" then - if FORGE.game.types[value] ~= nil then - entry.type = value - else - FORGE.message = "no type called " .. tostring(value) - end - elseif (name == "x") or (name == "y") or (name == "z") then - if type(value) == "number" then - entry[name] = FORGE.scene and (math.floor(value * 10 + 0.5) / 10) or math.floor(value) - end - elseif (name == "rx") or (name == "ry") or (name == "rz") then - if type(value) == "number" then - entry[name] = (value ~= 0) and (math.floor(value + 0.5) % 360) or nil - end - elseif name == "scale" then - if (type(value) == "number") and (value > 0) then - entry.scale = (value ~= 1) and value or nil - end - elseif key ~= nil then - entry.vars = entry.vars or {} - entry.vars[key] = value - end - FORGE.dirty = true - forgeRefresh() -end - - --- ===== Rooms ================================================================================== - -function forgeRoomGo(index) - FORGE.room = math.max(1, math.min(index, #FORGE.game.rooms)) - FORGE.selected = nil - FORGE.track = 1 - FORGE.key = 0 - FORGE.held = nil - FORGE.lastField = nil - forgeRefresh() -end - - -function forgeRoomAdd() - local n = #FORGE.game.rooms + 1 - - forgeRemember() - FORGE.game.rooms[n] = { name = "room" .. n, entities = {}, tracks = {} } - FORGE.dirty = true - FORGE.message = "added room" .. n - forgeRoomGo(n) - - return n -end - - --- ===== Drawing polygons ======================================================================= --- --- A walk area, or a hotspot's outline, is drawn on the canvas corner by corner: V starts one, --- every press adds a corner, V or ENTER closes it. It goes to the selected entity's type when --- that type has a hotspot behaviour, and to the room's walk areas otherwise. - -function forgePolygonBegin() - local entry = forgeRoom().entities[FORGE.selected] - local entityType = entry and FORGE.game.types[entry.type] or nil - local spot = entityType and behaviourOf(entityType, "hotspot") or nil - - -- The panel steps aside while corners are pressed, since a press under it is the panel's. - forgePanelShow(false) - FORGE.drawing = { points = {}, hotspot = spot } - FORGE.message = spot and ("drawing " .. entry.id .. "'s outline: press each corner") or "drawing a walk area: press each corner" - forgeRefresh() -end - - -function forgePolygonClose() - local drawing = FORGE.drawing - local room = forgeRoom() - - FORGE.drawing = nil - forgePanelShow(true) - if #drawing.points < 6 then - FORGE.message = "a polygon needs three corners" - forgeRefresh() - return false - end - forgeRemember() - if drawing.hotspot then - local entry = forgeRoom().entities[FORGE.selected] - local entityType = FORGE.game.types[entry.type] - - behaviourOf(entityType, "hotspot").polygon = drawing.points - FORGE.message = "outline drawn" - else - room.walk = room.walk or {} - room.walk[#room.walk + 1] = drawing.points - FORGE.message = "walk area " .. #room.walk .. " drawn" - end - FORGE.dirty = true - forgeRefresh() - - return true -end - - --- ===== The event sheet ======================================================================== --- --- Rules are where someone with little programming skill actually spends their time, so this is the --- half of the editor that matters. The vocabulary comes from AUTHOR, so a rule editor never needs --- changing when a condition, an action, or an event is added: it lists whatever the manifest --- declares. - --- Every condition and action of a rule, flattened, so one index walks the whole thing. -function forgeParts(rule) - local parts = {} - - for _, item in ipairs(rule.when or {}) do - parts[#parts + 1] = { kind = "when", item = item } - end - for _, item in ipairs((rule.when or {}).any or {}) do - parts[#parts + 1] = { kind = "any", item = item } - end - for _, item in ipairs(rule.act or {}) do - parts[#parts + 1] = { kind = "act", item = item } - end - - return parts -end - - --- A new condition or action, with a value for each parameter the manifest declares, so it compiles --- the moment it is added rather than only once every field has been filled in. -function forgeMakePart(set, name) - local entry = set[name] - local item = { name } - - if entry == nil then - return nil - end - fillDefaults(item, entry.params) - - return item -end - - --- Adds a condition ("when"), a condition in the rule's any group ("any"), or an action ("act"). -function forgeRuleAdd(partKind, name) - local rule = FORGE.game.rules[FORGE.rule] - local set = (partKind == "act") and AUTHOR.actions or AUTHOR.conditions - local item = forgeMakePart(set, name) - local list - - if (rule == nil) or (item == nil) then - FORGE.message = "no such " .. partKind - return false - end - forgeRemember() - rule.when = rule.when or {} - rule.act = rule.act or {} - if partKind == "any" then - rule.when.any = rule.when.any or {} - list = rule.when.any - else - list = rule[partKind] - end - list[#list + 1] = item - FORGE.dirty = true - FORGE.lastField = nil - FORGE.message = "added " .. name - - -- Select what was just added, so its parameters can be set straight away. Conditions come - -- before actions in the flattened list, so adding one shifts every action along: the new - -- part is found by identity rather than by assuming it went on the end. - for at, part in ipairs(forgeParts(rule)) do - if part.item == item then - FORGE.part = at - end - end - forgeRefresh() - - return true -end - - -function forgePartDelete() - local rule = FORGE.game.rules[FORGE.rule] - local parts = forgeParts(rule) - local part = parts[FORGE.part] - local list - - if part == nil then - return false - end - forgeRemember() - if part.kind == "any" then - list = rule.when.any - else - list = rule[part.kind] - end - for i = 1, #list do - if list[i] == part.item then - table.remove(list, i) - break - end - end - FORGE.part = math.min(FORGE.part, #forgeParts(rule)) - FORGE.dirty = true - FORGE.message = "deleted" - forgeRefresh() - - return true -end - - --- Changes one parameter of the selected condition or action. Numbers arrive as numbers so that a --- description keeps compiling to the same thing whether it was typed or dragged. -function forgePartSet(key, value) - local parts = forgeParts(FORGE.game.rules[FORGE.rule]) - local part = parts[FORGE.part] - - if part == nil then - return false - end - part.item[key] = value - FORGE.dirty = true - FORGE.message = key .. " = " .. tostring(value) - forgeRefresh() - - return true -end - - -function forgeRuleNew(note) - forgeRemember() - FORGE.game.rules[#FORGE.game.rules + 1] = { note = note or "new rule", on = "frame", when = {}, act = {} } - FORGE.rule = #FORGE.game.rules - FORGE.part = 0 - FORGE.lastField = nil - FORGE.dirty = true - FORGE.message = "rule added: E picks what it is on" - forgeRefresh() - - return FORGE.rule -end - - -function forgeRuleDelete() - if FORGE.game.rules[FORGE.rule] == nil then - return false - end - forgeRemember() - table.remove(FORGE.game.rules, FORGE.rule) - FORGE.rule = math.max(1, math.min(FORGE.rule, #FORGE.game.rules)) - FORGE.part = 0 - FORGE.dirty = true - FORGE.message = "rule deleted" - forgeRefresh() - - return true -end - - --- Moves the selected rule up or down the sheet. Rules run in order, and "the readout, every --- frame" wants to be last, so the order is part of the game. -function forgeRuleMove(by) - local rules = FORGE.game.rules - local to = FORGE.rule + by - - if (rules[FORGE.rule] == nil) or (rules[to] == nil) then - return false - end - forgeRemember() - rules[FORGE.rule], rules[to] = rules[to], rules[FORGE.rule] - FORGE.rule = to - FORGE.dirty = true - FORGE.message = "rule moved" - forgeRefresh() - - return true -end - - --- Sets what a rule is triggered by. The old event's filter values go, since they mean nothing to --- the new one, and the new one's start empty. -function forgeRuleOn(name) - local rule = FORGE.game.rules[FORGE.rule] - - if (rule == nil) or (AUTHOR.events[name] == nil) then - return false - end - forgeRemember() - for key in pairs((AUTHOR.events[rule.on or "frame"] or {}).filter or {}) do - rule[key] = nil - end - rule.on = name - for key, kind in pairs(AUTHOR.events[name].filter) do - if kind == "type" then - rule[key] = FORGE.typeName - end - end - FORGE.part = 0 - FORGE.lastField = nil - FORGE.dirty = true - FORGE.message = "on " .. name - forgeRefresh() - - return true -end - - --- One condition or action as a line: its name, then its parameters in a fixed order so the same --- rule always reads the same way. -function forgePartText(item) - local out = {} - - for _, key in ipairs(forgeFieldNames(item)) do - if item[key] ~= nil then - out[#out + 1] = key .. ": " .. tostring(item[key]) - end - end - - return item[1] .. (( #out > 0) and (": " .. table.concat(out, ", ")) or "") -end - - --- A rule's own fields: its note, what it is on and that event's filters, each, room, and the --- flags a sequence can carry. -function forgeRuleFieldNames(rule) - local names = { "note", "on" } - local event = AUTHOR.events[rule.on or "frame"] - - for _, key in ipairs(sortedKeys(event and event.filter or {})) do - names[#names + 1] = key - end - names[#names + 1] = "each" - names[#names + 1] = "room" - names[#names + 1] = "controls" - names[#names + 1] = "interrupt" - - return names -end - - -function forgeMode(mode) - FORGE.mode = mode - FORGE.lastField = nil - FORGE.picker = nil - forgeRefresh() -end - - --- ===== Tracks and the timeline ================================================================ --- --- A track is keys by disc frame or by time. The cursor is where the timeline stands; the canvas --- shows the frame of the disc layer's video under it and every track's box there. - --- A new track: boxes over the picture, or a rail of points in a scene -- or, with waves, a --- track of spawns, keyed by time or frame like the others, or by a rail's stops. -function forgeTrackNew(name, waves) - local room = forgeRoom() - - forgeRemember() - if waves then - room.tracks[#room.tracks + 1] = { name = name or "waves", key = FORGE.scene and "time" or "frame", spawns = {} } - elseif FORGE.scene then - room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "time", points = {} } - else - room.tracks[#room.tracks + 1] = { name = name or ("track" .. (#room.tracks + 1)), key = "frame", boxes = {} } - end - FORGE.track = #room.tracks - FORGE.key = 0 - FORGE.dirty = true - FORGE.message = waves and "waves track added: K adds a wave at the cursor; key stop ties waves to a rail's stops" or "track added: K adds a key at the cursor" - forgeRefresh() - - return FORGE.track -end - - -function forgeTrackDelete() - local room = forgeRoom() - - if room.tracks[FORGE.track] == nil then - return false - end - forgeRemember() - table.remove(room.tracks, FORGE.track) - FORGE.track = math.max(1, math.min(FORGE.track, #room.tracks)) - FORGE.key = 0 - FORGE.dirty = true - FORGE.message = "track deleted" - forgeRefresh() - - return true -end - - --- A track's keys, whichever kind it holds: boxes over a picture, points along a rail, or waves. -function forgeTrackKeys(track) - return track.boxes or track.points or track.spawns or {} -end - - --- Keys in order: by their moment, or by their stop's name on a track keyed by stops. -function forgeKeyOrder(a, b) - if type(a.at) == type(b.at) then - return a.at < b.at - end - - return tostring(a.at) < tostring(b.at) -end - - --- The selected track's box at the cursor, interpolated, or nil. -function forgeCursorBox() - local track = forgeRoom().tracks[FORGE.track] - - if track == nil then - return nil - end - - return authorTrackBoxAt(track, FORGE.cursor) -end - - --- The key exactly at the cursor; made from the interpolated box, or the nearest key, or a default, --- when asked to and there is none. -function forgeKeyAtCursor(make) - local track = forgeRoom().tracks[FORGE.track] - local box - - if track == nil then - return nil - end - for at, key in ipairs(track.boxes) do - if key.at == FORGE.cursor then - FORGE.key = at - return key - end - end - if not make then - return nil - end - box = forgeCursorBox() - if box == nil then - local nearest - - for _, key in ipairs(track.boxes) do - if (nearest == nil) or (math.abs(key.at - FORGE.cursor) < math.abs(nearest.at - FORGE.cursor)) then - nearest = key - end - end - box = nearest or { x = (FORGE.panelX + PANEL_W + overlayGetWidth()) / 2 - 30, y = overlayGetHeight() / 2 - 30, w = 60, h = 60 } - end - local key = { at = FORGE.cursor, x = math.floor(box.x), y = math.floor(box.y), w = math.floor(box.w), h = math.floor(box.h) } - - track.boxes[#track.boxes + 1] = key - table.sort(track.boxes, function(a, b) return a.at < b.at end) - for at, item in ipairs(track.boxes) do - if item == key then - FORGE.key = at - end - end - FORGE.dirty = true - - return key -end - - -function forgeKeyAdd() - local track = forgeRoom().tracks[FORGE.track] - - if track == nil then - return false - end - forgeRemember() - if track.spawns then - -- A wave at the cursor -- or at a stop still to be named -- of one of the first type, at - -- the middle of the canvas, to be typed into shape. - local key = { at = (track.key == "stop") and "stop" or FORGE.cursor, type = sortedKeys(FORGE.game.types)[1], count = 1 } - - if FORGE.scene then - key.x, key.y, key.z = 0, 0, 0 - else - key.x, key.y = math.floor((FORGE.panelX + PANEL_W + overlayGetWidth()) / 2), math.floor(overlayGetHeight() / 2) - end - track.spawns[#track.spawns + 1] = key - table.sort(track.spawns, forgeKeyOrder) - for at, item in ipairs(track.spawns) do - if item == key then - FORGE.key = at - end - end - FORGE.dirty = true - elseif track.points then - -- A rail point is put where the editor camera is, looking where it looks: stand where the - -- player should and press K. - local cx, cy, cz = nodeGetPosition(FORGE.scene.camera) - local fx, fy, fz = sceneFocus() - local point = { at = FORGE.cursor, x = math.floor(cx * 10 + 0.5) / 10, y = math.floor(cy * 10 + 0.5) / 10, z = math.floor(cz * 10 + 0.5) / 10, look = { fx, fy, fz } } - - track.points[#track.points + 1] = point - table.sort(track.points, function(a, b) return a.at < b.at end) - for at, item in ipairs(track.points) do - if item == point then - FORGE.key = at - end - end - FORGE.dirty = true - else - forgeKeyAtCursor(true) - end - FORGE.lastField = nil - FORGE.message = "key at " .. FORGE.cursor - forgeRefresh() - - return true -end - - -function forgeKeyDelete() - local track = forgeRoom().tracks[FORGE.track] - local keys = track and forgeTrackKeys(track) or nil - - if (keys == nil) or (keys[FORGE.key] == nil) then - return false - end - forgeRemember() - table.remove(keys, FORGE.key) - FORGE.key = math.min(FORGE.key, #keys) - FORGE.dirty = true - FORGE.message = "key deleted" - forgeRefresh() - - return true -end - - --- Moves the cursor and shows the disc's frame there. -function forgeCursorTo(at) - FORGE.cursor = math.max(0, math.floor(at)) - if FORGE.video then - videoSeek(FORGE.video, math.max(0, FORGE.cursor - (FORGE.videoStart or 0))) - end - forgeRefresh() -end - - --- ===== Pickers ================================================================================ --- --- Choosing a condition, an action, an event, a look, or a behaviour is choosing from the manifest, --- and the manifest carries a line of help for each. The list takes the panel over while it is up. - --- Opens a list. set is a manifest table (AUTHOR.conditions and its kind), or a list of --- { name, help } items; choose is called with the name picked. Typing narrows the list. -function forgePickBegin(title, set, choose) - local items = {} - - if set[1] ~= nil then - items = set - else - for _, name in ipairs(sortedKeys(set)) do - items[#items + 1] = { name = name, help = set[name].help } - end - end - if #items == 0 then - FORGE.message = "nothing to choose from" - forgeRefresh() - return false - end - FORGE.picker = { title = title, items = items, at = 1, choose = choose, filter = "" } - forgeRefresh() - - return true -end - - --- Opens the list a field's values come from, if it has one. Answers whether it did. -function forgeFieldList(fields, field) - local items, kind, free = forgeFieldChoices(field) - local was = fields.get(field) - - if items == nil then - return false - end - -- ENTER on a row takes it and moves on to the next field, as ENTER on a typed value does, so - -- the form is still walked one ENTER per field; a click takes it and stays. The value the - -- field already has is left alone: choosing a type's look again would empty its parameters. - forgePickBegin(((kind == "file") and "a file for " or "a value for ") .. field, items, function(word, onward) - local value = word - - if word == FILE_NONE then - value = nil - elseif (kind == "boolean") or (kind == "flag") then - value = (word == "true") - end - if word ~= tostring(was) then - fields.set(field, value) - end - FORGE.message = field .. " = " .. word - if onward and (FORGE.editing ~= nil) and (FORGE.editing.key == field) then - forgeEditNext() - else - FORGE.editing = nil - end - end) - if FORGE.picker then - local found = nil - - FORGE.picker.free = free - FORGE.picker.field = true -- Drawn under the field's row, not over the panel's list. - for at, item in ipairs(FORGE.picker.items) do - if item.name == tostring(was) then - found = at - end - end - -- A value the list does not know -- a room not made yet, a word of its own -- is offered - -- as it is, so walking past the field with ENTER keeps it. - if (found == nil) and (was ~= nil) then - found = math.min(2, #FORGE.picker.items + 1) - table.insert(FORGE.picker.items, found, { name = tostring(was), help = "as it is" }) - end - FORGE.picker.at = found or 1 - forgeRefresh() -- Drawn again as a dropdown: forgePickBegin drew it over the list. - end - - return true -end - - --- The picker's items that match what has been typed. A free list -- a state -- offers what was --- typed first, when it is not a name already, so ENTER takes a new word as readily as a known. -function forgePickItems() - local picker = FORGE.picker - local filter = picker.filter:lower() - local shown = {} - - if picker.free and (picker.filter ~= "") then - local known = false - - for _, item in ipairs(picker.items) do - known = known or (item.name == picker.filter) - end - if not known then - shown[1] = { name = picker.filter, help = "as typed" } - end - end - - -- What was typed exactly comes first, then what begins with it, then what merely contains - -- it, so "miss" lands on miss and not on branchMissed. - for _, rank in ipairs({ 1, 2, 3 }) do - for _, item in ipairs(picker.items) do - local name = item.name:lower() - local at = (filter ~= "") and name:find(filter, 1, true) or nil - local mine = (filter == "") and 3 or ((name == filter) and 1 or ((at == 1) and 2 or (at and 3 or nil))) - - if mine == rank then - shown[#shown + 1] = item - end - end - end - - return shown -end - - --- A key while a list is up. Answers whether it was taken. -function forgePickKey(scancode, keysym) - local picker = FORGE.picker - - if picker == nil then - return false - end - local shown = forgePickItems() - - if scancode == SCANCODE.DOWN.value then - picker.at = math.min(picker.at + 1, #shown) - elseif scancode == SCANCODE.UP.value then - picker.at = math.max(picker.at - 1, 1) - elseif scancode == SCANCODE.RETURN.value then - if shown[picker.at] then - FORGE.picker = nil - picker.choose(shown[picker.at].name, true) - end - elseif scancode == SCANCODE.ESCAPE.value then - FORGE.picker = nil - FORGE.message = "left it" - elseif scancode == SCANCODE.BACKSPACE.value then - picker.filter = string.sub(picker.filter, 1, -2) - picker.at = 1 - elseif keysym and (keysym >= 32) and (keysym < 127) and not FORGE.textInput then - picker.filter = picker.filter .. string.char(keysym) - picker.at = 1 - else - return true - end - forgeRefresh() - - return true -end - - --- The files under the game's directory a field could name, as picker items. -function forgeFileItems() - local found = {} - local seen = {} - - local function walk(directory, prefix, depth) - -- lfs.dir hands back its iterator and the directory it walks; both go to the for. - local ok, iterator, handle = pcall(lfs.dir, directory) - - if not ok then - return - end - for name in iterator, handle do - if (name:sub(1, 1) ~= ".") and (name ~= "Singe") and (name ~= "data") then - local path = prefix .. name - local mode = lfs.attributes(directory .. "/" .. name, "mode") - - if (mode == "directory") and (depth < FILE_DEPTH) then - seen[path .. "/"] = true - walk(directory .. "/" .. name, path .. "/", depth + 1) - elseif mode == "file" then - local extension = name:match("%.([%w]+)$") - - if extension and FILE_KINDS[extension:lower()] then - found[#found + 1] = { name = path, help = extension:lower() } - end - end - end - end - end - walk(".", "", 0) - -- The kit travels inside Forge.game, which the walk from the games directory cannot see. - if not seen["Forge/kit/"] then - walk("Forge/kit", "Forge/kit/", 0) - end - table.sort(found, function(a, b) return a.name < b.name end) - - return found -end - - --- Whether a field names a file, by the manifest's word for it. --- What a field is, for the list it opens: the manifest's type for a parameter ("file", --- "boolean", "scancode", "switch", "room", "type", "entity", "state", "track"), a list of the --- words a parameter takes (the manifest's choices for it), or nil for a value typed free. The --- editor's own fields -- a type's look, a rule's event, an entity's type, a track's key -- are --- typed here as well. -function forgeFieldKind(name) - local group, key = string.match(name, "^([^.]+)%.(.+)$") - local entry = nil - local param = name - - if FORGE.mode == "types" then - local entityType = FORGE.game.types[FORGE.typeName or ""] - - if name == "look" then - return AUTHOR.looks - elseif (group == "look") and entityType and entityType.look then - entry = AUTHOR.looks[entityType.look.kind] - param = key - elseif group then - entry = AUTHOR.behaviours[group] - param = key - end - elseif FORGE.mode == "rules" then - if FORGE.part == 0 then - local rule = FORGE.game.rules[FORGE.rule] or {} - local event = AUTHOR.events[rule.on or "frame"] - - if name == "on" then - return AUTHOR.events - elseif name == "each" then - return "type" - elseif name == "room" then - return "room" - elseif (name == "controls") or (name == "interrupt") then - return "flag" - end - - return event and event.filter[name] or nil - end - local part = forgeParts(FORGE.game.rules[FORGE.rule] or {})[FORGE.part] - - if part then - entry = AUTHOR.conditions[part.item[1]] or AUTHOR.actions[part.item[1]] - end - elseif FORGE.mode == "entities" then - if FORGE.selected ~= nil then - return (name == "type") and "type" or nil - elseif group then - -- The game's form: a layer's parameter, named layer.parameter. - entry = AUTHOR.layers[group] - 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] - - if (FORGE.key == 0) and (name == "key") then - return (track and track.spawns) and { "frame", "time", "stop" } or { "frame", "time" } - elseif (FORGE.key > 0) and (name == "type") then - return "type" - end - elseif FORGE.mode == "dialogues" then - if (name == "start") or (name == "next") then - return "node" - elseif name == "once" then - return "flag" - end - end - if entry == nil then - return nil - end - if entry.choices and entry.choices[param] then - return entry.choices[param] - end - - return (entry.params or {})[param] -end - - --- The list a field opens, as picker items, or nil for a value typed free: the files under the --- game's directory, true and false, the scancodes and switches the engine names, the game's --- rooms, types, and entities, a dialogue's nodes, a room's tracks, or the words the manifest --- says. free says the list is a suggestion and the typed text is taken as well (a state a --- rule sets can be any word, and a rule can name a type, a room, or an entity that is made --- later). Every list but those that must name something offers (none): --- the parameter left empty takes its default, or means the game itself for an entity. -function forgeFieldChoices(name) - local kind = forgeFieldKind(name) - local items = {} - local free = false - local none = true - - if kind == nil then - return nil - end - local function add(word, help) - items[#items + 1] = { name = word, help = help } - end - if type(kind) == "table" then - if kind[1] == nil then - -- A manifest: the looks, or the events. - for _, word in ipairs(sortedKeys(kind)) do - add(word, kind[word].help) - end - none = false - else - for _, word in ipairs(kind) do - add(word) - end - end - elseif kind == "file" then - items = forgeFileItems() - elseif (kind == "boolean") or (kind == "flag") then - add("true") - add("false") - none = (kind == "boolean") - elseif kind == "scancode" then - for _, word in ipairs(sortedKeys(SCANCODE)) do - add(word) - end - elseif kind == "switch" then - for _, word in ipairs(sortedKeys(_G)) do - if word:match("^SWITCH_") and (type(_G[word]) == "number") then - add(word) - end - end - elseif kind == "room" then - for _, room in ipairs(FORGE.game.rooms) do - add(room.name) - end - free = true - elseif kind == "type" then - for _, word in ipairs(sortedKeys(FORGE.game.types)) do - add(word, (FORGE.game.types[word].look or {}).kind) - end - none = (FORGE.mode ~= "entities") - free = none - elseif kind == "entity" then - local seen = {} - - add("self", "the instance the rule is on") - add("other", "the other one, in a collision or a trigger") - for _, room in ipairs(FORGE.game.rooms) do - for _, entry in ipairs(room.entities) do - if entry.id and not seen[entry.id] then - seen[entry.id] = true - add(entry.id, entry.type) - end - end - end - free = true - elseif kind == "state" then - local seen = {} - - -- The states the game already names: a frames behaviour's ranges, an animator's clips, - -- and the states rules set or test. - for _, entityType in pairs(FORGE.game.types) do - for _, b in ipairs(entityType.behaviours or {}) do - if b.kind == "frames" then - for word in tostring(b.states or ""):gmatch("(%w+)%s*=") do - seen[word] = true - end - elseif b.kind == "animator" then - for _, word in ipairs({ "idle", "walk", "attack", "hit", "dead" }) do - if b[word] then - seen[word] = true - end - end - end - end - end - for _, rule in ipairs(FORGE.game.rules) do - for _, list in ipairs({ rule.when or {}, rule.act or {} }) do - for _, item in ipairs(list) do - if type(item.state) == "string" then - seen[item.state] = true - end - end - end - end - seen.idle = true - for _, word in ipairs(sortedKeys(seen)) do - add(word) - end - free = true - elseif kind == "track" then - for _, track in ipairs(forgeRoom().tracks or {}) do - add(track.name) - end - free = true - elseif kind == "node" then - local dialogue = (FORGE.game.dialogues or {})[FORGE.dialogue or ""] - - for _, word in ipairs(sortedKeys(dialogue and dialogue.nodes or {})) do - add(word) - end - none = (name ~= "start") - free = true - else - return nil - end - if none then - table.insert(items, 1, { name = FILE_NONE, help = (kind == "file") and "no file" or "left empty" }) - end - - return items, kind, free -end - - --- ===== Keys =================================================================================== --- --- Point the engine's callback at this and the editor is usable without a pointer at all, which is --- how the bundled menu has always worked and what a cabinet needs. --- --- TAB entities, types, rules, tracks, dialogues --- UP / DOWN move within the list --- LEFT/RIGHT move the panel out of the way --- PGUP / PGDN the previous or next room --- ENTER type a value for what is selected; again for its next value --- ESC put the value back; otherwise close the description --- A / D add, duplicate (an entity, or a type) DELETE delete what is selected --- N new rule, new track, or (in the entities) new room --- E pick what the rule is on C / O / T add a condition / an "any" condition / an action --- [ / ] move the rule; in the tracks, leap the cursor --- , / . step the cursor K a key at the cursor --- U / R undo / redo S save --- B build P play (Forge as a game only) --- J / L, Y / H, I / K in a 3D room: orbit, tilt, and zoom the editor camera --- V draw a walk area, or the selected hotspot's outline, corner by corner --- In a picker, typing narrows the list; a field that names a file offers the game's files. --- In the dialogues: A adds a dialogue, a node, or a choice at the level selected; T adds an --- action to a choice. -function forgeKey(keysym, scancode) - local rules = FORGE.game.rules - local room = forgeRoom() - - -- SCANCODE entries are tables of { name, value } and the engine hands onKeyPressed two plain - -- integers, so the comparison is against .value. - if forgePickKey(scancode, keysym) then - return - end - if FORGE.drawing then - if (scancode == SCANCODE.V.value) or (scancode == SCANCODE.RETURN.value) then - forgePolygonClose() - elseif scancode == SCANCODE.ESCAPE.value then - FORGE.drawing = nil - forgePanelShow(true) - FORGE.message = "polygon dropped" - forgeRefresh() - end - return - end - if FORGE.editing ~= nil then - if scancode == SCANCODE.RETURN.value then - forgeEditNext() - return - end - if scancode == SCANCODE.ESCAPE.value then - forgeEditCancel() - return - end - -- The caret's keys. DELETE on a value just entered, still selected whole, empties it: - -- an action's entity cleared is the game itself. - if scancode == SCANCODE.DELETE.value then - forgeEditDelete() - return - elseif scancode == SCANCODE.LEFT.value then - forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor - 1)) - return - elseif scancode == SCANCODE.RIGHT.value then - forgeEditCaret(FORGE.editing.selected and #FORGE.editing.text or (FORGE.editing.cursor + 1)) - return - elseif scancode == SCANCODE.HOME.value then - forgeEditCaret(0) - return - elseif scancode == SCANCODE.END.value then - forgeEditCaret(#FORGE.editing.text) - return - end - if forgeTyped(keysym) then - return - end - elseif scancode == SCANCODE.RETURN.value then - forgeEditNext() - return - end - if scancode ~= SCANCODE.ESCAPE.value then - FORGE.closing = false - end - - if scancode == SCANCODE.TAB.value then - for at, mode in ipairs(MODES) do - if mode == FORGE.mode then - forgeMode(MODES[(at % #MODES) + 1]) - return - end - end - forgeMode("entities") - - -- The digits go straight to a panel, so types to entities is one key and not four. - elseif (scancode >= SCANCODE.MAIN_1.value) and (scancode <= SCANCODE.MAIN_5.value) then - forgeMode(MODES[scancode - SCANCODE.MAIN_1.value + 1]) - - elseif scancode == SCANCODE.LEFT.value then - forgePanelTo(FORGE.panelX - 40) - elseif scancode == SCANCODE.RIGHT.value then - forgePanelTo(FORGE.panelX + 40) - elseif scancode == SCANCODE.PAGEUP.value then - forgeRoomGo(FORGE.room - 1) - elseif scancode == SCANCODE.PAGEDOWN.value then - forgeRoomGo(FORGE.room + 1) - - -- The editor camera, in a 3D room: J and L orbit, Y and H tilt, I and K close in and back off. - elseif FORGE.scene and (scancode == SCANCODE.J.value) then - forgeOrbit(-ORBIT_STEP, 0, 1) - elseif FORGE.scene and (scancode == SCANCODE.L.value) then - forgeOrbit(ORBIT_STEP, 0, 1) - elseif FORGE.scene and (scancode == SCANCODE.Y.value) then - forgeOrbit(0, ORBIT_STEP, 1) - elseif FORGE.scene and (scancode == SCANCODE.H.value) then - forgeOrbit(0, -ORBIT_STEP, 1) - elseif FORGE.scene and (scancode == SCANCODE.I.value) then - forgeOrbit(0, 0, 1 / ZOOM_STEP) - elseif FORGE.scene and (scancode == SCANCODE.K.value) and (FORGE.mode ~= "tracks") then - forgeOrbit(0, 0, ZOOM_STEP) - - elseif FORGE.mode == "entities" then - if scancode == SCANCODE.DOWN.value then - forgeSelect(math.min((FORGE.selected or 0) + 1, #room.entities)) - elseif scancode == SCANCODE.UP.value then - forgeSelect(math.max((FORGE.selected or 2) - 1, 1)) - elseif scancode == SCANCODE.A.value then - forgeEntityAdd() - elseif scancode == SCANCODE.D.value then - forgeEntityDuplicate() - elseif scancode == SCANCODE.DELETE.value then - forgeEntityDelete() - elseif scancode == SCANCODE.N.value then - forgeRoomAdd() - elseif (scancode == SCANCODE.V.value) and not FORGE.scene then - forgePolygonBegin() - end - - elseif FORGE.mode == "types" then - local names = sortedKeys(FORGE.game.types) - local at = 0 - - for index, name in ipairs(names) do - if name == FORGE.typeName then - at = index - end - end - if scancode == SCANCODE.DOWN.value then - FORGE.typeName = names[math.min(at + 1, #names)] or FORGE.typeName - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.UP.value then - FORGE.typeName = names[math.max(at - 1, 1)] or FORGE.typeName - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.A.value then - forgeTypeAdd() - elseif scancode == SCANCODE.D.value then - forgeTypeDuplicate() - elseif scancode == SCANCODE.DELETE.value then - forgeTypeDelete() - end - - elseif FORGE.mode == "rules" then - -- In the rules, up and down walk the rule and its parts as one list: past the last part - -- of a rule is the next rule, which is how it reads on screen. - if scancode == SCANCODE.DOWN.value then - local parts = forgeParts(rules[FORGE.rule] or {}) - - if FORGE.part < #parts then - FORGE.part = FORGE.part + 1 - elseif FORGE.rule < #rules then - FORGE.rule = FORGE.rule + 1 - FORGE.part = 0 - end - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.UP.value then - if FORGE.part > 0 then - FORGE.part = FORGE.part - 1 - elseif FORGE.rule > 1 then - FORGE.rule = FORGE.rule - 1 - FORGE.part = #forgeParts(rules[FORGE.rule] or {}) - end - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.N.value then - forgeRuleNew() - elseif scancode == SCANCODE.E.value then - if rules[FORGE.rule] ~= nil then - forgePickBegin("what the rule is on", AUTHOR.events, forgeRuleOn) - end - elseif scancode == SCANCODE.C.value then - if rules[FORGE.rule] ~= nil then - forgePickBegin("a condition", AUTHOR.conditions, function(name) forgeRuleAdd("when", name) end) - end - elseif scancode == SCANCODE.O.value then - if rules[FORGE.rule] ~= nil then - forgePickBegin("a condition, any of which", AUTHOR.conditions, function(name) forgeRuleAdd("any", name) end) - end - elseif scancode == SCANCODE.T.value then - if rules[FORGE.rule] ~= nil then - forgePickBegin("an action", AUTHOR.actions, function(name) forgeRuleAdd("act", name) end) - end - elseif scancode == SCANCODE.LEFTBRACKET.value then - forgeRuleMove(-1) - elseif scancode == SCANCODE.RIGHTBRACKET.value then - forgeRuleMove(1) - elseif scancode == SCANCODE.DELETE.value then - if FORGE.part > 0 then - forgePartDelete() - else - forgeRuleDelete() - end - end - - elseif FORGE.mode == "dialogues" then - if scancode == SCANCODE.DOWN.value then - forgeDialogueStep(1) - elseif scancode == SCANCODE.UP.value then - forgeDialogueStep(-1) - elseif scancode == SCANCODE.A.value then - forgeDialogueAdd() - elseif scancode == SCANCODE.T.value then - forgePickBegin("an action for the choice", AUTHOR.actions, forgeDialogueAct) - elseif scancode == SCANCODE.DELETE.value then - forgeDialogueDelete() - end - - elseif FORGE.mode == "tracks" then - local track = room.tracks[FORGE.track] - local keys = track and forgeTrackKeys(track) or {} - - if scancode == SCANCODE.DOWN.value then - if track and (FORGE.key < #keys) then - FORGE.key = FORGE.key + 1 - forgeCursorTo(keys[FORGE.key].at) - elseif FORGE.track < #room.tracks then - FORGE.track = FORGE.track + 1 - FORGE.key = 0 - end - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.UP.value then - if FORGE.key > 0 then - FORGE.key = FORGE.key - 1 - if FORGE.key > 0 then - forgeCursorTo(keys[FORGE.key].at) - end - elseif FORGE.track > 1 then - FORGE.track = FORGE.track - 1 - FORGE.key = #forgeTrackKeys(room.tracks[FORGE.track]) - end - FORGE.lastField = nil - forgeRefresh() - elseif scancode == SCANCODE.N.value then - forgeTrackNew() - elseif scancode == SCANCODE.W.value then - forgeTrackNew(nil, true) - elseif scancode == SCANCODE.K.value then - forgeKeyAdd() - elseif scancode == SCANCODE.COMMA.value then - forgeCursorTo(FORGE.cursor - FRAME_STEP) - elseif scancode == SCANCODE.PERIOD.value then - forgeCursorTo(FORGE.cursor + FRAME_STEP) - elseif scancode == SCANCODE.LEFTBRACKET.value then - forgeCursorTo(FORGE.cursor - FRAME_LEAP) - elseif scancode == SCANCODE.RIGHTBRACKET.value then - forgeCursorTo(FORGE.cursor + FRAME_LEAP) - elseif scancode == SCANCODE.DELETE.value then - if FORGE.key > 0 then - forgeKeyDelete() - else - forgeTrackDelete() - end - end - end - - if scancode == SCANCODE.S.value then - forgeSave() - elseif scancode == SCANCODE.B.value then - forgeBuild(singeGetDataPath() .. "preview.singe") - elseif scancode == SCANCODE.X.value then - forgeExport(forgeReleaseFolder()) - elseif scancode == SCANCODE.U.value then - forgeUndo() - elseif scancode == SCANCODE.R.value then - forgeRedo() - end -end - - --- ===== Typing a value ========================================================================= --- --- A rule editor that can add "when keyHeld" but cannot change SPACE to UP is most of the way to --- useless, which is the half of the job that matters for someone with little programming skill. --- --- Keysyms arrive as characters the way Tools.singe takes them, so this needs nothing of the GUI: --- ENTER starts editing whatever is selected, each press moves to its next field, and ESCAPE puts --- back what was there. An entity, a type, a rule, a key, and a condition are typed the same way; --- forgeFields says what the fields are and how to reach them. - --- The parameters of a condition or action, in the order the panel shows them, so typing walks them --- the same way. --- A part's fields: every parameter its condition or action takes, whether set yet or not, and --- anything else the description gave it. -function forgeFieldNames(item) - local entry = AUTHOR.conditions[item[1]] or AUTHOR.actions[item[1]] or {} - local seen = {} - local keys = {} - - for key in pairs(entry.params or {}) do - seen[key] = true - keys[#keys + 1] = key - end - for key in pairs(item) do - if (key ~= 1) and not seen[key] then - keys[#keys + 1] = key - end - end - table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) - - return keys -end - - --- What ENTER edits: the fields of whatever is selected, with a way to read and write each. nil --- when nothing is. -function forgeFields() - local room = forgeRoom() - - if FORGE.mode == "rules" then - local rule = FORGE.game.rules[FORGE.rule] - local part - - if rule == nil then - return nil - end - if FORGE.part == 0 then - return { - names = forgeRuleFieldNames(rule), - get = function(key) - if key == "controls" then - return rule.controls ~= false - elseif key == "interrupt" then - return rule.interrupt == true - end - return rule[key] - end, - set = function(key, value) - if key == "on" then - if AUTHOR.events[value] then - forgeRuleOn(value) - end - return - elseif key == "controls" then - rule.controls = (value ~= false) and nil or false - elseif key == "interrupt" then - rule.interrupt = (value == true) or nil - elseif (key == "each" or key == "room") and (value == "") then - rule[key] = nil - else - rule[key] = value - end - FORGE.dirty = true - forgeRefresh() - end - } - end - part = forgeParts(rule)[FORGE.part] - if part == nil then - return nil - end - return { - names = forgeFieldNames(part.item), - get = function(key) return part.item[key] end, - set = forgePartSet - } - elseif FORGE.mode == "types" then - local entityType = FORGE.game.types[FORGE.typeName or ""] - - if entityType == nil then - return nil - end - return { - names = forgeTypeFieldNames(entityType), - get = function(key) return forgeTypeGet(entityType, key) end, - set = function(key, value) forgeTypeSet(entityType, key, value) end - } - elseif FORGE.mode == "dialogues" then - return forgeDialogueFields() - elseif FORGE.mode == "tracks" then - local track = room.tracks[FORGE.track] - - if track == nil then - return nil - end - if FORGE.key == 0 then - return { - names = { "name", "key" }, - get = function(key) return track[key] end, - set = function(key, value) - if (key ~= "key") or (value == "frame") or (value == "time") or ((value == "stop") and track.spawns) then - track[key] = value - end - FORGE.dirty = true - forgeRefresh() - end - } - end - local keys = forgeTrackKeys(track) - local box = keys[FORGE.key] - - return { - names = track.spawns and { "at", "type", "count", "every", "from", "x", "y", "z" } or (track.points and { "at", "x", "y", "z", "lookX", "lookY", "lookZ", "stop" } or { "at", "x", "y", "w", "h" }), - get = function(key) - if key:sub(1, 4) == "look" then - local look = box.look or {} - local axis = key:sub(5):lower() - - return look[axis] or look[({ x = 1, y = 2, z = 3 })[axis]] - end - return box[key] - end, - set = function(key, value) - if key:sub(1, 4) == "look" then - local axis = key:sub(5):lower() - - if type(value) == "number" then - box.look = box.look or { 0, 0, 0 } - if box.look.x ~= nil then - box.look[axis] = value - else - box.look[({ x = 1, y = 2, z = 3 })[axis]] = value - end - end - elseif key == "stop" then - box.stop = (value ~= "") and tostring(value) or nil - elseif track.spawns and ((key == "type") or (key == "from") or ((key == "at") and (track.key == "stop"))) then - -- A wave's words: its type, where it comes from, and the stop it waits for. - box[key] = ((value ~= nil) and (value ~= "")) and tostring(value) or nil - if key == "at" then - table.sort(keys, forgeKeyOrder) - for at, item in ipairs(keys) do - if item == box then - FORGE.key = at - end - end - end - elseif type(value) == "number" then - box[key] = value - if key == "at" then - table.sort(keys, forgeKeyOrder) - for at, item in ipairs(keys) do - if item == box then - FORGE.key = at - end - end - end - end - FORGE.dirty = true - forgeRefresh() - end - } - end - local entry = room.entities[FORGE.selected] - - if entry == nil then - -- Nothing selected: the game, then the room itself. - return { - names = forgeRoomFieldNames(), - get = function(key) return forgeRoomGet(room, key) end, - set = function(key, value) forgeRoomSet(room, key, value) end - } - end - - return { - names = forgeEntityFieldNames(entry), - get = function(key) return forgeEntityGet(entry, key) end, - set = function(key, value) forgeEntitySet(entry, key, value) end - } -end - - --- A room's own fields, typed as text: walk areas as polygons of x,y pairs separated by --- semicolons, navFrom as a list of entity ids, scaleBy as y:scale pairs. --- 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", "vocabulary" } - - for _, credit in ipairs(CREDIT_FIELDS) do - names[#names + 1] = "credits." .. credit.field - end - names[#names + 1] = "credits.notes" - names[#names + 1] = "credits.licenses" - for _, layer in ipairs(FORGE.game.layers or {}) do - local entry = AUTHOR.layers[layer.kind] - - for _, key in ipairs(sortedKeys(entry and entry.params or layer)) do - if key ~= "kind" then - names[#names + 1] = layer.kind .. "." .. key - end - end - end - for _, key in ipairs({ "name", "reset", "depthSort", "walk", "navFrom", "scaleBy" }) do - names[#names + 1] = key - end - - return names -end - - -local function layerOf(name) - for _, layer in ipairs(FORGE.game.layers or {}) do - if layer.kind == name then - return layer - end - end - - return nil -end - - -function forgeRoomGet(room, key) - local group, field = string.match(key, "^([^.]+)%.(.+)$") - - if key == "title" then - return FORGE.game.title - elseif key == "players" then - return FORGE.game.players or 1 - elseif key == "vars" then - return varsText(FORGE.game.vars) - elseif key == "verbs" then - 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 == "credits" then - local credits = FORGE.game.credits or {} - - if field == "notes" then - return table.concat(credits.notes or {}, " | ") - elseif field == "licenses" then - return table.concat(credits.licenses or {}, ", ") - end - return (credits[field] ~= nil) and tostring(credits[field]) or nil - elseif group ~= nil then - local layer = layerOf(group) - - return layer and tableText(layer[field]) or nil - elseif key == "walk" then - local polygons = {} - - for _, polygon in ipairs(room.walk or {}) do - local pairs_ = {} - - for at = 1, #polygon - 1, 2 do - pairs_[#pairs_ + 1] = polygon[at] .. "," .. polygon[at + 1] - end - polygons[#polygons + 1] = table.concat(pairs_, " ") - end - return table.concat(polygons, "; ") - elseif key == "navFrom" then - return table.concat(room.navFrom or {}, ", ") - elseif key == "scaleBy" then - local parts = {} - - for _, item in ipairs(room.scaleBy or {}) do - parts[#parts + 1] = item.y .. ":" .. item.scale - end - return table.concat(parts, ", ") - elseif key == "reset" or key == "depthSort" then - return room[key] == true - end - - return room[key] -end - - -function forgeRoomSet(room, key, value) - local group, field = string.match(key, "^([^.]+)%.(.+)$") - - if key == "title" then - FORGE.game.title = tostring(value) - elseif group == "credits" then - local credits = FORGE.game.credits or {} - local text = (value ~= nil) and tostring(value) or "" - local list = {} - - if field == "notes" then - for line in string.gmatch(text, "[^|]+") do - line = line:match("^%s*(.-)%s*$") - if line ~= "" then - list[#list + 1] = line - end - end - credits.notes = (#list > 0) and list or nil - elseif field == "licenses" then - for name in string.gmatch(text, "[^,]+") do - name = name:match("^%s*(.-)%s*$") - if name ~= "" then - list[#list + 1] = name - end - end - credits.licenses = (#list > 0) and list or nil - else - credits[field] = (text ~= "") and (tonumber(text) or text) or nil - end - FORGE.game.credits = (next(credits) ~= nil) and credits or nil - elseif key == "vocabulary" then - -- The game's own vocabulary, loaded as it is named so the panels offer what it adds. - FORGE.game.vocabulary = ((value ~= nil) and (value ~= "")) and tostring(value) or nil - 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 - end - elseif key == "vars" then - FORGE.game.vars = varsFrom(value) - elseif key == "verbs" then - local verbs = {} - - for verb in string.gmatch(tostring(value), "[^,%s]+") do - verbs[#verbs + 1] = verb - end - FORGE.game.verbs = (#verbs > 0) and verbs or nil - elseif key == "layers" then - FORGE.stash.layers = FORGE.stash.layers or {} - FORGE.game.layers = listFrom(value, FORGE.game.layers, AUTHOR.layers, FORGE.stash.layers, "layer") - -- A scene3d layer typed in or out turns the canvas into the viewport, or back. - if forgeIs3D() then - if FORGE.scene == nil then - forgeSceneBegin() - end - elseif FORGE.scene ~= nil then - forgeSceneClear() - end - elseif group ~= nil then - local layer = layerOf(group) - - if layer ~= nil then - layer[field] = valueFor((AUTHOR.layers[group] or {}).params, field, value) - end - elseif key == "walk" then - local polygons = {} - - for text in string.gmatch(tostring(value), "[^;]+") do - local polygon = {} - - for number in string.gmatch(text, "-?[%d.]+") do - polygon[#polygon + 1] = tonumber(number) - end - if #polygon >= 6 then - polygons[#polygons + 1] = polygon - end - end - room.walk = (#polygons > 0) and polygons or nil - elseif key == "navFrom" then - local ids = {} - - for id in string.gmatch(tostring(value), "[^,%s]+") do - ids[#ids + 1] = id - end - room.navFrom = (#ids > 0) and ids or nil - elseif key == "scaleBy" then - local list = {} - - for y, scale in string.gmatch(tostring(value), "(-?[%d.]+)%s*:%s*(-?[%d.]+)") do - list[#list + 1] = { y = tonumber(y), scale = tonumber(scale) } - end - room.scaleBy = (#list > 0) and list or nil - elseif key == "reset" or key == "depthSort" then - room[key] = (value == true) or nil - elseif key == "name" then - if (value ~= "") then - room.name = tostring(value) - end - end - -- A disc named or renamed is what the canvas shows from now on. - loadVideo() - FORGE.dirty = true - forgeRefresh() -end - - --- A typed value as the description should hold it. A number has to come back a number: "220" and --- 220 compile to different source, and a description that changed shape because a value was --- retyped would stop round-tripping. -local function typedValue(text, was) - local number = tonumber(text) - - if (type(was) == "number") and (number ~= nil) then - return number - end - if text == "true" then - return true - end - if text == "false" then - return false - end - if (type(was) ~= "string") and (number ~= nil) then - return number - end - - return text -end - - --- What is being typed: the field's value as text, whole and selected to begin with, so typing --- replaces it and the arrows, Home, and End move a caret into it to edit it instead. changed --- says whether anything was done to it, so a field passed over keeps its value. -function forgeEditingOf(key, was, set) - local text = (was ~= nil) and tostring(was) or "" - - return { key = key, text = text, cursor = #text, selected = (text ~= ""), changed = false, was = was, set = set } -end - - --- What the text being typed is worth: nothing when it is empty, and false is a value. -function forgeEditValue() - if FORGE.editing.text == "" then - return nil - end - - return typedValue(FORGE.editing.text, FORGE.editing.was) -end - - -function forgeEditCommit() - if FORGE.editing == nil then - return false - end - -- A field passed over with nothing done to it keeps its value: ENTER, ENTER, ENTER walks the - -- form without touching it. One emptied is set to nothing. - if FORGE.editing.changed then - FORGE.editing.set(FORGE.editing.key, forgeEditValue()) - end - FORGE.editing = nil - - return true -end - - -function forgeEditCancel() - if FORGE.editing == nil then - return false - end - -- 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" - forgeRefresh() - - return true -end - - --- Starts editing, or moves on to the next field of the same thing. Committing as it moves is --- what makes ENTER, ENTER, ENTER feel like filling in a form. -function forgeEditNext() - local fields = forgeFields() - local at = 0 - - if fields == nil then - return false - end - if #fields.names == 0 then - FORGE.message = "nothing to type here" - forgeRefresh() - return false - end - if FORGE.editing ~= nil then - forgeEditCommit() - else - -- One undo step for the whole form, taken when typing starts. - forgeRemember() - end - for i = 1, #fields.names do - if fields.names[i] == FORGE.lastField then - at = i - end - end - at = (at % #fields.names) + 1 - FORGE.lastField = fields.names[at] - FORGE.editing = forgeEditingOf(fields.names[at], fields.get(fields.names[at]), fields.set) - -- A field with a fixed set of values -- a file, a flag, a key, a type, a look -- offers - -- them as a list, opened on the value it has (or on (none) when it has none) so ENTER - -- straight away keeps things as they are and the form can be walked through without - -- changing it. ESC on the list leaves the field being typed instead. - if forgeFieldList(fields, fields.names[at]) then - return true - end - FORGE.message = "type a value for " .. fields.names[at] .. ", ENTER for the next, ESC to put it back" - forgeRefresh() - - return true -end - - --- One typed character. Point the engine's onKeyPressed at forgeKey and this is reached from there. -function forgeTyped(keysym) - if FORGE.editing == nil then - return false - end - if keysym == 8 then - forgeEditBackspace() - elseif (keysym >= 32) and (keysym < 127) and not FORGE.textInput then - -- A keysym is the unshifted key. With the engine delivering typed text (onTextInput), - -- the characters come through forgeText instead, shifted as the person typed them. - forgeEditInsert(string.char(keysym)) - else - return false - end - - return true -end - - --- Puts text in at the caret, or in place of the selected value. -function forgeEditInsert(text) - local editing = FORGE.editing - - if editing.selected then - editing.text = text - editing.cursor = #text - editing.selected = false - else - editing.text = editing.text:sub(1, editing.cursor) .. text .. editing.text:sub(editing.cursor + 1) - editing.cursor = editing.cursor + #text - end - editing.changed = true - forgeTypedShow() -end - - --- Takes out the character before the caret, or the whole selected value. -function forgeEditBackspace() - local editing = FORGE.editing - - if editing.selected then - editing.text = "" - editing.cursor = 0 - editing.selected = false - elseif editing.cursor > 0 then - editing.text = editing.text:sub(1, editing.cursor - 1) .. editing.text:sub(editing.cursor + 1) - editing.cursor = editing.cursor - 1 - else - return - end - editing.changed = true - forgeTypedShow() -end - - --- Takes out the character after the caret, or the whole selected value: DELETE on a field just --- entered empties it. -function forgeEditDelete() - local editing = FORGE.editing - - if editing.selected then - editing.text = "" - editing.cursor = 0 - editing.selected = false - editing.was = nil -- Emptied for good: ESC leaves it empty. - FORGE.message = editing.key .. " emptied" - elseif editing.cursor < #editing.text then - editing.text = editing.text:sub(1, editing.cursor) .. editing.text:sub(editing.cursor + 2) - else - return - end - editing.changed = true - forgeTypedShow() -end - - --- Moves the caret: by one, or to an end. A selected value is dropped into a caret first. -function forgeEditCaret(to) - local editing = FORGE.editing - - editing.selected = false - editing.cursor = math.max(0, math.min(#editing.text, to)) - forgeRefresh() -end - - --- Typed text, as the engine delivers it in full keyboard mode: what the keys produced with --- their shifts and layout, so a capital or a quote is what was typed. Goes to the picker's filter --- while a list is up, and to the field being edited otherwise. -function forgeText(text) - if FORGE.picker ~= nil then - FORGE.picker.filter = FORGE.picker.filter .. text - FORGE.picker.at = 1 - forgeRefresh() - return true - end - if FORGE.editing == nil then - return false - end - forgeEditInsert(text) - - return true -end - - --- Shows the field as it is being typed: the value in the list is the value being entered. -function forgeTypedShow() - FORGE.editing.set(FORGE.editing.key, forgeEditValue()) - FORGE.message = "typing " .. FORGE.editing.key .. ": ENTER for the next, ESC to put it back" - forgeRefresh() -end - - --- ===== Files ================================================================================== --- --- 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's id: the UUID the master service keys scores and updates on. Minted the first time a --- description is saved and never changed after, so a game uploaded once can be updated for ever; --- a description written by hand without one gets one the first time Forge saves it. Version 4, --- from the engine's generator, which is as random as a game id needs to be. -function forgeIdFor(game) - if type(game.id) == "string" and game.id:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") then - return game.id - end - math.randomseed(os.time() + math.floor(os.clock() * 1000000)) - local hex = {} - for index = 1, 32 do - local digit = math.random(0, 15) - if index == 13 then - digit = 4 -- the version nibble - elseif index == 17 then - digit = 8 + math.random(0, 3) -- the variant nibble: 8, 9, a, or b - end - hex[index] = string.format("%x", digit) - end - local text = table.concat(hex) - game.id = string.format("%s-%s-%s-%s-%s", text:sub(1, 8), text:sub(9, 12), text:sub(13, 16), text:sub(17, 20), text:sub(21, 32)) - return game.id -end - - --- 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. -function forgeTemplate() - return { - id = forgeIdFor({}), - title = "New game", - layers = { { kind = "world2d", gravity = 1500 } }, - vars = { score = 0 }, - types = { - ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170, anchor = "feet" }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, - readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } - }, - rooms = { - { name = "start", - entities = { - { type = "ground", id = "ground", x = 360, y = 440 }, - { type = "hero", id = "hero", x = 120, y = 420 }, - { type = "readout", id = "readout", x = 12, y = 12 } - }, - tracks = {} } - }, - rules = { - { note = "Run left", on = "frame", when = { { "keyHeld", key = "LEFT" } }, act = { { "run", entity = "hero", direction = -1 } } }, - { note = "Run right", on = "frame", when = { { "keyHeld", key = "RIGHT" } }, act = { { "run", entity = "hero", direction = 1 } } }, - { note = "Jump, with ground underfoot", on = "frame", when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, act = { { "jump", entity = "hero" } } }, - { note = "The readout, every frame", on = "frame", act = { { "setText", entity = "readout", text = '"score " .. score' } } } - } - } -end - - --- Every .forge description in a directory, sorted. A directory that is not there lists nothing. -local function descriptionsIn(directory) - local found = {} - - if lfs.attributes(directory, "mode") ~= "directory" then - return found - end - for name in lfs.dir(directory) do - 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) - - return found -end - - --- What the chooser offers: the data directory's descriptions, then Forge's own, then a new game. -function forgeFiles() - local files = descriptionsIn(singeGetDataPath()) - - for _, sample in ipairs(descriptionsIn("Forge/")) do - sample.sample = true - files[#files + 1] = sample - end - for _, tutorial in ipairs(descriptionsIn("Forge/tutorials/")) do - tutorial.sample = true - tutorial.tutorial = true - files[#files + 1] = tutorial - end - files[#files + 1] = { name = "New game", fresh = true } - - return files -end - - --- A path in the data directory no description has yet. -local function freshPath(stem) - local base = singeGetDataPath() .. stem - local n = 1 - - if lfs.attributes(base .. ".forge") == nil then - return base .. ".forge" - end - while lfs.attributes(base .. n .. ".forge") ~= nil do - n = n + 1 - end - - return base .. n .. ".forge" -end - - --- Opens an entry from forgeFiles. A sample is copied first; a new game is written first. -function forgeOpen(entry) - local path = entry.path - - if entry.fresh then - path = freshPath("NewGame") - authorSave(forgeTemplate(), path) - elseif entry.sample then - path = freshPath(entry.name) - if not authorCopy(entry.path, path) then - return false - end - end - if not forgeBegin(path) then - return false - end - if entry.sample then - FORGE.message = "opened a copy: " .. path - elseif entry.fresh then - FORGE.message = "new game at " .. path - end - forgeRefresh() - - return true -end - - --- ===== The chooser ============================================================================ --- --- What Forge opens on: the descriptions in its data directory, the samples and tutorials inside --- Forge.game, and a new game. Part of the library so a scene can draw it and press its keys. - -local MANUAL = singeGetDataPath() .. "Forge.pdf" -- The manual, put where a person can open it. -local MANUAL_SHOWN = 64 -- Characters of its path the chooser has room for. -local ROW_H = 22 -local LIST_X = 60 -local LIST_Y = 80 - - -function forgeChooserScan() - FORGE.chooser.files = forgeFiles() - FORGE.chooser.at = math.max(1, math.min(FORGE.chooser.at, #FORGE.chooser.files)) -end - - --- The manual travels inside Forge.game, where nothing but Singe can read it. The first run --- copies it out beside the descriptions, and the chooser says where. -function forgeChooserManual() - if (lfs.attributes(MANUAL, "mode") ~= "file") and (lfs.attributes("Forge/Forge.pdf", "mode") == "file") then - authorCopy("Forge/Forge.pdf", MANUAL) - end - - return (lfs.attributes(MANUAL, "mode") == "file") and MANUAL or nil -end - - -function forgeChooserDraw() - local y = LIST_Y - - colorBackground(18, 18, 26, 255) - overlayClear() - colorForeground(255, 207, 74, 255) - fontPrint(LIST_X, 30, "Forge") - colorForeground(154, 160, 180, 255) - fontPrint(LIST_X + 80, 30, "UP / DOWN choose ENTER open ESC leave") - if FORGE.chooser.manual ~= nil then - local shown = FORGE.chooser.manual - - -- A data directory given on the command line can be anywhere; the end of the path is - -- the part that says where the file is. - if #shown > MANUAL_SHOWN then - shown = "..." .. string.sub(shown, -MANUAL_SHOWN) - end - fontPrint(LIST_X, overlayGetHeight() - 40, "Manual: " .. shown) - end - for at, entry in ipairs(FORGE.chooser.files) do - local label = entry.name - - if entry.tutorial then - label = "tutorial " .. label .. " (a copy will be made)" - elseif entry.sample then - label = label .. " (a copy will be made)" - end - if at == FORGE.chooser.at then - colorForeground(192, 60, 150, 255) - fill(LIST_X - 10, y - 3, 410, ROW_H - 3) - colorForeground(255, 255, 255, 255) - else - colorForeground(230, 230, 238, 255) - end - fontPrint(LIST_X, y, label) - y = y + ROW_H - end -end - - -function forgeChooserKey(scancode) - if scancode == SCANCODE.DOWN.value then - FORGE.chooser.at = math.min(FORGE.chooser.at + 1, #FORGE.chooser.files) - elseif scancode == SCANCODE.UP.value then - FORGE.chooser.at = math.max(FORGE.chooser.at - 1, 1) - elseif scancode == SCANCODE.RETURN.value then - forgeOpen(FORGE.chooser.files[FORGE.chooser.at]) - elseif scancode == SCANCODE.ESCAPE.value then - singeQuit() - end -end - - - - --- ===== Forge as a game ======================================================================== --- --- Everything above is a library; this is what runs when Forge is started from the menu. A scene --- that drives the editor sets FORGE_LIBRARY and supplies its own callbacks instead. --- --- Playing is a scriptPush of the built game. The engine runs this script again from the top when --- the game ends, so the description being edited is noted in a file first and reopened on the way --- back, which is what makes P feel like a button rather than a restart. - -if not FORGE_LIBRARY then - - local LAST_FILE = singeGetDataPath() .. "last.txt" -- What was open when play was pressed. - local font = fontLoad("Singe/FreeSansBold.ttf", 15) - - fontSelect(font) - fontQuality(FONT_QUALITY_BLENDED) - overlaySetResolution(720, 480) - - -- Every key reaches the editor -- TAB, ENTER, the digits, the letters typed into a form -- - -- rather than only the ones controls.cfg maps to switches, which is all a game gets by - -- default. The mouse and a pad still arrive as switches. - keyboardSetMode(MODE_FULL) - - - -- Saves, builds, and hands the game to the engine. Back here afterwards, on the same file. - function forgePlay() - local built = forgeBuild(singeGetDataPath() .. "preview.singe", FORGE.room) - local note = io.open(LAST_FILE, "w") - - forgeSave() - if note ~= nil then - note:write(FORGE.path) - note:close() - end - scriptPush({ SCRIPT = built, VIDEO = forgeVideo() }) - end - - - -- Where play left off, if it did. The note is read once and removed, so a fresh start shows - -- the chooser. - local function reopen() - local note = io.open(LAST_FILE, "r") - local path - - if note == nil then - return false - end - path = note:read("l") - note:close() - os.remove(LAST_FILE) - if (path == nil) or not forgeBegin(path) then - return false - end - FORGE.message = "back from playing" - forgeRefresh() - - return true - end - - - -- ESC in the editor closes the description; twice, when it has unsaved changes. - local function editorEscape() - if FORGE.dirty and not FORGE.closing then - FORGE.closing = true - FORGE.message = "unsaved: S saves, ESC again leaves without saving" - forgeRefresh() - return - end - forgeClose() - forgeChooserScan() - end - - - function onOverlayUpdate() - -- The master service's client, once Publish has loaded it, needs a turn every frame. - if masterPump ~= nil then - masterPump() - end - if FORGE.game == nil then - forgeChooserDraw() - else - local x, y = mouseGetPosition(0) - - forgeDraw(x, y) - end - - return OVERLAY_UPDATED - end - - - -- Typed text, shifted and laid out as the person typed it; while it arrives, the keys' own - -- unshifted characters are not typed as well. - function onTextInput(text) - FORGE.textInput = true - forgeText(text) - end - - - function onKeyPressed(keysym, scancode) - if FORGE.game == nil then - forgeChooserKey(scancode) - elseif (scancode == SCANCODE.ESCAPE.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then - editorEscape() - elseif (scancode == SCANCODE.P.value) and (FORGE.editing == nil) and (FORGE.picker == nil) then - -- P plays; Shift+P publishes, one step past X's export. - if (keyboardGetModifiers() & MODIFIER.SHIFT.value) ~= 0 then - forgePublish() - else - forgePlay() - end - else - forgeKey(keysym, scancode) - end - end - - - -- The pad walks the chooser; in the editor the left mouse button is the pointer's press. - function onInputPressed(what) - -- A key masquerading as a switch (BACKSPACE as the mouse button) is handled as the key. - if authorSwitchIsKey(what) then - return - end - if FORGE.game == nil then - if what == SWITCH_DOWN then - forgeChooserKey(SCANCODE.DOWN.value) - elseif what == SWITCH_UP then - forgeChooserKey(SCANCODE.UP.value) - elseif (what == SWITCH_START1) or (what == SWITCH_BUTTON1) then - forgeChooserKey(SCANCODE.RETURN.value) - end - elseif what == SWITCH_BUTTON3 then - local x, y = mouseGetPosition(0) - - forgePress(x, y) - end - end - - - function onInputReleased(what) - if (FORGE.game ~= nil) and (what == SWITCH_BUTTON3) and not authorSwitchIsKey(what) then - forgeRelease() - end - end - - - function onMouseMoved(x, y) - if FORGE.game ~= nil then - forgeDrag(x, y) - end - end - - - FORGE.chooser.manual = forgeChooserManual() - if not reopen() then - forgeChooserScan() - end - -end diff --git a/forge/games.dat b/forge/games.dat deleted file mode 100644 index 978e19e30..000000000 --- a/forge/games.dat +++ /dev/null @@ -1,26 +0,0 @@ --- Forge appears in the menu like anything else, so it is reached the same way a game is and packs --- to Forge.game with -P. It needs no disc of its own; the menu background stands in, because the --- engine wants a video and Forge draws over all of it anyway. -GAMES = { - { - TITLE = "Forge", - SCRIPT = "Forge/Forge.singe", - REQUIRES = { "GUI" }, - LEGACY_SPRITE_ARGS = false, - VIDEO = "Singe/menuBackground.mkv", - STRETCH = false, - NO_MOUSE = false, - RESOLUTION_X = 720, - RESOLUTION_Y = 480, - SINDEN_GUN = "", - ATTRACT = "Singe/menuBackground.mkv", - ATTRACT_START = 180, - ATTRACT_END = 359, - YEAR = 2026, - PLATFORM = "Singe", - DEVELOPER = "Kangaroo Punch Studios", - PUBLISHER = "Kangaroo Punch Studios", - GENRE = "Tool", - DESCRIPTION = "Build a game by describing it: place things, write rules, press play. Forge writes ordinary Singe Lua, so nothing it makes is locked inside it." - } -} diff --git a/forge/kit/LICENSE b/forge/kit/LICENSE deleted file mode 100644 index 87389da1c..000000000 --- a/forge/kit/LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -The Forge tutorial kit. - -Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the -Singe source tree and is released under CC0: use it in your own games as you like. - -fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen, -rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest. -A game that ships it should carry this credit. diff --git a/forge/kit/README.txt b/forge/kit/README.txt deleted file mode 100644 index e15386eb0..000000000 --- a/forge/kit/README.txt +++ /dev/null @@ -1,11 +0,0 @@ -The Forge tutorial kit, addressed from a game as Forge/kit/. - -hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit -enemy.png a sheet, 4 columns of 32 x 32, flapping -tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick -coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills -yard.png hall.png painted rooms, 720 x 480, for the adventures -jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips -loop.ogg eight seconds of music that loops -clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s -fox.glb an animated model (see LICENSE) diff --git a/forge/kit/click.wav b/forge/kit/click.wav deleted file mode 100644 index 13277cc35..000000000 --- a/forge/kit/click.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95e21bd57bb4fc9bd7e251382e24a48ef63d7d01d98259008a19172c7b806208 -size 1808 diff --git a/forge/kit/clip.mkv b/forge/kit/clip.mkv deleted file mode 100644 index 0e2083b1b..000000000 --- a/forge/kit/clip.mkv +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6529fae0625a5c2859ffdb1a61be9d87f80c7cc873d23219e79860f8d836aa9c -size 42549 diff --git a/forge/kit/coin.png b/forge/kit/coin.png deleted file mode 100644 index d65f21d8d..000000000 --- a/forge/kit/coin.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:31a5213d47dd5b26894326c5f234a7dd0436f8f88ed0865f30fba2f0850ea528 -size 192 diff --git a/forge/kit/coin.wav b/forge/kit/coin.wav deleted file mode 100644 index ea91e45c1..000000000 --- a/forge/kit/coin.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cc1fcc85f50286868b66790c6609562d7cdcf4928740315a08521a81f84dcd18 -size 11510 diff --git a/forge/kit/crate.png b/forge/kit/crate.png deleted file mode 100644 index 4617418ca..000000000 --- a/forge/kit/crate.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1f621a5bf9837ab5257d9a35f4a08c882abbbd412c5df7c29470c91a6a3724a4 -size 286 diff --git a/forge/kit/door.png b/forge/kit/door.png deleted file mode 100644 index d830becdf..000000000 --- a/forge/kit/door.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d41b3f4b9f18da5f341f011e53143d784da1789a2540ab804774d64c6af7d85 -size 231 diff --git a/forge/kit/door.wav b/forge/kit/door.wav deleted file mode 100644 index ac7f28d40..000000000 --- a/forge/kit/door.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:96551c6781e3e69e8276760228c9c8ac310607ce4f5776a9c29be870393b1138 -size 17684 diff --git a/forge/kit/enemy.png b/forge/kit/enemy.png deleted file mode 100644 index 58c8dc066..000000000 --- a/forge/kit/enemy.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ecd914c6f41bc822bdc2bc5f4d83cea05ec4df857b81522988473a2b7a48cef -size 491 diff --git a/forge/kit/fox.glb b/forge/kit/fox.glb deleted file mode 100644 index c68b86d3c..000000000 --- a/forge/kit/fox.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7 -size 162852 diff --git a/forge/kit/hall.png b/forge/kit/hall.png deleted file mode 100644 index 7e6f004aa..000000000 --- a/forge/kit/hall.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4ee26b151e890dc8a4df2bae32542d684ff1fa7ecf268851f0723bba1761e58b -size 2527 diff --git a/forge/kit/hero.png b/forge/kit/hero.png deleted file mode 100644 index 278d8b3bc..000000000 --- a/forge/kit/hero.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:20bcb95bf065c3823b6217adfddedc3ab172eadf9fa81bda1e8b7b9829aea2ec -size 908 diff --git a/forge/kit/hit.wav b/forge/kit/hit.wav deleted file mode 100644 index 88c28f951..000000000 --- a/forge/kit/hit.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f631d2c98a63cc059fdab4b978ee89133e6859ab6a1653e22615c40e4cba407e -size 13274 diff --git a/forge/kit/jump.wav b/forge/kit/jump.wav deleted file mode 100644 index 325c81ab1..000000000 --- a/forge/kit/jump.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f907f366480e401d5456cec56a44744f96695b33bfc371aa969ee5e6c2dac866 -size 11068 diff --git a/forge/kit/key.png b/forge/kit/key.png deleted file mode 100644 index e016f22c3..000000000 --- a/forge/kit/key.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cb2282ba0c2a8e519463ca00aa7ce76bdde6356758ff3b311cfba973a792e2ec -size 161 diff --git a/forge/kit/loop.ogg b/forge/kit/loop.ogg deleted file mode 100644 index 4d888f3d7..000000000 --- a/forge/kit/loop.ogg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9d27304c178df218715978129e2d7a5ead7decd6a1d8f1dbf97974c8ce92708b -size 50059 diff --git a/forge/kit/rock.png b/forge/kit/rock.png deleted file mode 100644 index b4d27976d..000000000 --- a/forge/kit/rock.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:57db8cbb564b51e472e8ed23446762fb88e2791b5344c718030738e8deb97a80 -size 297 diff --git a/forge/kit/ship.png b/forge/kit/ship.png deleted file mode 100644 index 104559378..000000000 --- a/forge/kit/ship.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5c6682153b641100c81d881c22eaa1af284643456a5affb538f59cb76406541d -size 314 diff --git a/forge/kit/shot.png b/forge/kit/shot.png deleted file mode 100644 index 57d0771d2..000000000 --- a/forge/kit/shot.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3fe68847b2d223e14c1023570de600afdf567e1b09bd403172162b9bda125afe -size 120 diff --git a/forge/kit/shot.wav b/forge/kit/shot.wav deleted file mode 100644 index 58c1122ce..000000000 --- a/forge/kit/shot.wav +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dc12710ae5622938d4f28c4a5a86655c75d40bf29c5c574bbd6793826028f2c0 -size 6658 diff --git a/forge/kit/spike.png b/forge/kit/spike.png deleted file mode 100644 index 6dd7c5a64..000000000 --- a/forge/kit/spike.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2a6d8b67491352cb356c6d8d20480492bb4a9847c6e2ad8735225c49779a17b5 -size 153 diff --git a/forge/kit/target.png b/forge/kit/target.png deleted file mode 100644 index 73cdfb211..000000000 --- a/forge/kit/target.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c9a95bcd6a22970ad1f9626cd84fddd0db3de5d50a33fc8250b4dbae0776e937 -size 543 diff --git a/forge/kit/tiles.png b/forge/kit/tiles.png deleted file mode 100644 index 43629b054..000000000 --- a/forge/kit/tiles.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5b127780dcee14d08cc1a20d93362678e9c705d7a019573b7bff0a19b03a1c5 -size 411 diff --git a/forge/kit/yard.png b/forge/kit/yard.png deleted file mode 100644 index b431512e3..000000000 --- a/forge/kit/yard.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:643dfcff7892e4b77914989d1a5405694a8e68087f1523b3f87b50ea75e28067 -size 4431 diff --git a/forge/tutorials/01-tenMinutes.forge b/forge/tutorials/01-tenMinutes.forge deleted file mode 100644 index 5cea7b2ec..000000000 --- a/forge/tutorials/01-tenMinutes.forge +++ /dev/null @@ -1,76 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - layers = { - { gravity = 1500, kind = "world2d" } - }, - rooms = { - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "hero", type = "hero", x = 200, y = 420 }, - { id = "readout", type = "readout", x = 12, y = 12 } - }, - name = "start", - tracks = {} - } - }, - rules = { - { - act = { - { "run", direction = -1, entity = "hero" } - }, - note = "Run left", - on = "frame", - when = { - { "keyHeld", key = "LEFT" } - } - }, - { - act = { - { "run", direction = 1, entity = "hero" } - }, - note = "Run right", - on = "frame", - when = { - { "keyHeld", key = "RIGHT" } - } - }, - { - act = { - { "jump", entity = "hero" } - }, - note = "Jump, with ground underfoot", - on = "frame", - when = { - { "keyHeld", key = "SPACE" }, - { "onGround", entity = "hero" } - } - }, - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score" } - }, - note = "The readout, every frame", - on = "frame" - } - }, - title = "New game", - types = { - ground = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } - }, - hero = { - behaviours = { - { jump = 620, kind = "platformer", speed = 260 } - }, - look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 } - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - } - }, - vars = { score = 0 } -} diff --git a/forge/tutorials/02-platformer.forge b/forge/tutorials/02-platformer.forge deleted file mode 100644 index d88196432..000000000 --- a/forge/tutorials/02-platformer.forge +++ /dev/null @@ -1,144 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - layers = { - { gravity = 1500, kind = "world2d" } - }, - rooms = { - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "hero", type = "hero", x = 200, y = 420 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 300, y = 392 }, - { id = "coin1", type = "coin", x = 420, y = 392 }, - { id = "coin2", type = "coin", x = 560, y = 300 }, - { id = "spike", type = "spike", x = 640, y = 420 }, - { id = "door", type = "door", x = 700, y = 420 } - }, - name = "start", - tracks = {} - }, - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 400, y = 392 } - }, - name = "cave", - tracks = {} - } - }, - rules = { - { - act = { - { "run", direction = -1, entity = "hero" } - }, - note = "Run left", - on = "frame", - when = { - { "keyHeld", key = "LEFT" } - } - }, - { - act = { - { "run", direction = 1, entity = "hero" } - }, - note = "Run right", - on = "frame", - when = { - { "keyHeld", key = "RIGHT" } - } - }, - { - act = { - { "jump", entity = "hero" } - }, - note = "Jump, with ground underfoot", - on = "frame", - when = { - { "keyHeld", key = "SPACE" }, - { "onGround", entity = "hero" } - } - }, - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - a = "coin", - act = { - { "addScore", amount = 10 }, - { "destroy", entity = "self" }, - { "playSound", file = "Forge/kit/coin.wav" } - }, - b = "hero", - note = "Take a coin", - on = "enter", - when = {} - }, - { - a = "spike", - act = { - { "moveTo", entity = "hero", x = 200, y = 380 }, - { "playSound", file = "Forge/kit/hit.wav" } - }, - b = "hero", - note = "Spikes hurt", - on = "enter", - when = {} - }, - { - a = "door", - act = { - { "goTo", entity = "hero", room = "cave", x = 60, y = 380 } - }, - b = "hero", - note = "Through the door", - on = "enter", - when = {} - } - }, - title = "New game", - types = { - coin = { - behaviours = { - { kind = "trigger" } - }, - look = { file = "Forge/kit/coin.png", kind = "sprite" }, - vars = {} - }, - door = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, - vars = {} - }, - ground = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } - }, - hero = { - behaviours = { - { jump = 620, kind = "platformer", speed = 260 } - }, - look = { anchor = "feet", b = 240, g = 90, h = 44, kind = "box", r = 90, w = 24 } - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - spike = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, - vars = {} - } - }, - vars = { score = 0 } -} diff --git a/forge/tutorials/03-sprites.forge b/forge/tutorials/03-sprites.forge deleted file mode 100644 index dfee4ef43..000000000 --- a/forge/tutorials/03-sprites.forge +++ /dev/null @@ -1,156 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - layers = { - { gravity = 1500, kind = "world2d" } - }, - rooms = { - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "hero", type = "hero", x = 200, y = 420 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 300, y = 392 }, - { id = "coin1", type = "coin", x = 420, y = 392 }, - { id = "coin2", type = "coin", x = 560, y = 300 }, - { id = "spike", type = "spike", x = 640, y = 420 }, - { id = "door", type = "door", x = 700, y = 420 } - }, - name = "start", - tracks = {} - }, - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 400, y = 392 } - }, - name = "cave", - tracks = {} - } - }, - rules = { - { - act = { - { "run", direction = -1, entity = "hero" } - }, - note = "Run left", - on = "frame", - when = { - { "keyHeld", key = "LEFT" } - } - }, - { - act = { - { "run", direction = 1, entity = "hero" } - }, - note = "Run right", - on = "frame", - when = { - { "keyHeld", key = "RIGHT" } - } - }, - { - act = { - { "jump", entity = "hero" }, - { "playSound", file = "Forge/kit/jump.wav" } - }, - note = "Jump, with ground underfoot", - on = "frame", - when = { - { "onGround", entity = "hero" }, - { "keyPressed", key = "SPACE" } - } - }, - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - a = "coin", - act = { - { "addScore", amount = 10 }, - { "destroy", entity = "self" }, - { "playSound", file = "Forge/kit/coin.wav" } - }, - b = "hero", - note = "Take a coin", - on = "enter", - when = {} - }, - { - a = "spike", - act = { - { "moveTo", entity = "hero", x = 200, y = 380 }, - { "playSound", file = "Forge/kit/hit.wav" } - }, - b = "hero", - note = "Spikes hurt", - on = "enter", - when = {} - }, - { - a = "door", - act = { - { "goTo", entity = "hero", room = "cave", x = 60, y = 380 } - }, - b = "hero", - note = "Through the door", - on = "enter", - when = {} - }, - { - act = { - { "playMusic", file = "Forge/kit/loop.ogg" } - }, - note = "Music", - on = "roomStart", - when = {} - } - }, - title = "New game", - types = { - coin = { - behaviours = { - { kind = "trigger" }, - { kind = "spin", rate = 180 } - }, - look = { file = "Forge/kit/coin.png", kind = "sprite" }, - vars = {} - }, - door = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, - vars = {} - }, - ground = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } - }, - hero = { - behaviours = { - { jump = 620, kind = "platformer", speed = 260 }, - { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" } - }, - look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - spike = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, - vars = {} - } - }, - vars = { score = 0 } -} diff --git a/forge/tutorials/04-shmup.forge b/forge/tutorials/04-shmup.forge deleted file mode 100644 index bc48f7141..000000000 --- a/forge/tutorials/04-shmup.forge +++ /dev/null @@ -1,135 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - types = { - hive = { - behaviours = { - { every = 0.6, kind = "spawner", max = 6, spawns = "rock", total = 12 } - }, - look = { kind = "none" }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - rock = { - behaviours = { - { kind = "drift", vy = 90 }, - { kind = "health", max = 2 }, - { death = "Forge/kit/hit.wav", kind = "sound" } - }, - look = { file = "Forge/kit/rock.png", kind = "sprite" }, - vars = { health = 2 } - }, - ship = { - behaviours = { - { down = "DOWN", fire = "SPACE", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" }, - { clamp = true, kind = "mover", speed = 260 }, - { kind = "shooter", offsetY = -14, rate = 0.15, spawns = "shot" }, - { keep = true, kind = "health", max = 1 }, - { hit = "Forge/kit/hit.wav", kind = "sound" } - }, - look = { file = "Forge/kit/ship.png", kind = "sprite" }, - vars = { health = 1 } - }, - shot = { - behaviours = { - { kind = "projectile", life = 3, vy = -520 }, - { kind = "sound", spawn = "Forge/kit/shot.wav" } - }, - look = { file = "Forge/kit/shot.png", kind = "sprite" }, - vars = {} - } - }, - layers = { - { kind = "overlay" } - }, - rooms = { - { - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "ship", type = "ship", x = 360, y = 420 }, - { id = "hive", type = "hive", x = 360, y = 20 }, - { id = "hive1", type = "hive", x = 560, y = 20 } - }, - name = "start", - tracks = {} - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - a = "shot", - act = { - { "destroy", entity = "self" }, - { "damage", amount = 1, entity = "other" }, - { "addScore", amount = 10 } - }, - b = "rock", - note = "A shot lands on a rock", - on = "collision", - when = {} - }, - { - act = { - { "emit", b = 60, count = 24, entity = "self", g = 120, r = 255 }, - { "addScore", amount = 50 } - }, - type = "rock", - note = "A rock dies", - on = "death", - when = {} - }, - { - a = "rock", - act = { - { "destroy", entity = "self" }, - { "damage", amount = 1, entity = "other" } - }, - b = "ship", - note = "A rock rams the ship", - on = "collision", - when = {} - }, - { - act = { - { "addVar", amount = -1, name = "lives" }, - { "flash", b = 60, g = 60, r = 255, seconds = 0.3 }, - { "setVar", entity = "self", name = "health", value = "1" }, - { "moveTo", entity = "self", x = 360, y = 420 } - }, - type = "ship", - note = "The ship is lost", - on = "death", - when = {} - }, - { - act = { - { "gameOver" } - }, - note = "Out of lives", - on = "frame", - when = { - { "test", expr = "lives <= 0" } - } - }, - { - act = { - { "destroy", entity = "self" } - }, - each = "rock", - note = "A rock slips past the bottom", - on = "frame", - when = { - { "test", expr = "self.y > 500" } - } - } - }, - title = "Rocks", - vars = { lives = 3, score = 0 } -} diff --git a/forge/tutorials/05-qte.forge b/forge/tutorials/05-qte.forge deleted file mode 100644 index b708540fd..000000000 --- a/forge/tutorials/05-qte.forge +++ /dev/null @@ -1,140 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - types = { - prompt = { - behaviours = {}, - look = { b = 80, g = 220, kind = "text", r = 255 }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - verdict = { - behaviours = {}, - look = { b = 180, g = 255, kind = "text", r = 160 }, - vars = {} - } - }, - layers = { - { file = "Forge/kit/clip.mkv", kind = "disc" }, - { kind = "overlay" } - }, - rooms = { - { - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "prompt", type = "prompt", x = 250, y = 60 }, - { id = "verdict", type = "verdict", x = 250, y = 100 } - }, - name = "start", - tracks = {} - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - act = { - { "setText", entity = "prompt", text = "\"PRESS!\"" } - }, - note = "Window one is open", - on = "frame", - when = { - { "discBetween", from = 72, to = 120 } - } - }, - { - act = { - { "setVar", name = "hitone", value = true }, - { "addScore", amount = 250 }, - { "setText", entity = "verdict", text = "\"HIT\"" } - }, - note = "Window one answered in time", - on = "frame", - when = { - { "discBetween", from = 72, to = 120 }, - { "keyPressed", key = "SPACE" }, - { "once", tag = "one" } - } - }, - { - act = { - { "addVar", amount = -1, name = "lives" }, - { "setText", entity = "verdict", text = "\"MISS\"" }, - { "setText", entity = "prompt", text = "\"\"" } - }, - frame = 120, - note = "Window one closed unanswered", - on = "frameReached", - when = { - { "test", expr = "not hitone" } - } - }, - { - act = { - { "setText", entity = "prompt", text = "\"UP!\"" } - }, - note = "Window two is open", - on = "frame", - when = { - { "discBetween", from = 150, to = 200 } - } - }, - { - act = { - { "setVar", name = "hittwo", value = true }, - { "addScore", amount = 250 }, - { "setText", entity = "verdict", text = "\"HIT\"" } - }, - note = "Window two answered in time", - on = "frame", - when = { - { "discBetween", from = 150, to = 200 }, - { "keyPressed", key = "UP" }, - { "once", tag = "two" } - } - }, - { - act = { - { "addVar", amount = -1, name = "lives" }, - { "setText", entity = "verdict", text = "\"MISS\"" }, - { "setText", entity = "prompt", text = "\"\"" } - }, - frame = 200, - note = "Window two closed unanswered", - on = "frameReached", - when = { - { "test", expr = "not hittwo" } - } - }, - { - act = { - { "setVar", name = "hitone", value = false }, - { "setVar", name = "hittwo", value = false }, - { "setText", entity = "verdict", text = "\"\"" }, - { "discTo", frame = 1 } - }, - frame = 280, - note = "The clip runs out: round again", - on = "frameReached", - when = {} - }, - { - act = { - { "gameOver" } - }, - note = "Out of lives", - on = "frame", - when = { - { "test", expr = "lives <= 0" } - } - } - }, - title = "Moments", - vars = { lives = 3, score = 0 } -} diff --git a/forge/tutorials/06-gun.forge b/forge/tutorials/06-gun.forge deleted file mode 100644 index f7f216ae2..000000000 --- a/forge/tutorials/06-gun.forge +++ /dev/null @@ -1,115 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - types = { - gun = { - behaviours = { - { ammo = 6, kind = "gun", player = 1, reload = "offscreen" } - }, - look = { kind = "none" }, - vars = { ammo = 6 } - }, - hand = { - behaviours = { - { kind = "hitbox", track = "hand" } - }, - look = { kind = "none" }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - target = { - behaviours = { - { kind = "hitbox", track = "target" }, - { kind = "health", max = 1 } - }, - look = { kind = "none" }, - vars = { health = 1 } - } - }, - layers = { - { file = "Forge/kit/clip.mkv", kind = "disc" }, - { kind = "overlay" } - }, - rooms = { - { - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "gun", type = "gun", x = 30, y = 450 }, - { id = "target", type = "target", x = 450, y = 210 }, - { id = "hand", type = "hand", x = 600, y = 210 } - }, - name = "start", - tracks = { - { - boxes = { - { at = 150, h = 90, w = 90, x = 405, y = 165 }, - { at = 210, h = 90, w = 90, x = 405, y = 165 } - }, - key = "frame", - name = "target" - }, - { - boxes = { - { at = 220, h = 105, w = 240, x = 480, y = 157 }, - { at = 260, h = 105, w = 240, x = 480, y = 157 } - }, - key = "frame", - name = "hand" - } - } - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score .. \" ammo \" .. gun.ammo .. \" misses \" .. misses" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - act = { - { "addScore", amount = 100 }, - { "damage", amount = 1, entity = "self" }, - { "playSound", file = "Forge/kit/hit.wav" } - }, - type = "target", - note = "The target is hit", - on = "hit", - when = {} - }, - { - act = { - { "addScore", amount = -200 }, - { "flash", b = 40, g = 40, r = 255, seconds = 0.3 } - }, - type = "hand", - note = "The hand is hit", - on = "hit", - when = {} - }, - { - act = { - { "addVar", amount = 1, name = "misses" }, - { "playSound", file = "Forge/kit/shot.wav" } - }, - note = "A shot at nothing", - on = "miss", - when = { - { "test", expr = "not event.offscreen" } - } - }, - { - act = { - { "say", seconds = 1, text = "\"DRAW!\"" } - }, - frame = 150, - note = "Draw!", - on = "frameReached", - when = {} - } - }, - title = "Draw", - vars = { misses = 0, score = 0 } -} diff --git a/forge/tutorials/07-adventure.forge b/forge/tutorials/07-adventure.forge deleted file mode 100644 index 9ddbefb5b..000000000 --- a/forge/tutorials/07-adventure.forge +++ /dev/null @@ -1,244 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - dialogues = { - guard = { - nodes = { - ok = { - choices = {}, - text = "Then go ahead.", - who = "Guard" - }, - start = { - choices = { - { next = "why", text = "Why not?" }, - { text = "Fine." } - }, - text = "Nobody passes.", - who = "Guard" - }, - why = { - choices = { - { - act = { - { "setVar", name = "allowed", value = true } - }, - next = "ok", - text = "I have a key.", - when = "has(\"key\")" - }, - { text = "I see." } - }, - text = "The door is locked, and I have no key.", - who = "Guard" - } - }, - start = "start" - } - }, - types = { - backdrop = { - behaviours = {}, - look = { file = "Forge/kit/yard.png", kind = "sprite" }, - vars = {} - }, - door = { - behaviours = { - { kind = "hotspot", name = "door", walkX = 590, walkY = 330 } - }, - look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, - vars = {} - }, - guard = { - behaviours = { - { kind = "hotspot", name = "guard", walkX = 480, walkY = 360 } - }, - look = { anchor = "feet", faces = "left", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, - vars = {} - }, - hero = { - behaviours = { - { kind = "walker", speed = 160 }, - { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5" } - }, - look = { anchor = "feet", file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, - vars = {} - }, - key = { - behaviours = { - { kind = "hotspot", name = "key", walkX = 200, walkY = 400 } - }, - look = { file = "Forge/kit/key.png", kind = "sprite" }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - } - }, - layers = { - { kind = "overlay" } - }, - rooms = { - { - depthSort = true, - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "backdrop", type = "backdrop", x = 360, y = 240 }, - { id = "hero", type = "hero", x = 150, y = 440 }, - { id = "door", type = "door", x = 590, y = 300 }, - { id = "key", type = "key", x = 200, y = 380 }, - { id = "guard", type = "guard", x = 520, y = 340 } - }, - name = "yard", - scaleBy = { - { scale = 0.6, y = 300 }, - { scale = 1.0, y = 460 } - }, - tracks = {}, - walk = { - { 60, 320, 660, 320, 700, 460, 40, 460 } - } - }, - { - depthSort = true, - entities = { - { id = "backdrop", type = "backdrop", x = 360, y = 240 }, - { id = "readout", type = "readout", x = 12, y = 12 } - }, - name = "hall", - tracks = {}, - walk = { - { 40, 300, 680, 300, 700, 460, 40, 460 } - } - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "(sentence or \"\") .. \" score \" .. score" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - act = { - { "walkToPointer", entity = "hero", player = 1 } - }, - interrupt = true, - note = "A click on the floor walks there", - on = "pressed", - switch = "SWITCH_BUTTON3", - when = {} - }, - { - act = { - { "say", seconds = 1, text = "\"It is a \" .. event.target .. \".\"" } - }, - note = "Look at anything", - on = "verb", - verb = "look", - when = {} - }, - { - act = { - { "walkToHotspot", entity = "hero", target = "self" }, - { "give", item = "key" }, - { "addScore", amount = 1 }, - { "destroy", entity = "self" } - }, - note = "Take the key", - on = "verb", - target = "key", - verb = "take", - when = {} - }, - { - act = { - { "walkToHotspot", entity = "hero", target = "self" }, - { "face", entity = "hero", target = "self" }, - { "talk", dialogue = "guard" } - }, - note = "Talk to the guard", - on = "verb", - target = "guard", - verb = "talk", - when = {} - }, - { - act = { - { "say", seconds = 1, text = "\"The guard shakes his head.\"" } - }, - note = "The door, while the guard objects", - on = "verb", - target = "door", - verb = "open", - when = { - { "test", expr = "not allowed" } - } - }, - { - act = { - { "walkToHotspot", entity = "hero", target = "self" }, - { "playSound", file = "Forge/kit/door.wav" }, - { "fade", out = true, seconds = 0.5 }, - { "goTo", entity = "hero", room = "hall", x = 120, y = 420 }, - { "fade", out = false, seconds = 0.5 }, - { "addScore", amount = 1 } - }, - controls = false, - note = "The door opens", - on = "verb", - target = "door", - verb = "open", - when = { - { "test", expr = "allowed" } - } - }, - { - act = { - { "say", seconds = 1, text = "\"That does not work.\"" } - }, - note = "Anything else", - on = "verb", - when = {} - }, - { - act = { - { "setVerb", verb = "look" } - }, - key = "L", - note = "The verb keys", - on = "pressed", - when = {} - }, - { - act = { - { "setVerb", verb = "take" } - }, - key = "T", - note = "Take", - on = "pressed", - when = {} - }, - { - act = { - { "setVerb", verb = "open" } - }, - key = "O", - note = "Open", - on = "pressed", - when = {} - }, - { - act = { - { "setVerb", verb = "talk" } - }, - key = "K", - note = "Talk", - on = "pressed", - when = {} - } - }, - title = "The Yard", - vars = { allowed = false, score = 0 }, - verbs = { "walk", "look", "take", "use", "open", "talk" } -} diff --git a/forge/tutorials/08-scene.forge b/forge/tutorials/08-scene.forge deleted file mode 100644 index 4b115192a..000000000 --- a/forge/tutorials/08-scene.forge +++ /dev/null @@ -1,129 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - types = { - camera = { - behaviours = { - { kind = "camera", lag = 0.2, lookY = 0.6, mode = "follow", target = "hero", x = 0, y = 3, z = 7 } - }, - look = { kind = "none" }, - vars = {} - }, - floor = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, d = 12, g = 110, h = 0.4, kind = "mesh", r = 90, shape = "box", w = 30 }, - vars = {} - }, - hero = { - behaviours = { - { down = "DOWN", kind = "keys", left = "LEFT", right = "RIGHT", up = "UP" }, - { height = 0.9, jump = 6.5, kind = "character", radius = 0.3, speed = 4 }, - { idle = "Survey", kind = "animator", walk = "Walk" } - }, - look = { d = 0.6, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.008, w = 0.6 }, - vars = {} - }, - ledge = { - behaviours = { - { kind = "solid" } - }, - look = { b = 70, d = 4, g = 90, h = 0.4, kind = "mesh", r = 110, shape = "box", w = 4 }, - vars = {} - }, - prize = { - behaviours = { - { kind = "trigger" } - }, - look = { b = 60, g = 200, kind = "mesh", r = 255, shape = "sphere", w = 0.6 }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - } - }, - layers = { - { fov = 55, kind = "scene3d" }, - { kind = "overlay" } - }, - rooms = { - { - entities = { - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "floor", type = "floor", x = 0.0, y = -0.2, z = 0.0 }, - { id = "ledge", type = "ledge", x = 6.0, y = 1.2, z = 0.0 }, - { id = "prize", type = "prize", x = 6.0, y = 2.0, z = 0.0 }, - { id = "hero", type = "hero", x = -4.0, y = 0.5, z = 0.0 }, - { id = "camera", type = "camera", x = -4.0, y = 3.0, z = 7.0 } - }, - name = "start", - tracks = {} - } - }, - rules = { - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - act = { - { "setState", entity = "self", state = "walk" } - }, - each = "hero", - note = "Walking is a state the animator plays", - on = "frame", - when = { - { "test", expr = "self.dx != 0 or self.dy != 0" } - } - }, - { - act = { - { "setState", entity = "self", state = "idle" } - }, - each = "hero", - note = "Standing still", - on = "frame", - when = { - { "test", expr = "self.dx == 0 and self.dy == 0" } - } - }, - { - act = { - { "jump", entity = "hero" } - }, - note = "Jump, with ground underfoot", - on = "frame", - when = { - { "keyPressed", key = "SPACE" }, - { "onGround", entity = "hero" } - } - }, - { - a = "prize", - act = { - { "destroy", entity = "self" }, - { "addScore", amount = 100 }, - { "playSound", file = "Forge/kit/coin.wav" } - }, - b = "hero", - note = "The prize is taken", - on = "enter", - when = {} - }, - { - act = { - { "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 } - }, - note = "Fallen off the world", - on = "frame", - when = { - { "test", expr = "hero.y < -5" } - } - } - }, - title = "Field", - vars = { score = 0 } -} diff --git a/forge/tutorials/09-rail.forge b/forge/tutorials/09-rail.forge deleted file mode 100644 index feab26fb1..000000000 --- a/forge/tutorials/09-rail.forge +++ /dev/null @@ -1,213 +0,0 @@ --- 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 = { - { kind = "camera", mode = "rail", track = "rail" } - }, - look = { kind = "none" }, - vars = {} - }, - floor = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, d = 60, g = 75, h = 0.2, kind = "mesh", r = 70, shape = "box", w = 40 }, - vars = {} - }, - gun = { - behaviours = { - { ammo = 6, kind = "gun", player = 1, reload = "offscreen" } - }, - look = { kind = "none" }, - vars = { ammo = 6 } - }, - painting = { - behaviours = {}, - look = { b = 255, d = 0.2, g = 255, h = 16, kind = "mesh", r = 255, shape = "box", texture = "Forge/kit/yard.png", unlit = true, w = 24 }, - vars = {} - }, - pillar = { - behaviours = {}, - look = { d = 1.2, h = 8, kind = "mesh", occluder = true, shape = "box", w = 1.2 }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - zombie = { - behaviours = { - { kind = "health", linger = 2, max = 2, ragdoll = true }, - { kind = "seek", speed = 1.6, stopAt = 2.2, target = "camera" }, - { attack = "Run", idle = "Survey", kind = "animator", walk = "Walk" }, - { kind = "target" }, - { after = 1.5, kind = "timer", name = "bite", start = false } - }, - look = { d = 1.4, file = "Forge/kit/fox.glb", h = 0.9, kind = "model", scale = 0.01, w = 0.8 }, - vars = { health = 2 } - } - }, - vars = { health = 3, score = 0 } -} diff --git a/forge/tutorials/10-release.forge b/forge/tutorials/10-release.forge deleted file mode 100644 index 434886528..000000000 --- a/forge/tutorials/10-release.forge +++ /dev/null @@ -1,193 +0,0 @@ --- Written by Forge/AuthorCompile.singe. Edit by hand or in the editor; either is fine. -return { - layers = { - { gravity = 1500, kind = "world2d" }, - { kind = "bezel" } - }, - rooms = { - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "hero", type = "hero", x = 200, y = 420 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 300, y = 392 }, - { id = "coin1", type = "coin", x = 420, y = 392 }, - { id = "coin2", type = "coin", x = 560, y = 300 }, - { id = "spike", type = "spike", x = 640, y = 420 }, - { id = "door", type = "door", x = 700, y = 420 } - }, - name = "start", - tracks = {} - }, - { - entities = { - { id = "ground", type = "ground", x = 360, y = 440 }, - { id = "readout", type = "readout", x = 12, y = 12 }, - { id = "coin", type = "coin", x = 400, y = 392 } - }, - name = "cave", - tracks = {} - } - }, - rules = { - { - act = { - { "run", direction = -1, entity = "hero" } - }, - note = "Run left", - on = "frame", - when = { - { "keyHeld", key = "LEFT" } - } - }, - { - act = { - { "run", direction = 1, entity = "hero" } - }, - note = "Run right", - on = "frame", - when = { - { "keyHeld", key = "RIGHT" } - } - }, - { - act = { - { "jump", entity = "hero" }, - { "playSound", file = "Forge/kit/jump.wav" } - }, - note = "Jump, with ground underfoot", - on = "frame", - when = { - { "onGround", entity = "hero" }, - { "keyPressed", key = "SPACE" } - } - }, - { - act = { - { "setText", entity = "readout", text = "\"score \" .. score .. \" lives \" .. lives" } - }, - note = "The readout, every frame", - on = "frame" - }, - { - a = "coin", - act = { - { "addScore", amount = 10 }, - { "destroy", entity = "self" }, - { "playSound", file = "Forge/kit/coin.wav" } - }, - b = "hero", - note = "Take a coin", - on = "enter", - when = {} - }, - { - a = "spike", - act = { - { "moveTo", entity = "hero", x = 200, y = 380 }, - { "playSound", file = "Forge/kit/hit.wav" } - }, - b = "hero", - note = "Spikes hurt", - on = "enter", - when = {} - }, - { - a = "door", - act = { - { "goTo", entity = "hero", room = "cave", x = 60, y = 380 }, - { "addVar", amount = -1, name = "lives" } - }, - b = "hero", - note = "Through the door", - on = "enter", - when = {} - }, - { - act = { - { "playMusic", file = "Forge/kit/loop.ogg" } - }, - note = "Music", - on = "roomStart", - when = {} - }, - { - act = { - { "submitScore", board = "default" }, - { "say", seconds = 2, text = "\"GAME OVER\"" }, - { "gameOver" } - }, - note = "Out of lives", - on = "frame", - when = { - { "test", expr = "lives <= 0" }, - { "once", scope = "game", tag = "over" } - } - }, - { - act = { - { "credit" } - }, - note = "A coin", - on = "pressed", - switch = "SWITCH_COIN1", - when = {} - }, - { - act = { - { "addVar", amount = -1, name = "credits" }, - { "setVar", name = "lives", value = "3" }, - { "restart" } - }, - note = "Continue", - on = "pressed", - switch = "SWITCH_START1", - when = { - { "test", expr = "credits > 0 and lives <= 0" } - } - } - }, - title = "Coin Run", - types = { - coin = { - behaviours = { - { kind = "trigger" }, - { kind = "spin", rate = 180 } - }, - look = { file = "Forge/kit/coin.png", kind = "sprite" }, - vars = {} - }, - door = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/door.png", kind = "sprite" }, - vars = {} - }, - ground = { - behaviours = { - { kind = "solid" } - }, - look = { b = 90, g = 70, h = 40, kind = "box", r = 60, w = 720 } - }, - hero = { - behaviours = { - { jump = 620, kind = "platformer", speed = 260 }, - { fps = 10, kind = "frames", states = "idle=1-1, walk=2-5, jump=6-6, fall=7-7" } - }, - look = { file = "Forge/kit/hero.png", frames = 8, kind = "sprite" }, - vars = {} - }, - readout = { - look = { b = 245, g = 235, kind = "text", r = 235, text = "score 0" } - }, - spike = { - behaviours = { - { kind = "trigger" } - }, - look = { anchor = "feet", file = "Forge/kit/spike.png", kind = "sprite" }, - vars = {} - } - }, - vars = { credits = 0, lives = 3, score = 0 } -} diff --git a/media/Kangaroo Punch Logo with Text on Black.svgz b/media/Kangaroo Punch Logo with Text on Black.svgz new file mode 100644 index 0000000000000000000000000000000000000000..e21916409dbcc1efbd17a941529ad16c111d39a1 GIT binary patch literal 189708 zcmV)9K*hfwiwFP!000000JObZuOvx!rT5NX5h}irP@GeCzcjizfH)Y;KolTIGt_f3 zGrOw9tjb1Jc6F1#e)hMPd7P7(rGXx#o?+!N4-XG_bF+P2d+p!Km5br z{QiG>db<7N&+p%SdjIbBmmfZTfBP>#{p&Ap-~8$Q?eD(-^yyE(`}W(v{N*ptKb(Wl zAAkPt+rPg(J^jPq{LSxw`SrWMx!rC*y!*p9@nmuho>Gth|M&XRUw(M^>H9x?GkSimsTCdj{{0W%egEkX-^`Yu$7rSN z=r8e%e~SP6-Mf!(e|qy{Jlg;I=BMx8{QU9b?SKFCPjA1!{nN+qK0e1C{`=>5^Zu`Y z`uOvwr$7Gi;l021?SK0vZt&Y*-hTY_>D&MK{+ECK^zl#MetxZQf9ukJ`sushyz*b) z{pp8z*7n@nN~`|nIsf(>=luG^`@j4zAOHG?Z_+K@Mo!Ot&#`Y~_ve~Z@k#NvfBNNj z*X923&HvNO;?aKj)0?;NfBE)0{_6YJ+㧭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ℜ7Eug~!>?Bo@rii=^X+Bok94bT@6 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%0AMQIyb+(gT#vMZk48xXh8rJoJ& zW!lO>(0r

d+0tr`ElI7WYT{lMAi2pLyKsBEqj*na`z97wMk5#OeX2voQBw3X5Dq*5`nT& zSClb0tgwXYVVfd_pg<>uPVi%))_+N#&YSElis!P$(bVW}xM0ZrW2kR9@UX%|FI=`< zHWEOWvL0&j;D)*}H(u54}Tv8S!aOXEYFYctb>XoUP^%&HLJ7s4zMI1v+(i<5m z_+8tycah&(_ZJEHl-exO-D}jhTy0obagmiYmk5s~o7K~xl+`40Z{ueHZ>PW6wC3}j zXO7ggk{tfnhVCbPpPiy*bKKt#M=J~ygxM%v#;Znw|Z zBQfu1&)*9k8vq-$nyosbyL#1<}w z{?{Wvwc&*ztD%L!r@;hi1kfI67hh_Figx!u?Ud&itX%!wympP8USq z2CLl>zaB+c;``i(#QcPvPoRid(=$-6Q<*m)>$T+3s>7KB)UrFS+g5CW*ff(!J`1Kwj!ExxEuXa>@FEse^RvB{Bb}U{4Gy6hM}n*reXBW9 z>59=j7-^fl0pT@A`fdW8vs@1gko^1G@*CX6 zNKn0}G!}R!3P#m^cGRsILhE-&F-rW#_vbhH2QOXkW#MDBz2X?%bI*ocim0|$O8On* zUNLlXR#sNmW+#%dBi^3NW=UoIyO$Lz#1PViS<_TytykS@W!+2%=`?SyWnIP$N%`|$ zlHT$&?tQRekc18T6mZGuF?x1KPs_u8oJ?D#yN=yU$3#_(Gn83!fPJ{cgo-88Z*=dB6AF&R-AUje#JJ;smi)i9}y}aNs;jAYcyn_alN=ZkA9kB2pPPzWwmlquEH(ny}X!e2@}>` zSw0{gNGXynR%DBrgnES{(#Y^L@acf<(m-DxfI-qaKf08IqU&ygVQU3fdRnn>ZLGgX zq7)VO_~dL=6aZBG3gD(I-EhX#4GREMds3)os5g>Uo$gRJw)?3txo>W*FFkx9u$yYY zL^ta<8{7mTAse6jGwjL{y3iEify19=?62z4MYv3Rh)mONI?gISUArK0(bacbYkw&Dr&H)ue)W>G$ z5fM^V!|nGyD#XBZ)m@?zjYL-o3fhMKHflPQz%MJ%#+05zp1-kz6eRFieRpCGj74S~ zA|`QHsI7{Jk8HW>GzVNTL}W}wdsnMPn>a?uH`2Ae5#7Q2@kw?E;#0Vw!{034Gc5w` zS5Z;e>eLJFBF3qvH3AZ-AW-eU5$U~?w0r7|HdY%=cYNRp~qt`;ON70UC@D&{Ngzn7m> zBUo<)e+8*Bz~4%2;J%%{Et*bu$Cxn* z*B#Y#3Bg5*R!hiTbBU7n`h+$d5OI$?UhS?5edYl(Pi2LIY}Dq>h7hBQ1m*OoDr2z{ zYOC0RDQBl!Av}h5mojY+cIW%H`~YC*09MoyhdtCRb)_VC<_ePvJLs^4mHaggdKN!5 z@$}xcrf`_}3sea{yr{o>iRYbV<&bMU1)@;gl8d~t4I`Lh07E2R)nGr@bXf=svfUi! zWi%GzjG?S`jP8@@ie}=Hq_Nrr>SKVy-p03SgVXfJZz3zG(=$C8BDmziRuZl!bvFNv(POi85l6;D|bi7`-lhA&IU+LnC1q8boJ z!2~+6@#&|@xx;)(%SYB!&rwOD*bv$!ZMzjNuNg4?vzWeVM!yr8oSG0WE&N9p&!)DW zkhyHHjK!MHt66J$sl5wIJG>05jv;_r;t#fFkmB64v#qMxubw(OhJFSOOxi!+#=F&x zp<7r5u2%Ma5PIsj4SxC*6pcFMMtCqNR18Vn()>3ie@Hllvf2ndo#K&USGKviD{}{5 zVz39i{^l8>YX@dkOsekZiK;Imy!fqizRb32GYBh&{9Cm>RFtHj?5)M6CwX$L7!umHk4@<-_38vsu+colQX%Sg2r)iZFs(W*pE z5?@tmQC3?Xc~oD+58NF(vt?TfVxXz7vG2NjCWPn;-w-l_`!*bM50dLnyl`;S#0Pst zMR>mgdNCjl1K>(BI?iaThTWDW5kBRwoT5I6ehwrl&{zxHcPHK17EtwJv2kC@1>zIq z*blJT@ZgFk^%>k%PyltCmL|olL%aA+Zs9cQXTr&bIs!ely`ajjN7@OspVTth>3fH| zO+Sx{k)<${6gKxz?%Kz8X=lHVCyTy{q4kqBX^#?r4F_})C7!J&*iL;mebJ~H(F`aI zYh|Nvg!vV1+mHj*&k2MTgWoOTkZ7jx)XpgaO72#NM2N9z36^uE@2lCL#dM5N@$9fU>O+Ol3V1V zpCf7wpbm;cM~uml46n8fP*l6^2tH_E1iHqPdMx)zHpOIg%4*)KF@eXnFdFw9gCjT! z(}hd|Q_pzhxY`t?SYNZ)_Ri>oij1f<9m64AM}YT+2vwf&im9}*BHF%<46_Yg;AZ9S zen+HU1(?M~)U;#0I$8Lrq#tMYNCU}#cVJgPy#LG&X>&t(SyM;fOz5vCWE;DfkvAQA zh?hjEeFF#3xWMkio*Z+lA8Q1WU{vZi>1X>?dI>|0JpY1r3y`1G*81imh^q`j#KU6q9r z94uBg1$7qR>44oIDwK}Yl-^JbC8W5C8#0qecuKYCY@j7LjR_ek(yhSSvZyz|d}miE zK)+PPQa5>H&m!cpiXLKVg@Cl?5PId*JfG=AYTZ!rBpPe7e6sHcp;VdwMcEdQaq%^} zWlYlGLU0H)I1hyI87Qul;!erZ#rdPnrP%pw4;&81GWJB3JKtk9h2E(&E6$omYZSJ} zUfhjuwb@y%OmjzT*g&Kt(hvlO3X?f0^yBin8{L*PVawY@fq6M@+9emq-LCvHiybirz1*xE~t-La4{d;uJ8}n`s+XVnq@Ald}$Ibg~9TtP8 zY3jR$?u|NTD!Br~*%H+3RzEj(P^}e|lf|wcuqo9oH5L6~v#}C(f%K{~NYS-;%}LEd zKss6mkAZXZG(#8yV2y57;1aInGQ!|m>w!S3Hoq0ImA>- z*R{H@TOq_M?*Z-*w2PY|9AR}n|Ne%BVI`IJTY%nBlMiBPn6xs{pur{vdxn847kCeU ziH#@^!5*ZBOk0%T0Rx1scfY!N(onCFTX zoyiSH5f-8m&2;6GjtD~4^4oh#WpPc2lrZG>&65tbi9GJ_W z2>9Z`HGdA51JBl6KtKEMaiFTMDDynl)mxIZN``xSnrwfXT6`Z_^+boZq@jz1@{lA8 zlVX$+(jw&&xgprkb-xaZ&ceod5B1*Gyrvl=J48hYH=kWNrjLKlj5s8 zXH~eCV;7JV1Td-NI(MFs65f#qG7V3)ci}CN%C!c(u#5L9t0vw?R%vTKOUMGhm3XEo z)_$VV(FFHYwdD=Uub!m}?5ci_v|`L(bQ=Q~64duq|G{E+tDOad`wT17S9)8kIG43R z(@=(==;1O>cEHdtMpFAYEx%XP;_*VCNY(mN_n9g*6#eYfBbj}~hOqGw(iH_WQNXoW zYadP(O96Q8>v)p8za>NjwdlBOTSvOMsDS)BLeyJP3tRSn{&&_|yS2DB9C>ox-f!g0nu-{&PWDo|5D*;+S&`qXN#hm-0B4PH1n zcxer)v1(JMg8I(yVGPwWg!OT7c(b>T5DY2J)@4_ZYN7rrs*||ySSN_GEq1J;wu&V< z)dls`Q^gjc9rczhU93=%irQ8cx~9mjSPQC_wEF!Cr|fSp0E_EMG7iv;_N|TKa-{jf^VgfOsIB zu?bVF%oRqT*wjx%=%okEiPa$7(TzNDTCkAsnqG${WD?QW z$>l>`*)I2M6_L*LC_J)DXtiJX_Q?7d48;LH)wK-Uo%fE_)D7-+Cx@X)MVgcBWC(rq8kHyZ)H3;F7;gl1!l^$G@UXDSg4#{%_fRaP1~p=Z zb?aKA5^;YTVqY7e!Tn^-s<9D-+y!yDMm^FYJPzGDO={g~Ra&nCqF^g%F4SqDVqMh) z5zNQhLl)8EbZigOusdGJEAyq6($UlnS7`z5og<(p@V>FulZzDhHZ6IAW&LOwsY)tP zIFRG~zeKaUQ({N0?%N<7fbQAdm$4#ACpug854F9YHzqSn6*Lt>}!v8tQn}a zS`;eGYpe%Cg4*BPF)lNiJ}7D|D$c9J6D6YP)}8~DPOhrKe8 zx!5=MF8PfYKo%KN6c9Hz@7*-YDa7DdVI}U<&U1j%uf$xO!9MKFSyCmq7PLACx{pBj z1vH!3@oC4>^Hs%c>sjNeVod+5QI=;IiRNv#&eSJ*!vZo@j}D(JHk?uYF%THNH;C5C&jxCE@YFaK6@x=Q=!}e%{6kHK$_a;d)GWG(K(DWASN;NgTt36>_mSR8JfT z(@1jx@R~T5dQWvKI8zm1R!*afX63o>5pJyzYfd$QdL*G#&KO_p26YMB_(q0MK)7tZ zk)`bUQhU{>DPh+4CLbGMZBvTR+5yKXLbLHz?@AvlB*s8)8fQyvB=nsX9;+2ciYN`q z9%>xf-YP9jWTQ)T7VzA$L=9F=sUvd37^EnVYnFxY13OVA00qUE1EFiu%=W7Nq0w8` zWSL^1z@?hZ+8YHbge2GF>POwej5ds$^0c3L zyQ-)SyXGx_F;sJVN^)*mp~A~drxR*>68VM2X(&!t^%ew-18XF`6`EG7rJ3|lgoHs? zm;X3)-g#bseFs#YB5i+%+aQjF)9u_@trWh`&8H$uD&!DCh4O3&wmW^`rc>;@3ekX( z<_zW;2i+RMozByn?3h21!)Uuj?sC(VmBO^|7NG;`P$SGWbes}fbAiPA>n=mvUZ#3k3Oy}d4{^f=TCV?ljr6UPdg zL5gpAb=C1wL;R==29#sP$}&ZjW`n-%$g)JYhEbkyIM1CfzHl^t%Yg`_V!M)e8FD|3 z9Omi3%)pp}$oPNo-0C^G&gs@N$KLO>c&WRSfS&^=Bwm z!!cncXUHzF@Y1HikZ$(rEtf+$4 zNNH2aE~16)(^UESPHLphsqQ^V7qywDQ>r@4-6erj_b%J8=>V8T1-W-T15u4E#tsg9 zk>5qnHl@cV4vDX)%uOy1c=t~ZL>Y7gq}fK2mgd3jd88ckp>&jT%-f(44^@0V{Q6o| zN2U8u14%J5=9nmc+RROrbd$x6M8CK1ObYA1f$y@js_D*+b1t&JyFm!>hEfe^ms+*d zgT?-)y47^EFgOxtVJwL!?Y*#43Th=w-XbEsh!RI85G^I6Bng@}WgT3GyJ5mLPZ~uk z=id`O%w}s!1O#5T9iEI6kcv)wH1#TG$Gyko&M2ObtD-)D=bW~r{diC6JnB|QQTaJQ zJ0j>QRuc{HB7LwW*cNU8VH}^y8_65 z{1KuC>p*FBOOV{K^LDp@sdD3|2Pn{wbOYQb*JjPGxk|>}gFv)Wo^l3JS^_0(C81tz zb4kD9!l;GPwLo>RNw%a};aIRpM-h+Ou+pesvz5p^(xgJiAnH}k5>f6Ik0uzpit*C= zQD>}r11?sVUEZlueEV*98R3pJ|Iv>@-*)Y7i4smtu@EJEjtbI6@kyE~?vimbCB%Xb zje~fKm#=s}K3^=J$8U!_Z8tj}$)#Y}II+5ma-$W;>nlBmu!yuO%niV3okVE!QUT27 z@88CQO*-JdNLUv2F&~&d(9s3E3!j-NWI;Xo zHI$Qr)dSzSihtlXap$ zuEf|8{Mbq*9m(5AA<{9cJgWhvCH-D{(3MEnX)e{1k=AiU9)eDY!()F7t!9m#Y1N!b z3rj<{1 zEq(F8JgPW}@Dc~w*8v!Qcl%Atse-Ryw;9xpklf{(#EaV2xAU=>k4yT7Xie7`&$D`j z+DGGMMvixmRgW)1^cZ^aZ&R=9dVdNhr$-P)I6}Mo{t?7h&%?^cr(E3=3HDiZSx`gR z8Dy4osfqjG`nFKlC^u4;s`5y}EDwwNt#GCc>bOAq5;PbctV^TP*nyW7vm5A)0r%13 zKL;pE(Ky02&Mo&8lvJx}K4%UK{9DxxNpiX}u3gnyOgoG0cg$L- zgRh|Ae9wbCMn6&OMpUIU|6EvZ^3+nZOz&DT=Y>{7*A!_|XfN(K+5Tw@E6e>NgnT!c zmKx=_F-+tL8ox!5{OrBa-o>(8@G8Zc$eycNZUC~RWpB&#i zb7Q?=9|l?)lXlf{tFuc03@DaJj>@zIMZ7x{Qst+xjNpIZ&_!)@h3?>d1Sz0~Cdx2K zmb7;fyAYj6o&E0*QD9$bV!wV5RWCGHi767fJAggen82i~tl7eH7-XFXLZ<>2@OQ22 z@wkhFIXG!T)JE1Sk$lJ&q?>c;tSR{|J`1J@P`1r`;8O0P)!+CNcM0F(kH;I1U5 zJrB6c2`(am8hHg5nK`vA0)`ul>_@l0E1Y!rU=sD+v`3l*Xl)_@W*STAaA&nswitY zK9$q4NJfNF)Sik!2&&G@0EEgWv#IV=(g6ypyQNxMRdT+@87#r9s3?N>(@ewgLg7O5 zeo*l?66JE(-AS3Gw3^f8YyyEmDM(Oo8tkpOd@dbS?PTZ(PdfCh;Fa>Uz~_~Hdvr83Eq;}0J2TBRcoJs zcn~UOrL=J~p+Q<7si(nSJp?@!3|OTCOQ|h_kejonorcOi%Sf;)twZJ12K|jeAktQ< zrxlF}?Z}ghYECq?R5k7vQ@E?g#1Nu0!HtfNTF4c{3d zR2RaN)PP-{>9k^{t<{3+4NW%G{SPP}fPgAq0S}0=EqF6TW!JBBbFxXhS(VY5N!dK^ z_w9E(9t34EU@K}osK2+zvB!yWTs8F(&1g0PQtXiCA6lbjPZTMmNpcCziok{PBpDGMS5z}o@o&-R4)=_sN=|sG|Vam7kCK^3C6Hr*IY!w?V^JF zbm1q{pVMbhqEuf%4+?N#}Ed5pD_9q^vfvjvKk%$(Gbb&Xbw9d@LXyyAO(J zzZ#nQ2(bn=mK@wRK1VC9wwYAvZ^_@BwK)P6X+>J*-N1w%BDZQAeydAcOTI#sXaZTo@oH>cq zX@GT|wyK+{yT|LBS^1BFYn6c#q;$i9b4UovbjK%t& zsykM9-~wwSa;A~>r?6S!=0yrUjl!1Z%lC1`SWA8R$IYpl8BP#t@bEOZf{HJJo&@*pL|WN#V-48{1f{4E7;SlsUzCY5xh5#PxY}2$Rl> zP@oEwYk$cK0$Q~qR8|{G8IS7URNL7r*2q?Kr%ZbMt*C);x?CcPnsBqn5)|y1%Cf_a zqOB8d#J37F+ZCgkbw%}(@qQ9NACpw;_>=Eeo4Bl4mW*5}%D5>7iQ?nZ2uwO+aW3&1 z{z|8Ii-6MFTaWy+NnJCh=Sv7;#phHD@NUn$4x<7Fp*3N<3*cH{P{1?_CS6}^oHY5YI%w$y`#ydorE77sfd({O&Jv5&3Sj4vuauW_2gn|u z>8)GJtBS0gf0xwGm2j;|-k-)LjP@z%F(@63mzoDtg;F&-L4mGtRZq4N@!i9l~{q~{1Ah+CzwLIZtg|JFSxSvvb=UNj*`Q<`A5noqi0i3bb87jsDhD$ z31PfIAz|xPkP4}aER-cKK>-vWNbOF2UtRRJq(5+QWLM?zh`mqgUJ3XHth>UnfI)v! zEDV>nZ&L z1v6p1C$W5?1f=UOvnX?Lwu8$GpChFyMa~q`;tZ;~B3RuIuv6R-f%eXxRC7{(#dv*< z1;r5~PFH>*bcC&Uj#VMYfLXV2l{Tb?Er)@s`FkC&zo6Xbd|W+5lRNFp)f%cq<*rvX zVjHwhcXIemEymEfgNmD8oEQ{GF$5M4!Qp^P5`*SJ+-QoT3s{czc|<3H=67H2@j5mS!!!6FLq1oPoRTN zdHOuU3gsid~Q9cawoImew%q&m>k zw7g4~hNfbnVg~Tub{n^Kq3Tsef~-h;nQaMy1`bF|5)O2;xh95?$azeBP!|$$ggsRK zQ)+qEhv{=3y!mW{BDRL^&@#vpCTG?H8CVCtpNs<=uC?!#({`i0muLmt7gVvTU1dPh z9Nnr?jiWeOK!iw+)@VLpc!Dk;1HF{_ajT!6F|C@IwqJLvlp=#}o<05=Ylc~I;%17p zN0$0Zs18iH$1QhOPMKq== zR?`RWpUAJT#H*V5Jn@l*avVxcZt|3+h#ux9PyJKX@k8K!afbaU92i8=w2&9;EQ z$JG=!$zSY@I_%A%p~xFWvoWI9B%pGe-Xuj*U)$%h-dz6eSf)mu{!Q^l@IR4iC_M_q zVV#cvs(!>Q(AL|a0!&4o3&RW5h03DI8L9$Rs?EruEivpGl|%Jrq!}lf@B#PGjhsj)Vh!(H=pu8q1x_%37 zmeq+2peirQfBEq4HLSFYV%wlx9P*DrxXY$_QZPmcPO|J;B@R4|UO>wgQFJDiYR$ch zLW`UX*(5bot0@51y_w4yrjxm2qZ0fBG1sDO2gPA2KrQ{<7Jub9ETTp|)wX39kkZ&t z_rYbfIncg&Lrwf02;egLM$lwbi4^Q$_f$<5)7MqT%r4#xBJ6m+X;(hJzixc5U)h!o ztnT_{h3x(4_z=b*6|^a?-9;zdrKc@d(A!+&t2|BV`kr>g1L;Q51Rd^%0@7+ObMSa1 zT8lzaYtOOQKjupd7q+#Lj|M?wbJfW1vH6~QpzqL?eNNFTNdOsPEcAr6E84IK*?oU_-Eu#SB%j`}0LO5nZZJ5OYDShjj2$v#G1Mi12(E5(2r#ZDZ?Bj|a>ZXJ?Q0(i1;H*zT%xECHnxZ}+i zA-vyD94^||tcWz&2q<|)!WK%pRxpf2fo7ZOV-{)X^nB&5mcs#Q5YqK{rL>VXyy zr`(0Mo0QY2R6J5MdnO%^0?^uq+^p@w(cyNdXb%P!83?Qi)F*ZsVIG9PwH-P+Zulom zD)&ky!0S>6h7#25W6~rJ<~qns`^JN8rg)&Tzae(C82LNz%z-jZHy?DZ?inAE_z@7v3r(ih4Q)J<>8DgN2P~!g z{7z7IlLh*4Hw2Jkq#8ish5n{AQ()*RISRT|M}l*@WfjbWkb=0vDk+gG zSltTk0lO<9)g3}W-GSTQjdbYQ&=s`M&caq6f^N|4Q`p!b(gojH5T|M_sfxy}^xKs$ zPxi!y4~WNVd8pG`?drUK4pA`O^JuxhDM54Jk91C5OZ92Q`WHA!6$-%d#dgHIxx+D4 z^HQYNk$sm$JpVq#7lVn+0m{9{_!vbnG6WOZzWLpjKunUD7sSxEhjf?_h!7DA;o*B{ zE$cMVaN0EuU1iONfYN{t%5<(~W3W880u~U1cI~Rgwt2g_PCXA0N=-V)xnk2(9RQOeRjD6;J|gS>ZS^TJ+CVvsjyrMBK= z2+Xn`+r{pH8KCJ`a;2Gxu!|$>*9Wb)^nQ?V`ohA{e#uJpF>A468RP3h;9F|4PyxMrS8LmR_sp!^(b6SEE3%>{lTA@z1AlC?Ic)jYGYPz3c_v(DzYgp? zfNxc^6?e#F;nxsh8)-_Hd?+{rT)~Y1Lu+2?q}dKBWO0{2|12A(*H8u9>A+@#NfVSC zN|xk?kUrWbf+*oaX1pLJH{eL0f}6b{4XNf%3M(DYntbNKst*SZbF(*pOs=28#LM1-4Bo_7$uM!;Yr@wcQ`UV+KaZsH9XS=BlQ(h|jRxF%-hJ4K?A z!|vfEtCWEntoheN-s@0qQF!=cXjc5XKi_fm;>zAsU>;~Q1B$d{@L6NA=ul2kv2LtH zr8g~AYgNI#OyjlUDL9b(=cMgJ2SOcvwDphm0jg=YJMd~%vrq~Q5}n44r2-)WU^5|l zr&!B20?n0tIm(&oFuqY8tdOZx5SbhZe%z5@AE0XYgsSV9yLla#IyR+~A9hG*)En8b zcNdZv76zv4sc8IkR~$hUATmus$jrzM z6^WLI{WXN~+()grVX+UH{y?D=8P%5YWn`zaTW`%P~Tc#v9Q+lf7BJP3{Lml@&W+8srOMsUke# znIW32bSYH#RDTO8jfqUW95DhwiAphMdQ}6P)>T)DqyM$YuXE{D;I6 zu5SKy@I-iYemqoeMVCEQSVyawy{W=xEJxD``aq)3dX#IXA7x3!K#n{@q_)Nh%QG3mV zbXQxKFUf>rJ!%y~3B|Z&Hc?0W6rsQ?=R(3mlE%CQP0@Ou3WLiikF{PYsX&FiCj3Ra ztAH7!V!YGac|eE8;*@iMpz6<|o@6n-kc>LRokMM^lzerQN^|kY(8AW6FkS4B9Ij`m zv~#dh93gpr4Mk>g=#1Dr!^k06&BL6Z%I_#^TI5Fj)Hn3a!1R*UKxYi!G(a zZlL%Y&s7oIpti?ptB1j`nw32w^*MKV$FKX*3d)=E^-zYeh6w)}p!8E}Paen(l@*!n z$x^jNFd_b=0AKH&Z!xBID4SPXCCDCBaM*$X=+M5!Xrc~aQJU3`M;;)=w}+^@s{Oa0 zL+p~J1(Au+qACi)s_Ob!hxr+7W1M5I%`|wR={J69UiX|}Ng5A#PKq=t;Hf&YYC1vR z(2Drtq=2+o1Qoi;&!H8~DTV0PUq6TZtI}yTk6M59qdUNJYWms_MdB5-;4F7-T2DRl z<&|SDz{<&U>W%WuS-*$>P~vwct*_261EtcQ;;Ori3-wHjVQKn_(uVVYxp;^fxTMXojhI5O=sk>$UY&xmZi3yrRa>Gc74pmadbnhLt+zNSNC87!&fQd7>ax%7n zR3{zK-1a=l1wvwz)|x{tm6CN*;YKwI%W@xH9`$FmEj$fI{BGs%i9I71?>3p2IvKQ+ zk#~ZmCsYD}%pYn&&_!3*=-rjIMRj_-M#biI#Rc}w&bIp3Xvh7?@}e|1DvR$JuGr#6 zJNAtgXf^b(_Qy=*#DZUg{dVQ4mic72!Z~ArbXX6ziB%J9?z@${5fqtJuLk-p!a*0x zLFH)A3{HZ@bYi&SQl(_SJ`rX?`%+gxlE6u22PrjG?s7Ih!HUW0n)M*qu(48Ls@8?V zUOUJ9>?VOWA)Jd%&}H3K9<*o=E4X6~o^`g$ErTLZRa`SVdg`uzVL@h9o zZ1eg!pUT)+{3R_Pc1xf?K*ux-RH}riv{{l7LG$p{9az*Fd8rN3HsQJ(456Z$*D}$)HxrEuqv|7dt5eFzHhKUiyC3<)V3hjJB)Y1ybZvZY^`lEs1Ill@{VIPQsaT9n}2KvT9S9RzSbA zpV!p==tz^I9Sl<}V_-8YC3fVySu6yjv*qH7fyTB_rKl6GEC$ML(tbE5grFqZAgoZ7 zYCsm$)r^tV#aXO^$MxNm9yFocHQT5;Fz#+{nXI5$>0$G*IFlL(DU58_28GGZr+0ovXE@);0+$(iU&RdUgV3Xa-jjxVg95K5f=`?>%j5 z-Y|!`1tSPZCA08mC!BzY-_VE_@&oH3Eh{4-D7KwlefqMgd-|0A1E&#_6xF*|?g9n` z`ja$LIC`11lm7&}nl zQT`B9#0_(f3Scs8+q*1B8LFN>2h?*bw8|UX()74LV~B#`I<1>u_xrshYEeEgBlxam z-PT9*Dc0Mw_s(IbONc6vY&>&-#NOzLu;RaIVO0^+aK3_li5CowHF0 z3WH9}Jo!etaUCGuwMyupMJJW(cpn_p0XmfsaZtNF&JJkQ_lFjaooF+GO@W`HM@`LG zxb-dyE4J%B*lZdYV6Hj-Z(94&B$4cZ^$;=YwdvF_FMZqnMl4m$pLxE%fa2F_%c)L} z^zJ^%cax~H0svvxEmF}>y6LsEO_5Voo;KN(Mxpk+jm9rcT&pY{ynzLp_&*=Mt7J1l zd6NUyM>~`Of!1KsQ{8=!l$g~kTK>rV>cj4WVhXh)AQDMfgW%g}Cn&62oTP@Zngf$% zDLfHcy1a{ZmD1la~rk6;Wc)cQ6V2#>hsWC}aX}4vA*!EBp z7!D4YfMu3`O2%Ono6j*xB^;BUc|cUAwVfMEkhn`DULsm+BhSB6ZvgESj8yf75htNU zYr=gP=6LFtg-vV6XjbbAF=^0z!hKgaO{2QMd|8^vaSa5Nt4px=wXcWicuSigLb%HT zRe%>c?<^1>a3k^C_d&e9Wbj55gPs$n zP*K@(57KIW{S0n`;bzuNh)@3#Gwj78wA)`yj+ppAHa>0OUN&CBdWF^z(7){uJ5LEH z1wV>mebbd+%{peXpB%1j8JxGayG3Or5&;oLVYl6T-g5wAtEls`|Mg=T!q6Qa2JEcr z`e+BoufTeyqxX=Qxl+7iHtar+H!d1WqNiHT_r^8F%66^V*C7BSZaZYFPByUw8`Y(2 ztvXICFmlx@)(gCP39ggz3?w6a`!orrL^UGNE=$JEH0+-(D_e&W#4ZsXDaVp`S!#X~KPAr2cx1qO>AOr?F(DQ0#T zM$aS{EV61a63iu$n@L%?W~*HK;arTHYBAL!+?iCEIIV646;pq`;|PZL8aJR_&H2<# zByYf`UoRYm%mePeAqeSR8vpCW+pz~5D^&kO6Pl`wkluxx9vi^v2M~hyu3%0Om5SB=&)!By7 zMlD&ntj=0bFGimCI3Q*fMWc~VMm7z27*4(J?t!4P_rGusXEQA4x)IQZ-a5FWfZaq6; zenEbt4uBxI*-pMdd5J(GKEdtq?{+RhA7rSrgO)d({RN$^T8*`$^Sha>)xG59W302C zg{n6ZW|8+O^Y?+_rAyQS`Bd-S6lQdT%Wlk?aXQJ`oOl`Vv1;jpPLX-0 zn}I@zO1G=wk4{CqMWH7^Yjre$vBQ$iGB8~xBvRO@R5HBM>dBL0gwip=|L@mdglF;{ zwQ=tVWfEB<;#dPLH=Xq=tx*qe_NQvky{(qkLb6u%dvxIMP2|n#{)Luzj`onOeoa$m z>wwsOx77*1eYKS8xe-51Dcz8E8EGbXmWDvn+0qGWbPe>T9oDk|svuoezM$wpt5oBe zWA^WXNr}m9-SU02Y3tuolipjYKtmF}AquOS;!?b_msDKRd|W>8H>r1ite{f4)5$Xog3D8?tu#5!a|y6vB*1brneC*^r`vO z$~&0=G`+ii8EjE{w}kw$dVf*-gYv*gdv6Ei=+q_fU5S)PHk4USr*27Z&b1;$Jx#29RqBm=X3*$SB}pz)%*Kmr1Xl; z21x8xF<#h60;vCknL6R0W&7kDl$vv}97XH9=$uN?sEY{#)ZY65cEMeD*TUm4&ctS%;7(cw_gYHSAOd5hUPS+P_niWIz(KV<@6D`ZS;lvBwjZ0LwTGn;SA_>G z5t3sj{4uAxsHQ+C**L$xKvLb0|26stz4gBQC*8eJ8!B?OP!#pNFm;cpy0+SZ@1uT zlk%MM%XY9Lo06AMTToSIGWVco09ZhTv{}sFr|t~=zf}Av7X5F7rZzk`mAx2CGYvV- z6Oda0QFbhH1ngKXZ8Tt~6zYgKggx-1^Z@vkR4x@h_#QpZOV0D&JGLR%M>;Ky5}HNI z?j4C0);z^_!zv75!q&mf9&PLt9&E(aL@^UxuG+jSb@z<9m6VV6GSaOZMjXub0#mhy zhMZmRH@#RbWfNegg?WeXt8YWJXM4aMKUj+0QGG^Q3}(U6^bz0y!cwdfAx4i3Tj&oW<5Km@UXl`+`T%0*YD_X)O= zrEmG9Cc)@1qE%u0Vmv;2Vw~$R23RtI@H8?8rs`3N_ux7g3Sc2ZzM-htB`ZO#M){g9 z1(AVp_hhg;ySv=2AglCA8G0)@WcXD9PkDHN}vz{ z&$=b5Fk@sIi!a!Uk=~jOg^YmpJEUPkfy9*ndnds01>ZA93L7D_wGiPNh{UqnAmpIR zV(UiIYTFFK8q)gAq!Hy&5R|tEalwH^Lk^&hZlFN&fGQ8C|3`_ zO=KLlO45M^c*#=*e^&dRRYH-q*4jEOE{Pp<&mtG7?GVlnN5z)lbg^*6gC4YCPs8Ht z&H@a}CV_cGf-T$(L>=A>*^Pl0kiWBDV9} zR6h@7-D$g|G%M6lF#5}?r1gR0tG7yRMXY7h2W!gycGqK62-=YiOS__X1HH;xHrQL@ z_Oj{^(RsA&7s`P*96M`&zQ8ccGN8W(K?TC(UhN;xk@M{cHoAhJBD>K3hXa+wnELp> zp{447r@Wtm_u|CLvq3;R`M`}*mpN+;@DblfiWr3QeT>7$bL8uQe7|=m0-AmP91uCdRWf6akDdtHTO`%-;8q?Cbn?9%e#%1RjuV+jmQ+4`vN`DV= zN-xlb(8&Ha0ui|fa*ciEMfo-Sx<=asY6)n*D}`3+>fR&ATHPJH@(({yLqaaFH#7h{ zQ{VpkMub`&?gETzrYaaeDf85($wDVPe-aeks`u0(Rg}>hT zpV#;L{ryJ1roa7u4+4Ng$U4+!n-1l(NoTDa$*T#YCt&HnT^BVK5IERW%?r?APk*X~ z%mONT!+p6j-HW10q(WEPRog<-V&ypTHSVdzJ*Dkr%l5h;4c5y|@g2n(=V219PETdf z$k)DUqp+K1!(;n7mH^LU-%u6Bq&S2%Rbem9K0CM%h!0bm*Mk|BX}+lt3xh;FMoqlZ zOu7tGS!vi@i12F5{z)~54VlERBXl55iJqbZFmBJ-dJ4O8VCn|6s+d1Z>BGPern95@ zDM){o@iqJpJ`Gkc`_#(ZUVr~VjjeFI=M5#FKG^HmNyt*k41<@HridT3PekSlwGKh+ChOkH7#l!AnsS z`Zdx@&n+Jji3wvrmLC-po!?S6n@#Chx zUXq|~;zJQ67~VC*p3uZqYck89A5h_?iwR~#WC&?;Z-PDCD~hinl|{9O^UojGr)dhD zloFEU5Unm^4Ou_1wi9~T5Lf%HK(Ween!+R9euKD5$gT!^034r!F`wAJEX4VN0z(if zq)Gz>fYod-C4j^9cs^ruZKo-B6xbNAXB^t6rjpB}I>v}>oP8oHqU#Xp2=@H|_e%H0 zlr(z#HMa1X$h05ECH6efz8%0yxiVXKDirNS3qkljBstoKCVKC_u!)^o3>|Nj$ACeQ zQkp|Mj4GMb1O^(aj3dWbl*~yLr2^CZ8bKKdsCIXO@u=nyRp@Pk6C|AxPBmw#?E(ANw0KiiR4%Sf}4I-XH}BlIoVUkyzkx! zDc(>p50^HQIWAF)Ex|!2pkJevv(%@qN5i#JXYQW8w0k9bWQ}>@2Ri7v7?zOl!c$iS zw>N>X>?71P-jSIH1)Fr)#Ds!sQM$zsG@p;X$U5b!<|hv6l7j(_r(QRa_})D$2t~!h zs^JIlHySVo2|aK)#!TfqskZr4Ms(=F`8u!xiwdJzSStr(6Vu%kBjw$!Bk>1#)0`dKyUK7fFrL%SW&`Kjt3K+ zjpBPV0NC=6BW+-@z8Md&992TR1((%t>DU_b5B9fB@0a8JJyzkkt3`6xrQUyyeWy5# zm}S^W=EoRj{#||JA-M<`p`~UgTu;02#EaedCQ;iJ47FmQ;XxW18LBWrKyrW>k}MOF z?ldl}ZKe4ns|j#!lylHmS}Lode|_>E5*_^jDA{N{4V%YOpy4xBPNMFE-M8)PCRf8iBkEY71M$-ah$MK zB@EHb>(RI#41W)Rjh*$!LCBt5k!nu?X>NIk zMYd7-iw8RgBa#2}uL0`H6JE1g!gqlv-apanzMIpa_baIJU3~Z2S>8YMb>LxMK8 zYS=H5DF~AcjAE9Fe!>$h)8&sUr%aY*3EplJgm23 zzH}0n8?6CnD-fUQ0S3;5;AW^r2!m~9QHi&*2#EQnBIkT0d=a?0Dw>{7_tfnBD7CF8JUaD8wfdBoh^Y-xSPuWfIN#Oy0@&jKwlOkkE~KgEBUTw zJ^8)IZkAhf&Ye$+vOPQrKQOD2sRtFw&_z=Y*Z&?9IiHN2!_A$$9SUkN-}gGXY4q;C zGGf3*01Wy8=5^I!!di0h23*z0=2y$~vOND<{kelA&G zcP~pPn=K0BT8iws=VAhq6*K6K`R3~OgXz2a799!eTsFk+uJmGYuFWH?ph^^h;_`lf z_;Ol%zTLG;x)1tqdM!7rzufx)XYB7|en2E|%D|c>8+E^Rfua^W zys(KUH#a0)^#cOGNmQ{n4^-mY_%imiz!W;{-!xE7`aHvVUR|`|B(E*K&Sp42Lko&)jtLti2R4Xo1 zW=a$LCvllOX;4}2NYtl6Ru*D^&w=7pP%VoaPi6nNu!yYt6uTCxJrA7I58?^$`D`!g&e?TQ%bQ{D5=H-dL~k1f zVqdW+%PQ}z6c)|Pm2Es+QeL#vFHT{^G&)|j!~nk6%vTSnFeovjctJBMh_QqLBkqOP znqmbeC+Yq+WW3=r%*uM8lopdXbUm@k!f*PInaW`ak!57ikr99(#eM%CCwt$l)gC1t zAQiQgo5I+#^1k;1atgbvLgCW>elz^cOBET|q8Fg}1Ej zCvvKUFspT0CoZR`6wP>u;H9{Bt`?DiNF{94IGYun^f#&1dp!r7fo<3O*3O*mu^CIw zzW;p*7V->n{+XgrN$Kc6d@Peo&sS2v*M7mm6#43W20-JIP__SjH4JEe!3Nw&6K1rahW zNI1OB#M-tFDUn8-4@9!t5q`<4o$^?7CGei)4G4Rz=1$vPtn5WgKfz*NN|CKPpe-0d zFLlU<>CbV4%=@e5AS4ph8r|i)a;h811!Al(b_H5dj1TxW5*k~gLAsggQvNum zb_(@uWFH`vUsl&hKMpfMm)&Qy_6@5gXm@bvXbdh=m?i?zknjWro$&wRyf3FU)T60zF#*E9)*%(1v-hjyZ zNY{v^WnlM8DY&?ew!1NP4Q`3Tts4tk6-CK5Sb>{12H<%8hz~!&`dtlxUfMc1J__>! z3uF_#h>Vnpyf!OPv-%zauWdZkzlJy*2oX*EbR)7H_Z(I`b5ULyemwk`(oKP-%^zgX ztIZ*XA9p{dTxq$mC+j^1lTK_D4C+j}xlhb7v zWpCS4QyD-W7~c-4Ckw1b)36zlsw3k$q8>is;+okpnZqpiW>Ic@1S}K9T^-FJ)96pn z&_`@q3{JO?VLzTTTo#>GAT(86m9I?nq)7(u=^WCW??lt$ohrp4dq0+!6^tWrYL)km zY5=NWdlN)kRhCQfDnlGJf%w~fPVeNa^(kHa+sRS0eq4K&_qq+x5&)>oo#mX9>Lpcd zRDp!H^b40v)vF-dXQ8KhQ_A~aAs4DSp36vgZSmD=33pNQRT))Lx2%@s(PaqOFyC!X zybbv4US@Es_8V>(XtAs>WRckWb;d#)X}>Eb{*S>st{M}1s$x?tE>x_^UojIy@4@PB zeIgjF9yz@o2MS=)j({Pw$H)iOGOx#y-O~8T9!PPU2`eOuA z?mWk6Bg$%eUsNuXAV|_vmuJ|8M9ir+M4o9yt3k+ZMO0dWF=ya@DOvZ8>QJ`>aJ8eg zqL9Gk_`ZWf7-3As+3L#bYFLT$-aSMp&CZJgwKS@!H-W*v)_OwWIeZq5I)!R==yv-Ku*?a9(5DlJMMAf0p*L~-f*raSv_1_P*u zx2=q1p?TcUGmN!gCS3+M=Uv$`X1u#>llr1Y+%=eTEX|JGte|+^6&%!Lsuf5{<%?P10Z3IbV?~=$DCyzWtB7^> zKtRez^LMuGNW9rP@9y0j+W&50`=nZN5`fHSFhhe`FyJGw8|=@n&vkY)6Ncn<6|n4yXNILsJ-VY?K=Dq!fMEx0$7&X@D(6$qzJPd@U=wH{gf~tM92SoYPq? zqJJ}jnU%zrc;F4GN4rF^0ez{@xaAnj*E^2{kC~^Bq;E>9SgNK^odVjs$974rE@M9i#sU0T4iyxm*f{0(}i=+?%SrNR;0z}(_{^RUaC+Mjkq0jZ!IDy7UcVSXPD$a8@C}9y^sf4rjJ5tFUIFS zot*=dZ#3fw4cb&xNEL3M=oaUd>RvsKcwX49w8*>rULe z>{{IsoM>lM9T4@{%lZ9$SjJNgx>%_{Caef`c5mM5t5sX8SaW*Obu>yo=fXaX%x*jo}ZB(RJR#h-3bTLGOh2JdU z^+2GyV=p4!aY&hOK(Lat9{R}A?!YG)Q#6T;Sztu1NPMRV7bIxSl2?h0Xnf+fiCnG# zBf>&zAJiLyC+FshC2SL@Hf@PnQAD2;z780B5%{^fko^Uo6ch+EeQh)FQguVg387>7&~tl zMmlRpfWEaVJ8Ol`#P~iIttK^x%1o!%nxXlW=Eh&+;z}!jB8Tw|8_L_+e{qX*Sg=E7 z9_2|Q1_{I9Q+Wu3+6wZ8}H(4XB(hfw**I?1nrhf_Ul^SNDq)tZtYGl8tvs zPd2|GeF}vqi3dX5U(<-aW5M@(DTc$ncEeWtxdO$K>R$(wjWsB)a|%eOb!u*CSlNtL zg9C9?7`G+$L>B+12*r`*6l+y6%RptcLWr+aUP5Xfh(Wb90)nxG;q7z>x_57KOL{13 zbIBUx0|8CN^QZ*ZE$Pl27$6g76z!UHc$2hRpp|%ML8GigUud>OH;$UtAZey?ASO0+YhyM zz?4|cFeMMa0`fewUE!81$J7e-Oi&hB=m* zE!E|guDa45D5m%=PoM47C8Zg|y-zJkliDx@W7b>aI=C{x4>~byn|Euyk%g#7K^(g+6@1fyd*wTaP*8=Q=N^moo$FoUd@R5m>rYyK4 zPT=qrUlxXacRMSqmZ@YfTB9Z3!%vXWoz?LyWOoj-dxCD-+@hlvnQxEWGN{(D5M@We zct1`ls;!-Zz^U>rl(7+N4dnGy55bqR;mdK)>3_Z5L<7s$^L}q{2?&W0AxYYz09O|d z>-ZiL!@a-teIjCdlkJWUp0rBxJpjV5^^&b-X`XqD)l(;gj7p4Df)ek3u_Fv_^C{$cj2C4|nRU z_cV==A^EW^CYtF!-hb)Xa`b37PYmhJo-aH z;?VRxEK1wa_HQ%wDeeVtc`h5c@d{!|oGqaLhBb8oNDWy4c&yB^TR%;<=h0HyU~J>l zDgn7`L|g`8D&JeeDPwU@Oj1Q*MYD#k&aL{=3k?S#CgkW+36Q1&Us|y|!fxmSllDoZ z6Xh|be(Frz7$6k-hVbQ?mv)uwLe zGn88-m#ca~i%;uxRY+Pm@hMuZGM2kj7QiRGn`!?Z-0jT5?4_CtM==olC}iRxTtIR_ zSV*iN^b79N<&AU@{Pm82l0XgG^eY>0R-Uy<<;EUD9j^qo6ck}VU%{6KWGeJ}btm}9 z$O!urcY@QfQdSkV_<<3rA9$D6EFiDyR{q_4!GB5xqo6T-GlD}FTS)O$Hgb(+fvmBT zs+6ge#B`LyEjin4oKY+-daqoX5NsfO(g548Uu(ZI%w#o6Pb!M`?F?AchGcZHKgdRU zeI1Zil7uaaCH-nUFBlJS!4s|z8kuvGgRB>7v6|gl3ghA3lthjh6nI0FX%`u<4@b(1 zf}VaLxNJG#Vk3;P`Z3-PV@5JWYQk8LxcnoMl^gXkf6^P)v5o%gk#f$P;LYF+sv7py zDYW4Py6Ji5k=Vc`i2M>+Pcw=-)_g40@`1rNOD{-ok4Z`43&(imnQAgkMf(u*s>G+T zaDC)@FfdG4>`zl20&Vf-!}082Z%WH*5gtUfqCN1b=bp40`-{7ptJEsY64yHn&?P^%C z_>ff$fGLFqEgLHC58CNKQnrA6w2->sRa0#itZkIIRKy5DHY{9$_nst^Q^u?)^4I%= z8+wajZOVv77gR+Gda50zO9B)d$tzh^fwdR{P#R)rOH39K{+pT^x-o?W0plpYd-V<% zi>*?_VbF!ig?B}@t|FT0R29Yhq^_t&mSRqv?JH)!rz1L)u%>M6=`Acu`N<1y8~?E)~Oqln9_O0ZSg z^fHng^>uv}H9gUwZ{t<+HpgoRJ(0T;4GSn~45Ag?(i;iOoyR-wNKS(tJNq_90d0$K z!!WZqA#xz5ejS*WJPUT0uSam=lVEK}`(H=8lSS~OieYB4um=s_EI-{llIQ83CL7+g zE65rHpv6syWNKzvt!3M{DM@G6l>*w21;!M~gPsNJzWkEi@3h8H!F>y#iez5g1rud% z0cERSbmQY{onH1+yt!Ct!8Le`Q8wkob!p4S(?em0+PbRU;?#EU$rNHkwoKLA1d&U{ z;yaXj&li#!r)5FTQxgJm&&oz!SBKS^jHFU@_L{0$$hJ(t0~MoPi!tfUsMj_u)gZJA z>yg}ZljpNBENw)B_a`AIGNt@4*oO+}a*XU&!%*B`tOP6>@xq;?Kq@?*&scDUBCV~8 z+4-MH z?fV;UNm^^PjB_W!IB*4d;^Ob4=^+%6j!X(+C6W0>Z~%rqhqMSPx*4*#_%X7P99kWi zoqQi{I@i)1klO?*F-MZrsAxzmkQiIxu|2oaNulCHzrACzHVpT?;C&J8=y|r z=f?di_fUt%cJ48+u?OpQaro}-90;Y!8^>s>kWmo4E~T&vvhEf~QM-*{`z>jIGB;MW zgkV`W?oiXKRujK&l&|ekIcK%nQ4{*fPQbxD5lFM983Jwxb|zU;EP`(zsSdZ*{WnAi ziB@!TYpSzGpn`3Mp?&v?x0ef}7GL@XR%5D%?z`Vqcb&kkJC^{-h&|6d%!SvxO8FXf zx^1frDbY*hTtBLUb3%T}zJ5xu8!a4Hf2SSK)fHLBEDKav-|UW*)|_|Ncf|20PSI5S zuuQ|^xv@jv9j-_!Os-RKM`SU#Ed{kwOIGesNa~K7NHpovt2-sd6tkHn1y6sXSebC3 z>JZ$AI@w)TliCY0x@aKG@DS{@R?H@rGF01^eG&!%SLd1-m>WxPMDT{}bD{OkgnS-{ z{gQW{*wN2!Ky-hEu2DV%&?3N$YDBATYq(UK&sJW7V*wWA`!PF&v`Q8fos$pr3*ZT=NlaHqsr=Q+UwSQG)Fj-mH~r zOjdTUCvn(g%)LvO6(5FXx+IE`&C0ah-rhLeY40=+tj{7 z6W?jQ$#5>OTIw6>+*&-hMFKj{)t<87nqR7~(FN1!7Wx(%9+-!FmRv*`< zXMs4C#(LVZXhE&2qIavm+%rDu*0eNc!X}u~w%_o`s!IKT$2Rw*zh(G=rBTqG^1`X^ zj^~vZ&6#%0dYzG-Jxyliu!JGW2jN(^sDR(|W+%;pBmEJUn17E&QW?Vt>AO@|Q;+U} zO@fvcN96`PvX{FR70Br+UnteGu<)3_N zNs!G0NWWgTzO_9vvrcQ=1M&jrW|&uGq=&n?*?z1?Yw&o`X$R#bkU!^_y^28r<}u6? z?61GvKJY3J=p;<7D}aJ~ig(?`1R2|O{nE3HVjyM~KZqlHd$&O_Fy78bTlPgZjKpdt zvt*-QKXMNd=zY19bG)bQhclnXvZe$iMQm%k#T1P1tk4l3A8%@_!87VV$+T3GtakYs zj)70bOQ)%b-zbs@l)m<25y8) zhP^kkgfw+=8nIG&qx&rwjdCpQsix2fzd7xA=Jv4+g=^};NP*m(;i0WV)05=#?CN4y z$H}aqBw#<}?n`#F5(K!LgQ5EiJMtqzXI0ysBe=P^4Epo#V})7(sT;UL#5?GX>8)Bs(*8{!33S}{q7oUCD3A}_ zgy4o>}a~X4VR5qYmE?hxA9(_`l8A6%7 z{_%C!7FAl*5?gx8)PB7|tm-n_{_l0tXy9}i5BD-4Pn9%EJi>Lg2^$d)zq=_YFDv4X z(R{_furKfU9S0<*^Am&C2SfPzRJ9 z@p8ZV-Z@Lf-Jc-g_x((4wdMv6pw(QhIZSbm)pQ#|vue&2D^COkB6SN|{c->>;lF*| z<%6tzFLUj6aV0EZWB^b=ufNc7;qL}hgIFwB86>vt#JowrC=V&iQXMUN%lwLNBDT0K zSYQ>FNhFG00rRb!y1&vq5t^)^bSd-R{Q1?bWoo?|>M5=7hl(Bapg7R)?84e4=S5H^yi0>Rm zh4*o37^())YU3mZM4@U9=}>is`EC8-MM1mSSlTl-hndQkm3LRhyUsI`M>}A zPyf%afB&!l;s5*(zkdCz|M@;Ae~kEt*Z=arz2EY`{^x)9Pk;aV3;*Fi{$Ky~kAL@{ z|NX!E<3Ih!|M>6!_^1E$Uw-`Y-~P*=|L&ju+kd&AQ@(xpfA~NC^*`wMS<`fLuFXLJZOY~aS_ zDOR*;E-BL40qEF{2RH#LZd|EQ(O7arR!tNZk<6;8u*4xnfMXj%P|fTt{rj-7NcNKg zCTBd3%wr|{S*AZUHXU3G*}rW=xbVJKGPB(n)b_Fxlo)|5Ce6=xD^9?LZczDr0)bgG+3Lmh%j$aQM6*%rNO1{w)#Thi8X$n@ zbknE^=@Sh8Fm7G>fRP7xn>45_#$)wh0w$er5N%_t7!hC#e;>LgYGnYtSAQR?qlS=@ zjLJQ%&*<+K+G_Lt(+r^P52(YDK*Hip1KuXEhiSMaL$nf^m{EDq%u@CD$8<-X-x$q7 z-TKS$)zikt3 zjw{@vJ7VBR&eadfXTiP?n^pYtQsXU}EYGO(co~Z8_+-{=kUoaB(5n zqO7MpM&_u=suBY9OW|v!0=rg8Uu02^h&4k;0-LL=Dupo~+q8i~O6R`H4Qe5H#FNjV zI8LU6B-5Sq_gNPvG?j397C8P#Rwx9VO(3Fc1ms#2Fe5YSlKJQ|#?st!VJ_^HZmB5Cf21)@0(SB0)6`h%L8|%ycBN6TM|-S`u&T#Cc3#c!)+OC+ zpy1IwyKYE7m;XpLKRKG!4cc0sUiStJ%L@!*P(8n*32WLGgF0`#=e&J(T4a!_2XE=t z&=yn)KF7v>>ZV%9C7yL7#T(TqZ#b`m45tB+Y6Vn!m`XA=Gy@mU-PGW?zo@lBZaKO^ zTNYPD&~-C+H(hk{lkF!Xnj7tNItnJXwbD`COYnh9aC?TIV~1p-TZ=RFm{HemC$G(f z5{U8~_|hObiF4JHJE?C0W#fqGRlcjns=pNbVOl9)0ogcN)n&Ew`)>B>4x$9S%Z%V$f6a+sf4 z1{L(~xV#~%iRTiAENX$8kNY#-pOiqlp3`@Aks}}}$unc z_nf4owd51k>B}d>x@IB;%c4pHm`1c>tu~FP$IbRJ6{Wiy#`kADJf^FtRUG-#XT%^y zt9P-|R*TjiNhVbN^oUbm5n@!58R(xaUp@3_;lay! z{p~g7n!Y88txW#k+_ChQ?+MEb$=<2yMwW_u?K}-+3GY>Wk6?`;JN;-t#*)eiTR7QtzyODV8cwFmTUB`cmJjIx6n?>2mO`YxD!dD2J;B+W7Ya zcK@EylLGS$&odNm|5QUAPd|?ZO_%F8=s5tmQs_5)t=dSe-1PC0j0T3cGZGvw2HX^< z`4@Dh_=s-VscdX}jfO=}OV4fE6owyUE-Ht?vwLD9X-rssvsSRGrtVeuiYSZ|?He!`kj{U)pi2UEazJI7eJ--+2G=+v~BBNPb`GFq>gB`~{Q|n*inls?2Hgd~AO3qK7fbRyJtrSD-G{Zck1Ibm1l9__7I%WSN1o#kDpVQYR66bZ{;CfH5yV2 zrS<745bjY4YqVhfTC9JC;C%ejI<_NUQlo7-Z-&vJH>*>?D@%7jbgBs26d~o9hI~W9 zXPNqW?Z&^_({u4VLi)LaVqSmj4)U7%f958 z6_p>48YCfAzQrE}_HJI$A<#nXGNAo8XU> z`?!uIqKyeQVoWrpWjV!w|#3n^wAw{{`d1&G$Fg-Ehf>Ni`;<>>Sh;%owe!OYP6eU&um8k{36q8rv)T^hb zTso#ONhRe0jyW)a1PK}b+@Oplm0(Q8SzEw2X~_pSP0sQH&E3_)HN|e4%Z((aBM)_q zmo7m&^sj?rwJZKQ_yk|UK>&VMwb8ESFjq=x2+yq9QZl$(Pbj9#f71_vOh_$=ZJ{;4 zN4h^~vYc?tI*r((4e(*fqr>gL6WYpuLb@BQSn|L*>2CUX&Xj6mZCeRwMLA6sl{wE_ zI8Eq@j$9uSmoIQBG~nZ;I-8s|$>eo6gv=;7Jc7|Mj_ms(q`mg0a;IEkxK>vwVIib_ zGqlZT-JFd-6{-9uH7b;A)!>jK$z2ZQb!sVwrDk;?2A-_^jV7}uWy2&fa6Mn3wzB^L%e_g zrd*}Njj2qUEQy#A)439;Bsu1Jr|-T6Vescdk4XUv$&NLXF5c zS%PN<#}6C4AVsgD8rc@`&vcHIL)%2~1eV@JPi}Bibd{IP9U1``4vI6eT!A;Prm!i~ zia9pokZl|MbXSem-WV)Z6!h&t13U6Bp@h#M7XeI)Hn8Cu+`Wr(Hf9V&^qE0~SA%g?)!K;ZiAec~y?v;~5ZCMefY$Gd(vY2UG`k-jN!D zsC4!Ox5$XPej9PlAE=IX3TattRJ1m=5H2pR5*4k>(3hlVTxwofeGhz#?{^kV;m+i{ zEC5atB~~~9_0SYH?PVZar`=k+Hf3K@co0@yDpv3*f$yNu{*|Wv4PynE8`KTu0HOzq z?rp>L|cgm&}N@y)SgTZ&~=j0NI{`_V^+)30uB>x4|BPQO?! z07>(<5i&{@=HSatK_mR=YZ^?%VtsWLTni0Mlq%}MYGbip37Mme zV;?Unl0}tsLZDxQ{>~ADkNK$>XiGic&+&v27%aP-fV>A^)D1uxmk|Ijg-R0>060Cp zb)*0S)_I?S?i~K$js82JvVf)~w3KFi1@|i?g>J|%?V>)UIN>Prl4gaC8U)3e-mnj5 z4QN-o(J%NTUxW7up{w>=to$WpDyd@|7Cu2J6vr8&^U|+TmW=%?vDhX>g+MO?h;9gO zSq4DsdahG|5G!8kS zhC+qn4X;hStIVau%ee^znH0o!zpuPn!ns9Z{a5@xuuP0#J%-ud41 zKqoiiyVdka^mmh{Oc9nlNi)*1f4-a0J&h}-Hn^R}RQlBAC67|OE7blxqICo5w&_x@ z>9%~Yt3Fz^&jtIf9UBD8J4Y*`O_q+dPjNQ8^Gct8*U^HL*-h>Q4@tH=aqw^V>69;1Q4%8+uRrgVJA&4{ zfBv#C+^{8JJeq;0(_8MxV6%p&fx;j%EAW^81ufd_46vJ)FZ6 z241orKPdu8C_)tSUoRtu#o-lcq`1|5R8!QFcF)e`){u{1MNt1x$(+(tRw=|j+ROg9 z!duQ^R4tgW>;fn#FD$7`9)HNDs0AVnKQb6oQZnjw6ePTrHc68r@JDp_Gf+8~BAIFHR%@sDldxvsvk2{(ul^VMQonIC$i79>&nMkF3~s$>LP zTZO;CsLE^i!;+LWqT4sB$j3lCnssgg1W>N$)usT=H`Soyo#Vz5{N%k|i?)7>EUv&m zfeQ3Z8NfFw{ckr$7uv+NzvXgtK-Cij2Kx3^u}tRy>$4SYiBbjpLo|8}TdY>l70f9z zIF@25Dqp9sL)bW7^cBBX^VmHUoMUyS_Q>9P3h}_E-84iRY?jxe6EZA7(|e?k_g;L^ zF>n4ZSnI)JL&7`gTps>i7K=K=qva(nOz;R+l#ZO`+N>{B2|5zK7hrgLnJKK0ei!(QMEv||MVA^(~B=abrpyP%cF<2~f2Yw%_C9P7uP!Y{K&0of#{3NvY;aXsJZTqsI<+j|8 z@FmKA0!?^y(1806saRT@$}Rh}G!lxCmNL((gp-N?-9BNs5W%;}=4$Gp5?ewn;c;zpdL1;4q%7rJIa2?tPc)ZAu(eCHdWvVo(!zcuGXyhOFZ^6w@LyOijc`1=Wup4b)&kSx5}V)Bw4cS!yAu zJqy>SL=huvcc3~Sr+eT{zLA#-@os19^aq`b51s|( zkB{Nw&|l#asaqI!`-NSoVZ4WbNB+3`|0^P+&E;v`zxY0Ec2WerBo;oS$Wi?)`NF+w zIi5MTu31{X7Z8?=Bga5E3`@0>pKzGfHr?()mY%ASJ$sfG(qu*WW&CD0y5o^F8}wWa zpSuCamxg0c=TJ43iBA;_)aD|*rWFsFMF*4;=dEu^#)PiSzW@LTC@MBYKi!T^w#V4` zQ=rq+j2M{R6vR!bo!*jvs@Em+%X_s{-edLLlvyUuo>C10(jrFgI+islP{3tgv4>4* z(O2oxq4yf9@}X!s2yD?Omwm$eY0Huzb<#3PKufdXE?akkBjs9C+f5x^!AS%*RAyfB z0_gUU#RYe3`Gx`@#1IDdSO})x0sIb?RjBHR3Qo8dNRfV;4H70v_W-6M2B+w5 z%T-!X4V@b@mjF-Lo(YUY{AcBD_Mi|KRs&?Uhl%tU1R`ZUJzVdh#MxGuI*urUy~uOe zey0ERY$Rg`r45GhHZ>ogn&2x()6rQokR#e-Z`2X8o!AC=TgU?RNG14TEnGwLvY7&@ zRIS2l@tiI7hwme>n0Cx7?Jq;E=g4zuQyDqjIh2yEx@dAwuuSuvs5tCMtP@@kUU$;Q zBxI@tC@m#d4kYYaEodwAA_Q+nd>^9NP|{*6m?wr%Geq?VrzKsjoqEP|JY&fYSAJTUhcct=NE) z>dRXFwSq5)sdcXwQh@u{fNyqev8U0Mb8S-d*N5X~TPi8_IEF=Q`b@b90_ zxf;%FUBSRHsuyXd9}@milT1MOO*47sTXdHA?6zsQ5?V`m_x9WqXLWmJ0F^mlP`%XQ z0__mWA~6N&P&MnU0k-oCVk4@$=WeMYH4=~()48y zH)dKyx_YU#rB#`hht5ek8syU+#?cnHqQv>~mjWvdiq#64;pIkM+26+Y96pua#z`sr zlOsY`@#s{Fus_F_QGqjfvy?BdXz@Ujcow5197-zd1z```X%{oMgQ>l@Pg}Xt4z)=q z!YZdzMz52Or7+(Xu*H7l;8AAVA|#kt2I$&SEJ7)APyyR^GNRHfkxgp*QZiAayW@M^ zsNUSQR1~+seOkKUi;jszeH|t=*B@bnP>!r@?kY_!!i7zut+FJElfKK?HUeQ)KO=0+ zvb)`!0@FV%nD4$RnSU^7DIGI$#TG0BZ6fPHqOlZYuUMEXN?gK&_aL<`-uR+e293VS zOo0iDj3dP*alNKH@C3vtcAbtV&&T5@hh+G&Q^&nMplxmJD!_qzsotAQvCH77NyF&w z=N{{<7R)IDjikDs^b%MFO>PTJmF}`@S%wM-)o#FhCt{Fn-@nw&A1+uRfK&fR+CCF`hK+VWZ^;sTQt&rB5@IRQ zDJ`o$H=dFGIyOvDAd(78BxED{HE0rDV$ES#bMt4)z6}EbhHVq2+DA?3(7}`q1!2ZQ zu|!+W9=1Q7^cF;{v>5?pSa4l7Be1`S?T8D(;44i=vmEvd!+G#i^w(eJ+aL;c|00Gd zgxe~C>YtSD(!FEkF5z+rirN@mty5-;oa^4xV+`0&h-Cqy)D0pP$?xSgC3iPj!s%gkaf5FPe3~ zxN9V>$>g zsOYL2GZz57kpTIM6U6tiPR|J9cA9D8DCC zV7AqtCSykfW0O5X|4Y&48?0G|u3I{ZUZbdYUten(R2g9T;Y`z<$HmuhoN4cigS;0F zUu<>W!LXv}7&@-|yp4fx7LS(qg|V^&w->z;naXA}VN85i#aF}M z9D&N6UmO#}=X=w{@tL(Ol)PLAkXUK>zI}iR5O6Y`S+GSYHlk9`GE@Z8ODhLyEh(YkVIxf%|5bgKEnIfz;{wVw33lBDGWQ(-$O#zNCxw)KXcZtkWk=GA88ZG~PgWT%yY)aDhc z9t4S=x|gCfrJ)KE_ep-IEOP~?K$G~6;F-;>N!dy(6}=Fljo zMG;9$Eb?;;Nt?>q+uR+0HmYg>V8iuW6|n)K6?X$G#3o95+wDLs6=<)8+cJUm)dB1; zy$>s?%_cUD%25@JG%Q=UgSVe7)1Fj)@2Ihz#pP46AM~1$^kEO}5-t0m7T(5!OZQme zmN_J~b!RQYIaB4Nq%urOYpKuwInFrP<=gp47vv! z8&@d}>MMuK2+ELG#^_|`nHmVUXR`ryaz^%=2^w_zm4Zjb!G5j%edK~u1zyl&rf=PD z%^R9y%@CXp{-;a-rO_s|^sL^!ERegbI^(SRpje|mo0I?$zO@dy1Q^q{K{iu^uMXFX zhVjHsayR%$TGJZN=+UzlLVvKd36!8$JSN1BGFw8=q%F_&=2|V*Tr9m!dsAp1Q!<3` zgFl~o7sRQ@m0aZqH;xGSXx$vhs=j~4J?52;Pozb6H5JDQyR=~6))cb>zRP{M*a))L}Zt+>h_dwL=SG-1bZ z8(i)T6AN~W-Z^^@v0fj_&8%R2YwMNWlD!6gNf2wcZNiw)qia)(2l60FyT-TVRuXcL!~ z=6cu^@W@djIdrE~vW|535fSL=FC0U4&O*!JbSes@!ODWeLa)n0 z%1ZOM1spg+C2yK`ERw-_{7M@j9)tpV;r3gsITri>(;aX$s-jvF>740nXZjnOX zXJn&7g63cpLNeF1I>=zPoYqtXXb5&%+t*z6*kCq>wNNm?P@CMKbjsGkEZ52RX@yidrXS2mz@WaJUj!J9W8 z4$PE$0(m?wK3OxpF9)EGO<-FFpgZcg)Scqp0)`qS~E0NTzHGs3IK>6S`NbHb~# zXxp%7Rv7B5Qj`k|Nf=gNZl}02f2bXyCq(-w^RHv13E9e!_hXWX{e#&s_$KYx$IjYu zj%8@>1Z(V`!gh1Mq0M$n^}@oPdK#58*nv?(`ciI&Bhnmc#>B*U@(C3b_Xf3wfdO)O z2tSH!x7_*M4f)D-=~*sfOuHr3%#+rEy6yWNU*=}GD+Smfsm#cnct zsU~kPp~41SXwi3!&!p6ZpK)fUDno?(n^>61)bXRb5V=K4Gv*Vr!3n3HMYS$zCokwL z18kIZVDyvr)L;ePl+#Z288p1zKf{Njp( zG*D?%l(3HaZ&SQHxB4i8YKsh`#$3x09vo*r7AN=Z?Qwmz2fc+(o|YA0XK#e@_C0ZF z?y*C=(S(f@jAyZ{se<|SmvJD;NZ~*Iy@SRSrUiM*}^5j}iBld=yfE81; zdrWHj)O6Jo9Z>$nr!N-kRtyy=v=Q-+K~?_i80TP#-Dpaj9BdDiidS2dSF$_GBm%dp z9}Y((PGKF<(}G}(kXowuO+2_D2u$aPoiZ$_T-`s}fPNoCE5GNy(6kax=MUywlOl^I zY(Q&O?4ozuaK zZBvS7_Jfk#CLZv)j4 zme!KQkz4%|2WS&gy7|t%8AM+Ro=ysGKwL*@k1(-P)wSp(6$cAM@Nmr$kAT5%BhlTh z204txmqhZHv7Y>bc}1SMxZNGO?{NL#6!y1@1Cuhn1}>%_OZy0-`H=~V<3`LZVx)k7 z$m3ghn3KBT=$!%3*ImYYkQX&|^R?{PtT3&Q#B;i4?9^&j>Ve(*{ZUgl^HJ7Qrx0nK z=J>!O{DfFG8}GI%^zu}Z3f02hhI71DG6MQC6nU$6+d8(52<6iH`wW4#z5aap=dTxv zrBOfV%tt;4V^;v|LwzCv*M-$b_edB-l@BSHOja=Jm+@xDu-r)>R-QFfA2J^g zSNgNATSfk_{B7CA7Chw@IGoK1+RfdbOG2wO z2l^9#bA6J#VTLUH?Io2v#RK=g4gt7fZ|~>FFBD})qB+x4Ph`O#7E_cz)3mOGBzrk+ zL22|2!6N9XX~uP64=BYfD@lomq)uZP7E%NQQ5i7JdG0euQ`iwUE^h1TmdHf{Of0Md z{~JHKQ*<``hp}xBh3Wd!m^vo8irqA=%>@{k7$F-KfvLTvM64nx z{;c4|;VVmBYHLbHDzL0-qe3GH8}Qs$Gl9aHyCpY%T4G7qe^9+Q5UtsOT>64y2Kg%q z9)>UXeEQakS*%*6yaY&a^DhgZYy^PC$dsVCI2M@?FVGwCbe+4$aN~xCCo({d1}JK= z(6R+H-Sj<^J1gpHd>Fqu=m}eU>V@t1ej1`U+XP>tN(ocD*2pO{Y39KE0pcE-0*fL> z1#!ziqp${DrvOO~QZfMY?D91y80c(*u=$b3eA8W|8deDDYCsn|TL`PFm)odS<9+j< z|27f}wY&JVdvAH5KL-%tgpOn;+0Jp~m^N)Csuj`Roysp`izHSGQNmWNhi*fDDUgp1 z+^gXAKd4eCi-zh>gHvkz@qIq`ePQsX@&0M%i@Z@}6Kjtz@4VmEP*)c@3l*H@W09R4x=|5D&ci!KL5xCg}sq1E0Kat7IMoy6MPpd zMD;T?BSHNuxFzkK&N zsg-yzM@A)(WueaWmKcV^P&<3sl8Xgyx%Lx)acvW`wa<8Ql&Xolo4dLgDht+~db8uH z{Ma;OKIa9HD_8oFtxxJH48+qiKA_h6NFEMb+Z!uRiDknp+P+eX31&iu49M(n z$bu?-6Rb?a?9++5XB|VF2+Qvfhz`(3jr!7WLv2fwnXH;zw|uVJU@5w@s^OjknN`p# ztC;ND=WLG(k!v?D@~tt#SF*UTa5shH8<7jN>Qi1YvWtNa%&~Y6mg$h-y{8ZJ)8DR* z6!@~KygeV~)swQ>J4LcqlgJds(9N-d)?%8M_xP~Y)AymPfmN)8YSeE6++o}fDSqEVZ&}fnRuF&CFz^%+n)JFn+iKr0Sma97rsDgh zDi}%@FpX=oQ;e$eI-6cCKrSah+Z=-E5P-YdS*GB`bN2!VD|oZ)oB1@1W|3~d268t` zzRYdX#_H;`NugAx5xR_$v2EzFZ!zSeNTTN${I0f4>4-atRgU^AV|Cl3t+*N8lm@cfO~l)$ zeV4b`OH1wZ)vaD4EE%6yhdr)l112dZkBEK^@6t|8g_;BhesNdSu2xngE7Ffd>Zxvr zEP4l60D%C2jD;i>w92~&n9xUtDl3_bNN4MBD}Vs*a8#XISsXqRQ1J)rdSc;4piMVH9%b+$x{*U^ z33-25G+j%r%%IOtGp0gtPiqRxa`nTwJR^2KQkTh?rRDEGn*}1&oE5hUOLH~-TSdy> z&8j_6%!(ADa9d(q;~_xqOgLK0?$^%{n!r#kNS}Sw)D4gmV@o$kEuI3A^k(0Ug`-_kVI0?l5cg`LZkjaI z4U1dwUj`KCCITezJmm)vn50oR5WD#p@Qaj&Jy=!@Zc z4=3t0%;61b`Iw&HH=yJ##R-tGXM4DExKzH=!}4?j)f3v9lFO0;pQCC_q`r9!Ft5@v zot;g}tB+g?l`RyVAh|8BD-`c#M&GRaOBY)2GgRDYANggad`3iJg?PZ<0uJ$9y&u30 zUTURbA9^+r{CLVBVEv+CftUPJJ|$iV(s`pI5rKW!hrN{Wp$5{mX6t~Ukn%wB&R)`R zGN7VR20kKdDU2TPkuSEr-wMUTB0W3MQLMKcXrsZI(+3)&aH1p|gHpc_Rj8hZJU1%LrgO8}+*?4x z5bjM(R`n8}iZriUCjACuJzb2#G*xj8gRK5;3@ybxDm8{=n{}v}U)STDyVl8Jv-%cM$*^JTRF&AgM8U3U+){Bs9HVMLq2L^~A{ z?#GRXYNxgoy=sCHYJC6^SGY;H!({RsRoNwLz%B;$>^2{u&BQb{T5YY+km}%u)cB8t z8{aj4x$xHP-dSm#xM5Hcpn>1>N!6k3SxU>oLl)lljfE&*n^!6D=Todw^q~EPk^+4v zRph}qs;bs%y6N3h!s*96DJx6tMx@?0@xKKUsEE_zIa{h7X zh&#+S$b|J#RE&@&VZq5>tL7k8y0OnfkfR8;qVSWkEYsEp<$Cpl)hJLUtxl-Qvew9e z8gf-U>4+x(G~jkPp12oX?X~U|YLu~^E~rHo+iAofY||?DC}4CP*{KT*%Y(`Yx*`MD z)pC<`3rmvP7I-`;x=|Se%~IjrkPuX|(1iCzdXGQ3cVkTsp>WNX@vqi`RqMp!Nx+LG zBRn>##H8PI=ldRQjw*n~Htox+`cPU6zfM|aYR-{yQfEtnE0}ddt2DUm$WeWU=-3x< z+O-+KI)s)0LyT(kzLAsmBRJWT{S(&=m8Pn*b*%Aw zNJLd{qWaL?8?>~(u$1W5Y5@>LCBROqq71V7hNUWrL^Fr=0E?Rv$5BtQP2_6p-AV-6 z3AcAPO2-AIp(K~~&)1=zESSuIGNJkk3jgrl?K@br$KrJB;BO7W(eb~anzWtb(PLU} z?WtSdGMR&&sJIQIQEQ4Qgtu4os7AGR`@;RooF~P$+w`Z@3ZhqC*=8-eZ$~myLd|+S zkgEGg;Pvn{4DP18!8VrZZ04TYwAM>^svwx5TcK@Qc45!taqn%FyMw5C?N2AAx=f3s zBQ6BZZmZ{H8CE*oEjRHMa`~asAs=m8^cQdaPX1Dkrn(zxzem!74>F*}nwFhZ<;EpDb z!9C!Hp^y=3vBr3plQ)vhX1b-h7dq|$Sm}cNvc!iA_nKW8;5BV{&x?etAKfGOw7-v3 zY}U>o^*BT{27~mm%9N%DNfNnRmr{DFkInxNH$C1J7TbUlDox??;9Jf`!(S+bWqEL z%NxDsn{y=9w8G6*iP1d(U-8%9UIz>A`{3Tav&)hPLk{2}_IjRSQDEh|3V@(#DQ#KC z^x8w=eo)t(m-tD&F`?))4LwaUeA>ydP^xBcgA%nn;Uy){m&dkXNEcbwnj_A;xg@Mp z@`n$0?necW=I;Dm;P<1s8M&A)%O>97q>{n3lTG2RZC=7w!%C(y*V7hg!BIU<(~!9Q zP&l0h@hi~?3hq7o<9vAqM}NEaKYkxUuI&4P-9oJdjhf)g0bCQEkUSvmDoBHZmr(E4 zeE8pQo;9h{9J>|GmU5wIAcR2&Y``U2SWy`mRP7GEY!!0WCv~N%Dg#uanSWzrnf^#) zly1n#vcQ%+)dNfskjjINM8Wh+r4C%Px23!$?&_;pVPFcR3S#F>+Y65NHmH!*pk2$j zZE0>}O9<8Hn+@ui&r%BrBf@b%G1>~!h}GbJ`mKYOD+V$AiESmeZ$p)(mqp!rPrbo= z`A9(m2TLX7yoK^$&%&FJ*zPW89{>gIN7`9NA`YF!CJ(hDlPC{O_q*XTCdLY+c1>mq zyAoVuV`z z!G%}jVA!>x@p}L%RaLpS=uh;Jnr$zAjp09scg0&mXlb$2ZkcJalQorxrx zRN+93`ld?MpUL#*Ky|e#3=kFrh_Hn5^rcj1*1t(+=V7VUiMroO^sJ?9d42F^cVrDJ zYx;5(_PTB_c`m7;JKY4mLm6Amu7T7-lX8>SWq3gfuBOqOo#F=8&;UX8OWmw^_p?=@ zI$ko+%~H_3`;!xA54r*R0s^P{h>Dj#&{Z7Pd9utvvFcMi14S~?u~He!w@&#|ffA%> zpjt@MPGyI+RkiKn=3h(Q$=*=VT&2TCL{RnH(Ceh_o$tyP--jsv1ly0|a7iM5KY-N5 zFxx1g?BC(eAytww(?N0Ms68X_TirfM*l7PYrhRt2#9Gd`$!*|0rY~Us8?QAt_yRd~ z+yKJi;fGy8+?xU9p^BdE3;VV=j;zUGE)^pt%E)f}P*s;5S`rhrK|WA|8^ua)y;`fH z*XD8uYS~tTO<~jlg8CVE!W8DAkpo*2s%eNk+*+D0&S|P-3*Kw{wn%1aiCM#(`~K-- zgeF39(o{C2n8ev6vYlsi;|7;9!KU5-&7*imVGNL}H$eKoju6Wt3lXnr{Whi;u4;f7 zamN+>3S3hYf`>NXSYe?Wh`r5tGcAIU_QM+OEc>-lO<&&!x|~2J+<}2ls#`=9&Q%O_n=}PuFYK@8mt<<@~ws4ZCQW=d6PK|%nfL!R6*oM?o zYa9owouW*i<|1~57+s#c{?>=HoufF(EK(7{SS-BUl#c7NHsHQffo6l_oHmlxRxlXW z9BB=`Cu+O8;N(^^G#JX&f$dOONR_23^l@d#bbcgCr=3EGR9XtXxh}RXf+f{=ecv%5 zBQj;5w^dj!J<}aX_dY8bO0(Nfl z?X_XTbxH|NLu~3)8O})2jzrVc;+lqfR`tTnAV9q9+71K&|8zY*E9! zL8v4EL#=y~2wu{}cvF{G@y$Wnx%u_rby1p~dcx9rbyw^pvHZ^57dRu!D{ad`fc}(3 zh9%$5u}vk~`k-qxCbL{#0-N9IP}DESURzz8;o7t_ieW8RxD?=2a!+MUc z=|JMwW>WK@Pcm+nO|M?X7Zx4ft06@_DLiNmDe&xy_RZo3nV6X5Bvqbhnh7nSQdKHd zGaXNHK;`akS2U>qeH&UclHBlp;iz8U6;(!W*k>yiS1j8p;JV>AMYT89)(Vw)jH$Yo ze4D=HCihs!Bf?#v z#)ab}JNUZT7Ad@$Lp?{PtEd|6cF1y&(OB6koDzZ1ikjF5qL8wP?WE6fL>XRgQkYe8 z?_Wp2sc3$ROnXw9tpMgHVFdPFk?d1}r8r0D`l4CZGD%;UHtRR-b76yZ#gON>F@Tn8 zrvy6g--ZPJ6&q5ZiZjRiJti6IPmB*P{vG0rM(^OYoo=z+G)J$+i&#aA5MK;ge}V9w z>-|#~t|M%S9!+zpVGEr@77!cHmSe4v5$thj#@N6K{YS*1;qmAMEYYU@4aFa6KPqCk z)B=8IySquTq%5Mpf6Vlsa^3i4t$O=K&q#$b{=Cepzm3>B&_4Q6%lFp<2T2!N+?F4h z$UozIaa9deG%lAOkN120ccTSCWb5a8$k2|9yK0>t_Z*`hRl6S^?FfI^HiAaK_`Pfq zDli$5p6K7o0RaEY1y#>tRLN|K;lg1MztPtQznov4yQz((&B;2^Ch|mmSmis%6x4ixff2YVhd@b6Habg~RitZR!9$ zK*GQN+s_XjkA43r6!?YGN!dV}`nK>%Q;s*W61K^-*UnJD+;QYZVNF}WD0bQ%lJP+C z5+aRCc1+iOcFK-4ml1-yB+z^~%-hPYf??#ROy=30Uat!9MAjOUcH($g zF`EMwt%DRt)PB{>YM!a9R}Eo65+K=@#97XymeH2O?8L;T(iqK_TC)=f&d7W|M!Yro zUH6|%q&FzG6EN8WdA3mnfO6gncMqppdH$&w@TDt`6KfUje;mphb*{ zkhZFB#ad?D9%#e@SM=(`%jShh0RVe()wKL=48o&Z!6+mP_S+bwRY&_-hH>Fxe-Hmu z-cT@J&YzG@TNp?Jr>)4d607<{Um2TFa2xbsWiZB5z$0dzY6;EiDMoxoS~Dtt1_7px z<|!czO61t!x=OZ9VAHC1w?AYrEV?_n`l_dtZ9R&6|2M8O+*D0-xs$5pKkWKYYIhlb z;+;X#1~E1I8CgW3eAECCxrCsozdrUH_9RnLSq-UaLf|?Z#jXaa7xsS`35b5H-Bu@G zcJNK_=5#0qoX5Lm(wq_g8bR1VtSK-p?|_Fyfkza3`$W->uda2wa@rN9iSp5z($+y>uVLU>=1^(EhZuWwmWg3nG;QLAM!5ZEiGp5=*Ku zsvVRcV1k^Hpt=sir1y{S<%Px0tLMWI!xLcl{g2Zs{HG)*Ed-3=EdvEnY$4y{;TP9JG%Qq_Sl7`XQ+ zsp?_1MT8LAVqA~@PIbu7H;&Ghi zqArh%d(@H~vysN~QI5a>{mh*-%F{(TH48J7hYEo=m_bik;04_2g7bveiAE z2yORD+X1MjGoHuLZtO#}1`KQvck`NvQ7rzbb(j!rANy)8Ky032#0bbzSp`b*nzIn2 zO0K%>CC@Bak{~U65Z?;?ALaTfS5Q3P_cEYwR>4bvx*!9rocv*#cfwu_`2IJhGa@J*jJTLy4BTB(r77B}n zAkK16_TJ=a_`Pjl5HK?i%15Y`*LdnGH&Uq36!1Mp8-1l64>Rmuuku;zP`8&mOe?T9m4dvMve29BtqC`lE9?Cr+W#0-krh zAF9{y{%is(p821C$RmX?Z-}HvCUxBJjb2t`G@#=?Kq+*+NDxMm*Es1$Vw0NP!Ba74 z&F-1wTEvb2sgF7zF4Q{FZWS0G!>2%*NR#e{mFiqrsN&3;5}(*0$giEX6zEHZZYd&K z=PoFxJuc|x7qu+$!C2s17;AbTn! zh{N-buUWJ$irPFHR>}91ZcNHzGPJ~KQ+cU`F;Eh4jV#4*T!~`+k}mU@)*-3rq&F-e zoPeIJ%%QM*+{5dsEeh9_j>B>FLc2gk)oV%oh&)nykE)kRkAPAYzgnx<1Jts=lKQ02zyc9b*+7Eb?tKv-3+UXoat-$usn zYa2M}meC-jI5xg>V5(nDJ?P$%7gnPO&a+Uaw0Mrt8q!>^3-qMAjmnXa=yXfnrV0mj zW!KCq>w(gg7+f{os|^_u$3;b(Mhi%+NTBc;3hampJa&^BL zP_%gTaI3Vzlqyc^A`b}+95*z~=sPK`BF7*pcMZP$jSGZhD6dp$X~@u(k!!62_eQKq zLO}w0*Ss#wro_rsplkO*R1bfbMM5lBtm?RpBP#jId4!t$5QvMON3e-3MJ=@a(XL=; z4};lNrg}MAZffKYv#%E-Ajc#v^)}R}c1`g}uH+%)gLv=#(bhl=2onj)1g+cN(VN|r zEQG#;eYz#DR1>2+(L$$Csl-kx0~VZWkSg?@677a2JB-OHz?CMZztxU?lG)N*RC{(r z-9+w}^1&>=!|L}7(6Daoj_~BY^_#4GOu`p}TzPy;zO!`W!&az*Mg`Jr4&0QfPZ5x) zuI@7XX~(z*i4jK9WkE+lBRVdU9Hsa7ezaxVPblBZwH0vHV$#oK_7|+&dMYh!OtfIu z3`CSSpXz&b=iK>t${hVnJR`aA1(D?>$w+#s+TMmqPgx)hhwf%r&Rf@^bOHUdw1ZmF zu)2&@BendeSdYHBDSm~bC}k&f2T(w{ z(OS!3?QbtJH}0)L%V7)fpsQXYf4YY?hZ6CIU5>MYn?qG3DQPeZ2aNW}l*=w&7@Z5a zBw!^4V!?t^9!_OE^_CR~s_bJSc(LT9da?zEp1@@kEeq|SMnOyk6;f=QTOD+DU+=OI zlm#V`M(alvS&-jyqtIoN#|xGOcPW^6oxqb@Xo)7Zq^#uO#w`N0jDxlds1i~|pcP6h zjwk*TtGP{B1Bp&xY~MyS-nWW~54` zwzA!ZOyxidWJ)~4ph|@VC=Z`--$OK-_gA&O6DZxf=;JNBtSKrl+cK4EY-;P~_V61$ ztVKP{MXNX_i9D!6Fn!c~z4G<2-?)+rP&55uPb<3EhMUf2262(5hMJ!p0e7h(+6m)k zDB)|M9TLi{MfAo`NRl#5Z`O)D*d0>fW~WmUDSJAN5;W>H#R9z`g+ilqqanpvoa@{RQ zM4h|89ol?%t5IZeBV{DpRg2jMn@sTUO-mp*9`&P}DUNLW33Y-bqBcr#j;P+#(W^E} zc1us2GAA~WaP19sM7R=aOl@1}g5B!jZu&$!V?du=^d>0~_T~=UFafTA%F8Oop>b3# zL>rL`jPzkTE^xyqR#8y;VKIbsoeebo0Ay`?7-1<{$!$!0Ys*ln3duL2!+2~~b(dJw zVa)6&&Ws|B4e|@!*?w=^Y_9ulI{9*psD^mllXjbx^n#J6v_V$xU{Qq>h1Rr=A?$-TQQhaYjN(r=?Xf>bPSQ zuV`+%cDnCU(O4o9>U|$OoG3dFRAB+uCrC?GV6bf~yM3nh=383Z&22mcDp3_^S%ad4 zkhEf$_2qrNTZsi9Ay{Vvy$tfJQDu2sLgG1{;*n;yQ4j+0 zt4U4}qzGpT%n&y3D8i;&EUeQ|rpVMgp7nP(>1aSmd5v##!#&yu0fkF^9Liq{Ci9nJ zyEh5>@A>?o6%=nFsbk7Oyrra0`i5_SOcFa7&En{_o2aEYL0O0tw{=qLu-b?`rTV%c zoMPFDKSsA_W%;KK3tBFm)EY2$JrRol2PG(5(Gf1)rC8cd-(KUs0Sx2$3D^fOzaQ=m zs0g0Ldj&W+n9$6&D~bdw1k_PFkaFEq|1zzaJfWp^)G$+=w4h<}NO9XK&HEBU(q|gl zLc1cKHhN95YU)wj6qD)Nd9!aY)lgTzJE*%c%_fi}rGIz?+EV^{>O!uTO*uThcetg9 z4!L$FixOyq+^Y9Q7L6gkKFV~;3~u(-zP?gEApC8@jhZr_)D!QLVsm^+Puot$5C9dg z3?cy63#3U9MjGgTsVNyeoVwA|I%VAq@*E**%56|gHIvSXsyd=7bT{sH!sTCJO%-CS ztiK0gEMh$sHG>*X;_o8}Vr^P#RVq~LJy!o{fv!4gAH;KHe>u|8hdLHg*WS!3Z3LDQ z3frq`L)-Qtn>3Cc7LRK2X5`O@L249nEG=H_0S8H}+Y(Jy+9_pYe`(cMi}`9rtAWe) z&$xNKQu^EV(Kd437pR{K>CT#ZpjTOqAJFt{)=wGfsUc9IDyCLrYz+m*$G1pymJe&V z105FJWd8I>EOT~TpS#k&{sBb@FK?!`UaIBj+Dc&3(+#x$I)+3B)?Ay-Lm#gb`p^aq zsV;-t5jz`mlkl^#2xWUH6Lvg;dtx5n9L=zgSL~2vgn1 z*a@m7NyZ6f-fo&AM6slDFC}Vbn}A4nl4|221>J95y!+JDljWNsP7?aF)GZ{Ukm`PT zkhkgWuBc+-w)D5ry_OOhyOE$uf%LoYS^#K8c`*!8Z=($`vXA~g{Cgq?pC5nzHrAhz znvI!mMb8*=-cpyP0nlAnEX~sNVNTKKP^%MvF0h6C^7>D7{Oc2$3@W@xek?-LzTba` z>g`BJovBMKjLzfCtj4+BPf*>0rdQuXXgNsiK;?4fqg$R`LgCtTNq?hqvM{PK7aOYg zlseOgUS+X^aU@I7HfnNWoHI=w)uNLsUXcb4uBj-%v>($ld{e;^`;EOHro5NJp2ZL} zH?etP5eXU%s`4C_gDd6yKqN-$hjhQC)?$0#vbOd13(-$^ZCa^3(x!D=ZeDqt+>UHY zc(bmGWigK=uEPXD=<4YbstDF-O~R>D*<)q7a;rtO9^pITn&9rXyg6(5-V7s`$NTjo zdqnXn;yL#I$NW_t@Gtt+aCgVhwg?~k@%)=kVXKhVdTa3~`;sTbfiRuOvMA$W+J0nb>#s=O+- z@eEGv{6I?LFAQO_VG#8mpCD9P$9l3I1p&477)+k=);#lV*&YwrxA>(&x_V#iuo;e! zPg2Q5+8neKI7cyv5mB;A8o*V?%qqDnsXryif}?~M_wSqzS#+-gxOQ29FHDmfdtB6m9Ut{;x499LLmMRdRC zAD;I-ACjNJN8~58SN=Y~p1)0E?|()6QItE%QqTGn`3&40v05tPIL6DRn0&_t1LJFb zB;QaTyE1)OkY}PgzyI-I{Kg=E&+ShG-K${#S_*STtU2IBr3WE^DOsuIZU7GY$Bp*2 z)6{fF;xEg!tm4nDum9mUe*Jr#5`XRMUuxE{P#hmvS{YKeZe;d?8)h27Nt`Qa(C!Um zr;V#PX0M)$rF-&c8MC_Om`0{6g_=xsK`*9ShSGqw6I7$kgzq?92~ls+7OQS8!w-a{ zskM{q6nm#tinSFDt4+;{3|B`@trsh7=)UhT;i`h8z+IGL-7ySz9#^Oo{x~eD+oiJg zNO4&u^!`Yc?m7@z6|VSi^k!(vwuemD!X|k_w?g{>GzJz_YXKZVLpGL14Z7!zxumJv zoAF;V)6yG*7`r;!(6;`(G(eNOc#kiy;Y<(f=EN}~I9u8S|BXXT4P#-$5QE{qQUtro_)r=yoV1f4=xr6^4oiGe3UiLqGk{FE0(^oyt}o!o%rEpzWT3 z_Ab>TF|b^JkkHloZ46ye(Utnns){khiAdvDvE>Oux_dE@m^br45et?b?GUab+`&9_k|?7=M?Bj4KkzO{(db;X|QYyBM~ z_O>l#a<_@%Wef>?Ny6PM{Ai2TTmEE8MtSxRCnvQg%da0KQD|!RtN>BUKn#3l3Y*6Q zy=V3|UVJ?N^!ms97X=AZW%n+n{#Z^|o@-6VUPqv_jq|`xB^w%X-Uy`fiD(6)p!XZx zE#quqCxi?X>k5_J_s{2ROTCq^|Go^*RUkt+9gUigE;-Ec4w5EvUftorc(^4=MlpXB zu?uP#ZjtRbm}5LmGKx48<}SGQmVRC`CB)WndpGQ1x?{Bdmtg^BWc9UDzmE}9T*uLw?VVCkmW9yH%*i+vYm z--enZX#_pI8FaTX8UKkAN!!m47F$_9fn!cxKJj<;aAJ|PCy*~yFv9wPqL`12(Z!+G zOdZM(r2wih*j>__nA44d`l`l%@yCay3&s7TDK@uVd1bB)?WdYCO^?scI}c@g+IV-S zc0-jSe-?H}xZ``L^~{*f)J@k1)EPf1Z>(Q37Se9ZslnS69!LJH?^{ZRcc_^E<58&P zN9S6O}|?2Zyt(^>_5TK$&0rkhEj%~2Me#p=q~;iCR}@H>HdfD8hb z3?gkh0_rUlD8=|v8B3caedMg3^c1kT*NQ5}*Ky>QX=ahm<@YfXffmXObwUd&nI2h| zpuUMoClh+}-gSVA^`syrao+9X`qu-~mPHHUp1vir;REYAV0T7LNdkG8?_)l(3uUCW z6ITDXaSCA^UEvvO2L3)~YlilvYwO|2q3+c{#vTUr`8Mpf_E6}e@sV4;MPDF^# zHtpv!A4lckQqm76E829=+l5j1lV;Q%CB|;e($81OKT=KyBlg=Uv}EZL;jiY(nfZ4z z{2lLiXf=|D?=x;FaSZ2d_ks-zmE?o8s1AP_yrtubKt5mdpw?QzfX&Sp(mD)e4)%6?_znl?Y)FbbdFUK-z*Rr(}TLVEi2^_3AVJSL5|941skx_ z(4wLyqJpUEan)ji&(JFpMP;yyLb+NyT6GP6q$mw2Ds={J-^VR#tnzDhW=URrKm>&f zo$wDpO@(A!k-D6!7B&}dWE&Y2Rgd-1<|^wiW4SHC>WrAxu+Rb>O}Zytc)%eeH~u}0 zkBDa^8H!WiG3Ri(()VuyG7F1HgldV0RZHZ$N5g(k+e9hN*$5}vNCbDj?DyhgFOJ0B zp4-9%Nl#O(p2{3e(#l9A)#)8un#9vS<83cjhK2CAbTFBzyUecfzyIayYkg3)h)2!m zxDO;c*6@a4EfqfE^I!e|a#rW(qroVwye|Q?tn3%rHhIx=$LW%F0=DasD z5MxaEn`+wD#5ls{R-*99qxBif3{ElBpGHNj>HS^FEpdj-IxVW!jWy{v$>yQKL*#h} z_^|jKBPSCXR&OzR&_?&lh=ntK1H8hf%~@&-yyps*+}a0`?PXt=NmS8 zyrO?bHpOBYl8~~NSml?Sl3&L03@tf1@nwE~VySgk4Pt>CAf89oUAD9n=(z>2df54L zEBl9-Cd^xdwJTITs4_7iOwkEDASyy%p9j=X7apQmEWzo#{E9w|$RsIl$_)e--0p3N zr=f{GEabeR!l^3Njp?cGqdPJ(hKst*&g4e`Bt zcsAma&r&1pg8haQ{P(uh7+T=9p{(|fsAzA2nE!!XnXggVht!Cx zkDi1!9H-dJZ#z*;3Cp8%`rAuZt-Bh9b06_B-ami6kZDq>2VdE<^FaGNkODTU0G@F0 z0K5b!EItwEF@+&Dlwt0mlwY5TuOh*IoCp8=oB03Zum8&}IX9njZgpeyr(s?J8R$pL zPOxD)H4JL1#a-RlrsbymU&l0)0_5I_-dsgi?$EPo%37x7WMc!}vrb-zE}}M=^Q1c6 zMxNWSc7>;6ob@Su*Qy4&FU~;S#)A*wP{V3a(<13@VjGH7GfW$}DXi<1H+RJ-#_f#m z`pV64l`Kvmlf|@gmG7e&d(l>Pcl+2zZat`FMCw|K!m3}L0`G04Kjt8#SQ+d?wP%p^ zOqgD(`Kib(_;+LxLk7tzYZ~f+<(_onn@OlRvp;<(IfV!tIjmzMN=SU<)S8EKlncv3 z&LQEQq-DwWOcb>rJ?q_s!t{~vm!)~ds%mqnwy~czeW(;wyhD}#;6~Qh14j;s>Cqy> z!wZUGYF?{(sD^>9L2n9GZFpejlbFEZrN&3Is=JYq_wlZWzTKfB#=sO%ZM!{aArAktf03ZZf*UowqY3Ltcaw!sg zetq0YC2*MRd+&Kl#^htE(OuVpQO)Sz-0?2dlSH9A`7_N*pa_Zum8!X4#;$QM8il>o zWrvJ;FV6ilZy?KdFNdGMEW&^<+zs0!DOT00DyP#H+G^t94A)mWydV7$ylSR_!7`?a zg$?K4PYlAmjrOc5Z4h7H$FZ6@E;MeF1Y|SN+CDf^y|*K8rBY+-DUsSi3?=Mw^6Af) zQc$kb5J7x_*8Rha)XpN16wad5+#n!??tKv>T3p3b=Hu_eO zD=p(Q@0?8C9D+R`a$GKUG0w5)?pIAc;4V4*)A5!US@En-tG-CBrSwjN@YN+7JlDal=d&<~q zN~?jld{bEOg#nW$d8nFpq-s-!*$IRQR3i|A#N4M^eYFmImX5O%DYGb*M4zd&jKOx0 zl{^~KTAS~Q;Ty7+EOCAznLCi)QAdZic?e95W$_*eiYq#(SaA#qDq%wFvW}i)Jz13k zYeRqXW<|sGhJ|tEouV}oak8})tJE&{?Z3O#ETgbrRxe(r1N14?JWfQWj~MbVh|B-Uq2dlrK<7Ni^~!r#ljq)9A|y*P}`wko{!-agWO^-HA9h@ z#_jEv&LEI#{9V<~ejieU-}=3}3zKB-NLhmBvg{Y`_mW;Hc*RKE-KyU43y>$*lLr#xsx{Iu^yBV#!sz z=n5k!9@AaQmf;JN!ev!0U6X5J>K$dK7*j=AU5Yi?{4v0;FWdE~f*;5nTIl6ii2Ydg zeuD%-L|ExY8YnLX zAhf7%@^6iUyVb!VKFc1zXHUBjm{qVs8jbSf&Mt;p>2|0!b);1!uzRr~Scz@JKtyoo ztR^vSC`NbvnQmgMAk$qpM}8WO)vR{K-0sI{y%{#wv$ml-9}cTMfA$7*hFxvY43%w9Px?s&C2|6IpXf$z9LQ2d zMsx8{wfXQE( zq9!cv!tZi+?%~KGckoH{>s##B9L*fHSf5Cu?BC8OT!X1=oC6w2J7m1`fe*1^lD1%t zV+Kp#2A8}&NbcK?n!iLTi{nT90uBa&?9U#&4^DQp!8b6EuG6h=-AVclMKm)K)52!a z>1d0SRhkl0e}L<_pY@RdH%*k+Wz5-=m5;?npXNEn3kvn0E;1{>N5cF{7%8x*d+=M7 zT=(s9B$%Id5>~R<&;E9OcO5CLCs7V~HkT+#=Sbw=yFomk`s7J)q50S!uWnsnuTyv* zHDMDtN92c|n}_~BH^KeqBgn2L)ly5)n{^-eimy;cDFK7CEtFC|^4wj|?LRO6S+g?g z#%U;uosF-dxQ#lLrdoBtGro_@_c0^M7Hp*kNM`=4I_nyF&VlS^uGdna`y;Y9^FANHL;D2&B19YSUim8pc5ZoY#@s9>RGNT_U zSv4{{!#C6O75sBnI4R$>D`Q(+;O2=de^{ySe>!oA>CrwN4OPyE+~4Z=Pj<88e%AF5 z2RlYR%Ejl$X}5k}h#PQfm5GKcEoZA;I>pK9qn>;u%XRM<(?+HfdmJ%0DT#KD*X_-Bq44OTy;7;WxHO zla8pUpS&;h`@)P^mnK1>^KS=o9(klbp)7A<4b3xD1`3>o`sHR!cYS8gj|piDU2CSFLx4G zK#Kfi8@n|WpA=mBwKV27nhDMw2PTYQDf`_rRnhK{dyKq)AA+NX+&F3`Y_fQDpf5U| z=?YO44dLVVek_QrU{f)(nK%72B?TTCqhDQcFTQ>H`qZp#gr5BU%ER~&Q~!||5UfX4 zp6sWua($gBt}Ey9m^ z<8e__1t!UNBK+qT+Hs2ELU|${j4^-cj0Nv(phRLvq`m#^kQ0K1IfBQ`q$dOX>|PKv z%q(OewiD|Ob34uyK)`#l-7s&iNo+y?&T7u72C1ZjB3IPovgNBk#FgF7 zn%;}^uECp+`-})lsJBvrYyJk`5ZBJ(;W}oFwYLhk7~gv3KVunSHb zH^uPXFBZ-049?k8=`Dv;n}l+n<%2yweL%a{zW4(|}M%<_BS| zRObzdpqON4P~oGgf~JY_S;pededRhf6VCH>n@F^zH%p!ycDYKveax_h$M0{&(9Y4I zl*5r|CbCVWEMtG~pryw`=v`n&!lh>o1ojq0N4udWG^2Z6nRSicLzSDICtUgo z9U0oRY<%ckBqkaLR{T*`2ApQfc2Tsu81pTlAVc1t=X&Rm8R zFd45`>+u%LB%}VY&1-JtAT#fJ#TzoYb!?aD^ybs#CJn1~Q)b>r*~r+F;oMtgHXll) z?M23_qej_!ocs}}TgAoa_@Co)=t@|;f3k_K7zqG-O&`zq%IV2k#z{IF&$p3c!ym%c zb>L?5y`{bj@Z(aK$CzZ;g*RYgzn=QudDP$4N8mBEw#GzVkNkr|!UyCU2+yx?3w3jJ z?_@_^fXa>g-R9Dc`uW||1DHT2@`ej7kvOrGTxPeqq6=)HLjP(kRVCW+_c>~hhM9K+8(O82HV{cqNsHPdCL&dc9DG?}CdBq6M{{<{ftEs#-7vh>%3WXjXVY4 ztRBIB2;()(6Vlmif1*ADp}6M;6qqjYZ__vf0C|DuIG&e3u8M`HCMkk0xGUZs(e>2j zD)aNQSz62&MslC({NyTSzd*FxGcvlPhsA{PXY^c5+>AsB@Js;!AqP-28lg@uhWSV% z(Xql5^N);!Fpo%n*+?lc1nP)DaDQezkVrW0sAn34r5X4MC%OW%H?)TR?ku&%mVvrE za$^?uq}>?wUQ^jYV2&Q|%*+cVJ`%Nl)Z&T@2lI`D)JjV4lZZwriy*ts% zzc&-#+vF;t9>TcT&I75Lnl(q+lpIuhnio9v9*;kdWeb;+r12Bc_aYB-xDkzbe`r_L z0Z*VWPh$t7UoLWDn}64Xp6S4KUGG+Jx?=DG2T3d*YRV5r#VAhd&VEd41@j zha!~5^xt>GlaN8k&M1+eMBqNZhs$WGG4v^Vq|}yvd>UW`#CZZ-mW>x66%$cha)2N# zbTN&UoN``ZN!(BWHvM@w4ISlfpv;<%W;rumD%zh}h-DQQc1Kb4jX;e$SdkUINYtt9 zhsdXU`bt!@x^UxO=W5fJD9_{QN^oIo{l`GomxwMUef!5-+5X$F z(T`qk@0~el&Y9_tMg|P}xA&&cc%$*xK+${rA%fbDZywUz#q(dD#6LTUWxzOn_(D+H z3@9D)_m;W+sn_MUvUYuU|BR-n_bLD0eGRgVrqttF+`pHv$h6koe49sF8HT-EnUNv9 zAVO-SxR!{-bkU%w{Hs!SfDC@PDZYnoxRE5a^lequ2T?~r^eKrWBo7*+Sg_!7a#y}x z02R?T2-(31Hks_L|MdU-x4-?n|MDOH_22&UfBH}V;cx%_KmXUCfBPT* z^IM{`-IPulV`5MH#>ShxhgW_lx9W2CF|RU+kOg%3Tpdm1ky6 zJ=b1k$G0$to6CR|s9P-NNtvBe=i^W9g7RFh{o|(EOu-A)(;X~D-Px=xH!<%tm#1_6 z+AOolI-wD0J4XH`a(d_VpA6>M8O#|@i>u79hwRN1EJD{N-CfZl;NInAjIS#S>JF{} zAY6VEiAVrREIV8%9Y;a{8IGNgjsh(It6yFQyj9IYtZX)9o@NW>iN5p1V&?Gh6>6T; zErcGQ!0Q)uElK$`$VIVpH5BFF04DF3!s6Hg0zOT&Kcsx#O6eE&GQ_J z;M8A%c5|E$vZ#d$JqV*2v8oVxvv4B+aX`g*(Qc0!kN2rlI3l6ws;ePTHaq%P(<*$e5UN~mj zr{ClK=dpEn&#-iszZ^d&W(LbQ2=(vII(NuN&>7rtu7=E6pj>t3tytdNPc2{Tj{Bno zpCiMBo${50I`i^I>Hue?hAnGt=ln-y%9CLr=&?R@BM?E&@bDy`}h_p&}(@HB7-kdzp;HRqx^cKI8$KA9lbEkuEbXN_)+ee63w; zo>okee(n#^qmcT2+lwKoI*(`Y8WcSx8lX|0cv7Dhk;2+OC!@~WEY_R;-tFYX9-J_v z^;e`eN?j4U54P6%fEl@=MRVaq3=5g-j&k^jJja(@c#-fgSTRqdlk+F!yZ}s4z6h{ zbKS%KIb=xTx{KKJ`erO}-PYj}o)JrtKAvrx>MSZ(LGKERX&D)~?k^uQ!q7jDY zedM;#&zjS}inJE?=1_5}FFawZd5>$CyyZLQoxBawBkPGMQ%}@};L zL~FE|K0nd4XIW&ZY-V>ecQ(h7A@mp$Yx}mS?fd6=!tW{Mg^K;}uxPDvbErH1P zk!kE6Bb>6Xy&+v`hF-13XWP+&>Giqk-u7^<8_T9u!t;@1GH8I9B z#-W||^?$y{i|Je=_b8%p!vQ{a3J+Y!Yo}nK$U^3cOGg_<87It@M(J6Zr($)5QO=R| zV_s|g=Ks)3&bSWqoEgH-oY`k2kjbRAq;(F>%pc=2zoPV?neo>k5^rl14+7mVES*;{ z+}OLn4Y$tQh=rbPAZj<*5=(eoPxQ1}X9e5?cPV+iYyif})0N_zbEN(GPPs~~^U2>v+h zuP@BII5=>lgV^P+4prV8j8oy^ooKL72!kR4z~_!MQHy7s1VdQu}SluIiS z_oQd#{=Ouz&pby%-g1-EwX&Oc>qRJ7)~|eu@ni|A_j}LH&(4JAw!c41XoBdI0*F42 zrTJ<<-!FY$2#PkRvV;0$4_IaBs*&~WeP}dxp43Y8WkqdeGpgcUl9OPcQZJGBV(&A7 zx$pU*v}@}1bTptG%t-86#_Vn?#&bUf6Fdnb(@2~a9QW;*WEec_Qu7EK4uOPhyJ*pi z%8Z75w0{WWQGsqB7HmC^Ka+K5TPY{GH;zru@>1@rx8wFrF&ujZS2_2GJR(t@{rUYv zAjneqE^IjHOm9^^(c7sX7Pb$X$EFhH{b2Q7)l;~l){z~d-0y$ybA>yJ|M4->1nOtH zxVPt<4qEc@C=7-V*G$!m{-aVTxBGeDzPw!fn(Klw6WF{gi@~8~x} z+aH;v)FW9ZZv+>&Bw5BKYrPNPvGwlR?3$S~*`da|5fOP8usQ^{xsbJAQt&fJS{F#B z=R}zJE`oXY?+*n8T>+Z9{+~}8F6K6>0-xLO>)!vlN%TchD!t-hG|?xHd&$G?e3t_a<ioo0F!Q3$ca z`Q^;~^;`~6c-Jso^{d&bzn=VmxLRz#+W^b=Y85DmUtVN^gzgU4;TkQZeCKm_6bA40 zd2f^L1;|TOzV8l|Px4m$#lZEqOP7?v5dAFuV8{Nk595fh@__5$zx`G@Hj$A2d?SRY5%#6JZN@Bl$*5ZI-KtfkV2b+AoWF&@0~R?!#c;f=9YRajUtU7q8IaN z`{?Jsa88V|P^Kv80&1?EJXa$r z)%{0C)XWq9;UyVHg!^7tdjzX1LRhj}-wtj2yWXD7KgAH&C`>THku{(Bproy40__4B zf(autb*5O|^5D5C+bP-gW~XIeuu{SUsayI?E%_mE_6@BBhLeQtny73Ubng=Vh+z^8 z*}q)ftt%`23vh8~@|l+i-`q2Cy4L&e-z2sv$=9f4&zB~Siy;;67eegsHAL)r%SZWJ zC|H_P1IncbK=lp{h9+o(cM_5W5+>}DFAwQ7SPvg=0-Y>2hz&1nJGY2us)MGo=VcuA zf{av29^|++32-5=)Ir(+ytMA}^$@@)M=XZbcz+*^9Ang6-UsQoBj2;OYUCD& zHl}z}3EK3za4L?2)%r?O!n>J1<(}C`!@b5cEyLlxlgTH&;TRRm24-loQ!7^Uaf)l7y~K;w|qPv-a-$WHlLK8`MrS76TlB(!oc-Az=JH38iQI*oMXYMU!zBpYF0npULAFue)@@MrZ+tZll2{HeDMn>)rKoX2+s;QsJ z?mGB4^5|#gKF{auQe3|s=a*IL55*W`xn9BJr{mZsiwf+$hKXehd1krXkKCVC4cqg1 zj;Cz%>ayz_IQXE78vtr|h@CL{rb)i*MmMuzJUG!+6}jE0a_0cJhb(#jZ^cd#H$=Mqg1W1no6v<{#@34jX^UX!|$m)9Hp3DoENky z0>`#=)`_A<+r0kJnEWJ7VrigbEw*h@`NNw`U@izmh#$?6%)t{>61Gg&*_nwIkxrIpKZi~9?qRV4LJY7@5uG+zC_(Cv-O}gkTZWd}aXIn1-%p}N(Fo#QaH~lmC=`6us8gYyF!Vuf2^L?+$bI~- zh#gio;NU;KPbaAx4^0gI;Q#WTITZT+`(LwNhofS>R|2zkSxEeQPKZoz2g+=MWgGBt zGXy4^ONLyn6L)4bE+TTc99H#Ijz-^gjJwc7R=nwjk{>P?<&TfMR3pFLJ3vF##J#WB zjLL%Ic6qK<`U5u^&ecu~ldiA(B6>J-5I5N(RJY5;oSO~65ikRySXpV~YM*YjNWzAN z+6eUG?IioK3#mIfAM<0`I*osR|MCoR56j$!%|u|R!f51!wpFuFlVl!#doG?>PQSOW zC?U7+x|rV4UV`%%NbYSYethsX9?p>)hBOlyq3>h&qZ{E`FLm}^AFAu@d@^c2V2Uhh zL<@umh&L3Qr3uWylc=ZKCjypz?eWSiQD~(Z&U7*bpeTP_QkOB$l?l}Hj#;qu8Gy3p zDq#<>y-6bH^2j-=Y-Nv2;iQeFnx%xtNJ>7$HhunwfeGT^kYKheJ!&PV2e_8-M(h62 zXRC{CV!G69H~zxYMotYQ&f@kl)`$>s8*h33h#zOfr6=PXsfrL7TGqKG5rAGVtIgAr zkV$_cg*#O0rAu!J;pST56Hz1ghLACOE($y%35$v1UP@?S%%e5^`1SuVbs?d=J6pv% z^&=vTv(afuS86E3H7bpBTBn#(SRyVdpHknppmYr0;!nu;avx`g0(sy{3**z&xe9HT z6~iz_wQQ5I5`N35E9(wTVge5Fi=>kxV+oNeM}Pnj9kscX90?G+BZQGea48<#SxVUohd< zRFP+&>w+oKd}H$ZFg5MUhV1S%n#H{h#rP zhbuwjXx-LIfLlU=jm!vnv0)=c5}A+xGVW&f_{^rYt2)M+97yBxUY!p;u1()wTrjFu zVzz95%W@@`xuUbzL#C$4YmO3iYbd%KNZeXsWTQ!;*!x3>=(od2ouDt9hxILVGDEH7 zs=_jYF&l5yhgn7o9P0JN_Nq}+m5)aoxU;{GYmiO8*_S@;laAMh@d9R5{Iko$Zu-0U z#rkBXy`ScnFZzd_`?hu;-|dsvw!ScNf_O?s81d4Y8rYtm+yW2DO?+F%{Qn|{xdoo) z`qkeLu^HI%nUKAV9MyYN{)BwZLu>bV-s7IJQ^Eiq8J6>A5_v3zBec|Vt@4rMi;HFk z`8di0+`BW&K1N{NOV-_*7!)fj89_QxmC)zb#|Cy}5ctG;;5d5CNVc?q@Z45DWR8@% zp~k)RlF#TMtu6R3$#XJtC$5+pjX}}%Fluz8e?BqSD&Q(o5#|a#SMiuozsNbpXMboE zk?gqZu(Z&_NNGX3zD^uQ9$aUrVUcQ7Xds84tG^5OT|~aq%`V$+0h3y|p$2;gGkPY>SM_u`Dm(^r~BoVSwD{Q01=V{SeQeVKLavg3^ z6VN&>Sx`fSO~@P zQ?6XURHmDkfw4!ng$qEBPc)K2N|%y%hbh|Ko4%K<)ps-f>ycc{8&TZ;;gI!QUpF?D zaS!)BEXIN$9Kp5CD>n5WZP;e80vmpfaq^cXQ?Wp4$@tKdOoi`f&rbrFCK{&9xCW?eg67#UIL^5qW?&U&#&9oSI_bgw;Fb3`~p+j^q8Z9VXBF zExgOvjWZGH=`--bamE{e<&yErkScTJpk}G7BTa0S4?WUr zRO}W%E!q1JMf`U(7JYOfv5S5%LrGL^V%mma{1ayP6JMSt*imfio2}9jVPv~{@(YiO znMsYVDHr75zuoSJTA}vq^ZIUYLTs8b*d+>`=l)@X68h|yqO+v+uW&gTW4;#^#+JP~ zoS|(aC)p9H%F#R~KhLAGZFegByd_Yn#(PsH>BZ&gAa^$+l3$MF)HWYBkrU4o8QHpP zX9x_Jc+RWPl;%enRcdCcEMkwIWfg*L(v4TU8Sqi}R&CX!XV-c&tr=~xDm(JvJ<+2f zm^(dIJqA02d&C|cp2~`e-&14vTC?0RmHo@H_uN|}l~{s}d-yR3S!Z;NZc?!I*RcV9wKp(aUsV3Fn*6xpUrV zbSSx7e5uw2y}z8=3Kk7hJw?C(9%0UKj~pGbp=*Gi97Ey1q8nyjYN~P7?iiu!a$-^$ zaaty{;E$?7qSteC2bx-z3)@B;SklIc&oX=x>MXHetr`=)&%2w+BnSfaU;*&Qr1gg! zv}Wz0&t0J~*pJ@d+AVV{bL}RVR_ceD1W2)LZJ%s>Rey~?mU9zkF?V`|T+k>NuxBGK zZ$WIB!C_j=q+`wV;TO*p2jTbf$hQ5Y`>iLl)a=dA6ZwB!>J{pg@GN$qq@#+gKly%W zU_B(#h}*CtaM`UP|4TxRgD!iMjKO_9BdYNam&v9T53{(gxdD(`)ConkwoZ<2ICVvV z^)@CMS?^LpUT($!$xr_Zv?m-=ruJ^om#L%}n3`Et#sB^9ELHExZ}-~=y>Lbf+?-UD zOtE5^^yd5GQ?6~f4Ft-6>Y7rM2leB& zX^`YPmD1z#u5*-A6`5Ba>C}kqO+BiT9fa$@9?{Nuf=Y`+5zDAgg*>^1vm*a>V_an@ zJSVf4U?a=+3wn@tdUZGl8)x6_M5b*;gDwOUTNfLCR(z-{Ea?V8j%WkHJ30$vGB|xU ze>>2YA)26;Wu%V;G@M~F$TJdB@a~^C_s#5#V|XkD%fRY+g$Qnbz-E9bHK@zAk6An2 z9gQ#?ru>sY*f~vle9mPn>El_VN@Y~60rA3F1QZ(F#C5-9LRgERuJcQvz-+i`W|pGq zySzuP{#-E#w)n}vx3pb}?0)~{{A(IJn0v^KDk(49xIudlsUC2Tc)B$dx&(Qoe}6sZ z?S(*9((#J2Zd$gV1cPekPp0K_7nAHuGwH0|r>W~Ym3a;;o@8fD@$uN2@%v*5 zaOqsXe#z9ohVkMeRJ^7MnNuXw(zN6E!eB~>gJNtLVvK)EjJqlX$!5pCrT^GHD*tO? zy1m7kwk{2`0IFtYjY#i){hDPE7+~e$RAi;eEJ}kBI*xrFx{JBQ>dJconH2F+_i^Gf3nhZ-Zx^UClq#LACfzI zRENOIdSW^GggUOZUpVkak|U~Z(rI- z>PpE*)$R-HmlwDXd=7NO`0JtI?%g$hKWL`qT{x(;`@;C;1xAv24qBYdRM_r*>MngkSTO9M8IpPXFPbM2!-KHy6 z0S&4q0k#Ie1;mzw_GB7Z6gCOz?TiDve^Jw}jAbCST=VEcqFB76XFG9uf2)xlvAss$ zER%V%B6|XT?=hcVo<6{S*=u3a>sJTi`Fi@yc-|i5W?bW;x&Ke88a-VLJm+y8tCsJoSzns=Ero1P_D zi1WdxO{oNL_1gqKcVf^B&t$^4`Q{Sek@a{l9mkuneIm(#;irE7`dwJ3o*=oJz;=H1 zv(AlkCH+eDu|+VqL5x{D^l$zBg&rOJ_2ZErADBowArMo($M?d(L z)a*f-#)2JbYfEaobHrY5NDl?J!AE(Ef)!xmZP4gs zZNsp=Dh_BzylSQc3y{zl-ARU!hpti2^4`(@T$WasIPc^7KqVKRZqZgXW9)G=V723Y zYh-0Yr&rZTo@jUzbh~>bw^Vb+qK|Xel}@+PZFX#nig6EHTPpU-r90P(mUvW>!SS8B zs&RHRttyy|?zE%J-(+gxF^R<72#=-gCFiF;RB+FChy?3?6w~2avg&8HkDt5ARxM(; z28esb;nC;wf*A2m%)Rp~CPH5~?4Fxh(-l-~D@MJK=r*Q3Su>2u!Fv0L`*jalB$R0@ zuy-}NPyzNoKdv?fPTS^It45rY_4WgQadhwfG6c9`$H*9*?_rs#sdxbKG^)toII?aY zsOp4W-zq6xVL~RAj*-ya0N7ZgIST$gZaa?pN=<4-r3tim6e( zUKvufU@*f^KIb}v4q4aKJS!qnrY!??ym)KX^X!Jl?3LM9bONCfbHXUefLaEo2Of(AyArDNNps%khAiA zxRR2mx@cW)(J{Av7Kc0_=r{XhT^ql>9=o`>-kTt}B93ZSi1;={(t`8y*WaTfk9PY! zCBvH$N6vYzAFg2uzKQ+<>@mGYt;NBltk|Z$d$C2s@)_dRswvR75qahsM%3LU#R)e7 zPw}6wfD+PBw&kWx^TGh6d__I^q?aym_$NCoY(mGZvcjibd1r(g2fk=C5h z-?-^>UVMd%q>VsJH}$NWA-_~aQQ=or4&F;3V>W^ zk1u+EN``r;J(Y96XZd6l1qVr->r>6X%X-O&w`Tu`7XTeJQ-?BJZ-&;}d&XUsfDdk- z^Rd(z`@-GtD%3KZ_Qz-rlHi*to*_XyUE;0q%1OHg#5AY6svB{rpOYrx2`HC4gN!yv~-3*luO=xgo)W%{toy zt8T?Dzv3|-CpQYZs?|jcJFGf3Dm!=J+F;IF{K!~@35~L+-jOos>q^9#m26dcDk1HL zL%lbVE{Hd7_mm^~TT;|EDyNK(cAFH?`eWKULW}(K3u?FV>}2pNFjV_Pbp zzPD~oiLlw5>Gr$GwvJbj{VMqQyA3}(?v!0*bD9(PhgunsxDH~Ad5sY`)@ckAq!R$| zs3I{DDDgA6^28pJN@phpi2283GKYdBUCfxq`1JKJ*Fn#?Oaq`zBw<~z&t`(*W{zU8 z2cO|^_HtxW#+flC(S@jeLbMXY+;1vrMplxYvk`jb8K1O3bM=x<-fE)jUmZTnR51UC z>4$$ku39n*{O=G)s)H##b&b}ZI?dA2p{KC2txnr$RE+eZm^zU?m|0HG=_xu0{VA4X zLCI*kMx&DOlV|cs{ET4jAC7rzJDx>Puj{_K7Je2@9ZgIeQ`kMGn7)|qNu(OL>APP% zJNeG2=X7YZ;vGX&{@3FzpQTY7|3MCj2RjF};*698;(KmO?AL!d4_6~n&*M76)1qOF zA+|`^OiEQCFw-A)Vc2n#8Y&^KzaJ|rujVOf-DlwrHzjFIhx9DgGQ1L)qGH{OpaF%J zA>QU2)|!~YTGJbtlKCW}4%*P8>7R#ARjcfA0tJ_t*Hr+sRB}GbA4voLmWvs%+Kpq- z8m>cjZBnHY^~f~&_v2+sYUV<)L_O;FsKgem#U}6oI%FFtsjyf}E{{C!mwg6gd`4`2 z=mjom@iklYUqr5E`yiP$X43PRB5 zDzmNR3$+@ty1P^s`=anUgBho_nngUY$G*On>zny;)?-qrwN6WY(DaqE#FDciAUOWW zkVh!j(?E`w6;OL_1@z0|lh}AYNZQtfa-Q*ZUldT_ms% z=0p(J;p*#q1P9T4UI3Hn3MPK3?mlhP6<}<7OcRZl8|9hvhFS~=*cvX3SaovU?982! z(&oo_pyUl;6ik$9@S*X-)N3R*{ zdW)4GM_u1njfu|M_&*rsOJUO6$8YX!w66htkpkeK2AzCX=G^atr?}vKK{*O3lWgEm z>ahpMfCR8l^MOKGH_jZfpNWoOo!z^zYTT}`LcjM|`M$~ACSVo68!XhA>Ce>Mu8jrY zKI_LPAc|=H?utPvmpcl>us^Zhlw^`^W>fJ|FF2Y;iCr_&9y(^MtTNT@b)496mQ|Ex zcVPqUIi?wmEf|AwZ;*R-sEb5z`CCDxH#6rVuUMUa)AGZ7WN=y577gXo!<-(gg1C&w9pz`g@Eln?FOKRE16GeENUZB!pRs zF4;K5vEOP#CC?_Z?b=SFdB}~|pG2o>p1iJA!W^H8GN3m)`x=|Ka?Gz|<~6R<(A!QL zm*X}nhqd3s`h5u5CCdjDo`>yxA0x+0uzS3c-UYOM5`tOsrC88_OGjIICuvi1(S!Ad z#CQzFbKM>@ws#bVjBOWv(OcfZWt*5`v1OEf|GjHO&n?*1>!xGtNj5qqIpjNzVrU*M zQ~LF2+w~H>pLtWxPh8hrz@YfgIL#(sjSYH8&8)PgA4>0Kc72=BF|G6ugcWD{;WzuY z#nJJ|4Zow{9q6!>b*_Jy1h>Z!+XIYOa_tkCjZneyZcDpQ{I5{W4qkN${7fTjpqeM4 zzYdl==M`IZ*u9|)8E+1x_x*1oGUl~AT_Z{phmDDqD=l%4mttXNdQI(hPA0?0!8DGN z`#P7bsQl_TBRua9ubQh^F+%44@mP;ApBSM@o5;-Rn3wNu=MOSoK9+-;2>IBw49c*! z6@ytdqUMuS>i6F}ylgH0gCF+r^aVK_7pDnGdWW5HQa?q^$$@(vC;SN);3k6N5K>6I zs_wd=__%b2k7+IVpO8aROeBSifwe@%>vQz?4_Jyz@-oP$27lh6#r*XZL5~q0-80Jg z$K%+y_eTlC&uf>X{azxKga9r`>8jRlc8h4pC)OnaNPwb=-Xj|56RotB{w2rib0fwO zoCG#CsY$MDm;LauP+i|~Rn??=KYq?03$I)Q*?sHRuWlUCx;YEi;9BeN=ByGjYE`gq z&H{#E0APx1SJFCN?rSjN`T!xk?NG$sMz<(f#(WB#g-$IgM5yC_7;X@E>;dZ;v$Zup z&YaC{2v_}^Hp;}005=^=kN6#K8AWoxBUXv&eQGAtTwgR`PBxM48FfuD? zKo~FPgu^&n$Zv;Nz|avL9(ro$Bst}^?FjHIYIQa+ZtXm3%FHW7#ds`-9HVZq>44Cl z`!lG`RP^ya#z)S@V$hRTGKZd}EU9cqEQGu445Gu+@9i4QzwO!%-NJZ>s^7{}OS_K* zUS!cRCTd83*c#~pg4@q4{l0R8cgl*3aDe2laTa1q+>*YcpF73hGBTgu>e=Sidt>9; zgt}0sd|k#-Xx|D3Y;lTHUQ+8=Jgy4&c4=g?E{Cb{D0mlVh#&iEC5||ev;21nD!%(A zX(unV`$7Q!$v&(Cs$amrKf%kIdTkzMGK|pfj~D@*MgfEAd+Rqg1Hj~NQGgP?CQet; zEM1_62wTQx_S|=aJED>p@7Y0_Ak<-V~q zgn1Sq2@n?0F{lrOp};0N)dD{0*F}3vd*CYne~ot`D3_OU<0|iNBEON^xA?3&{wUUl zgQ0{Jh_|Z*y=%HrP)2^I%(ZXAOFjJ`wO1-2M4_aSuX@d#vRuF@}k*2!!iv8yr8bjIGH z^}S`@9}wP;(4k1cyQ)?AIAJTA$*5 z@81yr{q==LL5CiomvsHOuz6{_^x68Dn(A+6gF~neqfr*Z`y!eGMDF$L^FHn&Xh?Y% z6^1324tX#Kh|~k-u~YDuaC}`=13_v2+DCGDVqBoLkx=8(=Lu_i>pl7O1jj`}5W29c zQK-uxP|s7=9d$7S*Jt!vHyGBgZ|6|WBDtDje(WFo9atr(LzbT`OQp(=Y}dJ#iNz=0 zS&6`zE$xc3+9RO3Je7OoZ+E(~WL=W_y?ui+n;~_uD|XrU29sbX1knd3A*VSmrDYSeA@_%_=GAms7G$Q))}J!%EVV2 zR~E{x8j;L{D2A;eT!428SW@KEK;WRr^A+wW$%iV0^;n?yD?C~|%?03-VB(XQh3 zBgrzXga?1m*hz%P5ChUbVmOTxf*R-X_%zYTEj}PVDfvi<<=~4y^TXn2f)fvKrnCyA zTYea|cJvUjW@l9nRj6Ke!0L%9Um#23U?VhEJ3vj5n{;qXWlidusphSJOv&{++T)D?+-m36xUpwO{KNnWXHW_rqot_P!#-EZh!tMW@ymBkBU%N(Z~nuN*n$VwNV-i+_Lig&Xn$Not|o7))laEUQ({?r+j=aFKFT3Gmo?Pv4YD_k-eRXW@@1CjJE%~l_hQ?DqxerS7>6g|_Qs~uYfA@)c!5tFqpjrj1F=9JU7XGH)PyH`dwkhtMEL&6#|{ zvV1p%lM8VdXxKWpkPN4@T;OnJu}PTW1qDak`YzwDhwk(hiv7<0_&z)1hKTKJ8NS+u zqEHwl+8w87y;~+x5^ey0ISe<7DpERjea@Bp>p!-z6uDff6W(%<8%&E$BOVbY96O#B zz1vQH1a9w&m*+A!n}-wsw$6>b#Ga^$rj%?0oR|Wj|LO_9I7TJ2`1}}*E)7D%U^0GD zc<`6IK$dnLV`kFyHiNe~Q4J;qy0->^&=yeG#W22VevB?#=k0aMMW#wkLh}gKpH%Kv zvO+WNhb7g8a)8&q?Gd+9V9DcE<0Hy9oCX>vd&XG-e^x-H)$S;qWNljC_DG?s%Gag2 zQ@30bj1`)6J2&u6>i1iloL>$n0)pFVP_*E|0vgp~K(EU$nGn?JWd`-qTY!@F46!3x zStaKKubL*aQw7rfMI$m?EqT*yqV+WU>`PC*V-WWjgkw{9UHG+}=!xCMBmK+7Y~=J%DOOCy-Hb85tW8U9oOdm6>9FUHg~jTyj;kY^UJ?oos$F@8{7tLIWFa@@eo%LFguS=hq(`ucU&#Cy&x| zYC4x~29~l8%h5`9Yyv;VaF6CGUpL7`TqOnw>W`!q{uHQC@{%dgW^7H55TXoxri8;G z#Qm7K(vDp_$c&YUm`w5!Y@pLKsfLh-oPZ578ylyv?yzE8yJ8b|3XNv_O(()?r2J@? zBx_D zPCP2b=$C$6m+B0*RzCl1vCD+24xEk8w-Sg}3^ojP*U`TXvL1IKY3?{)R&`8M#~QZx zXMG=PnlNC-@SB&o-OhD645C6yfUjgwVyFhBMIdx}qF#Dr=iNLX1PnP~f~MLiHL4d%JE2zQg5Nr1X;UIHdL2g zb>XnK@zSLWlM>*N=1HxyL zI^{e`t(fcWR_Gb2>dXsGi~LKVq0;~T5TK$st}DNOx%8--gurWRASn!EYj7P%?%a8^ z7#dQoB5T*Hk?B>)v`6m2X1*ldyBQd3Kf-bGf8PtT- zq}HfAYdgl+4E%P%GO8k_Z5BYm(TRoy!%h9`r}#fkQI>%d_6ARbwp>s()Fp@YB^K++x%yaU)A03!}_BaJQ9q;S%GfD{~;9b2xZe-@tn{BmL z7Hg&h^lZsMN6x-42WDLf1qN2l_Wohi!hY7wK>O3V^L@^azZZv;@Pjmut16M(O_+Ue z*vph!@dB@H+pkhsM48^~`eZg#&29~ZmX*RP-L4FKjobZ4fI&03SE4eX`AZ6}ImESw zyEo4bzXX=Kp0VkdAwqM)~tUdjUfyof>011d#Cly74g{Vo)n{Km5g>>Y$Td zoO4=Zt@l<{$qXNQtw~}E=~)TuRd_Xt$L(<{-BnRBJgQfG{jTvVz*~dFCSs|sADFDH zP=XjL{>kHEt9zbHx%dEYyGfC%*JTPR><6r(dRX&93bJrPrFeA`dk|f3!CSFqA0ArZog8I)u z1)7D1`q>}?|K#4yHMycmj(13Yw9q~I9uGd}pfAS}*Yi>6zwh18d)OQS!0L_(PFD3n zTdUhpafUw9af-8DyKjAl2CK9x^?HxW=g?HYN7Z_1QS*-KAdl!ne))bYC-x&9>KJ@O z%hGdsQ0r_uXI@^q{a8J~wzgKA(hZmdeF-`BL`Yn`N%WzIDV?Cfo>3vY?|U}cGgOj- zD62Dn%Tgeb+T!SS6bVkOf?4#((sznSgXZH#oh&B9^vGn0IKD=P1NAw__4`;PmIV0n z+m}gjRu1#amlO!t9mL40Te%M5E7r%M*V21~U*2iKJE?x+jf;Fw*ahXS#v(ohhsXv# zqX1=s8}`x_fNGq;CM#a(h%bBo5&C7#9r9$KX*gF5f)m{ zY_yGiINjRGV25X6#4?J^<(VN^k6}W&R=vc0-WTTQ1*Uk7khC9xmt?e~UUsj+RJa;s zRC_eUv&mh4@6cYaGW*_K#8(G*KUeGXv>IEZ_c$tiEU`-V_=g9ry`4;V;Wo3AeQYRm zL1s0t;TWVDVeFFO*Yjjwu9Uw2ljFBTDET-SO&Hz5=54UwdU9)o=W1^Xwez>kiI6`8 zT5u4kAkj_aUC3It2&V8t!tVwRF_dSC?=B&>8=cC60_F-7WJC(yZe!uF)T zl{>!t%K?3oG3VmJS97ao(Qxw_V(LhVBlyUIi?A%WXeLDO?i|CjnI1N!oLQ7IZGKv` zr{wVDJHY^<&dp?>tm0tbsXUYXYxnY2M&|=qUt`E;1l6BpR6Ay3mECb(7#BsKPG6r7 z$Z(L?#QwSE6q&@>^P8R$)V68T%O|My`-So63(TeU6n#(5{_}0VoX9-S4r! zC%1ONIUnZCfV1*>q2`6);tvUtC=kqMzc1uC%IQiHCq0h1WgL!6H{WeUSU&2LZKu)O zO2Y7G*a9$s__hOH(e`7TI-<^r9rkwK8JnY?DI3|sZ1ryeG;bAF^i#q(DaCJPoJ@VL zptxh-`mj&D_j6R>-&{9#-n`R-S&z$-)Obd_+$JH=magCx$bRH>9ui)u4))S*RYLc^ zpo!SiYT4zB892Yca8fj18kV<0G;w79kP7iAi7S|%O7RLKk15E}hY7BMj2v=eg&9W1 z$1^=Lax5!JL3?g$XOuklJfk3I32J(;DJL9eq(g*v-|1$NoiTIo>%EuQyKc4jpotKi z?J5v|J+}9*lYTkwkZ-@(V^>cC#!ME6)-#y@`&asgj<542d^dc^`MqjY#HJZ zkvw2tRX)huW^7@mQxhJtXuGRY-I^lj!CVWO;f_)7!Qhfbe3P5SFmj6SAjGJjcsTTK zEuU)QR-_snyv{35)+m;JPsu&v_+X>Nl>NDm1?1Zc0>D(W&fd*K1f~!_$W4naXiS%C zjf{-mwO*Fh-?0L2_|MRjG;P=4s}D4cggPkFTP*^*vB|D@{kta2dChIee*r^i5>?)f zO(3W3WHC|)?bJaEfZkOc^(f(m>(b6>>eTdUd@HBz1iiKcyNqY0$sv4CdCEZWlG1AR zsW;?FlG@N8&wKQ(Jmio6%a=B~pbZXu9{Ubm!0_jgvMGg>pDr{4*da1gm1wec|DueS zH)R50j`&b*s#SWoUI5(u5upM;x4|3NYqkIRbw#46MM>2`i~*a98Ev42U0b;%n~yGR zB@<&N^5ns}#GR>r;z?GxUBkOFXuf8h0C&=YW3dm0+vYW)cs?N(ktqmz-|X2o^IJ;M z;D++5C7S8CS1wd8<8OQ%2HvY{^!bM|@@`pMtB+>}&VJw8Og+_u72~=QE*w)a*Wh_= zjo*jh#0ad8TMx>dDVfvDxHpvBQh_Pyox@X+Wdgkkay4ofh+T7(D^_`%Ifu-UvRix~ zxbwoFcqD&;Vgr8ZdLXKZo3``0r6xt(2^@bF~0F^;Q_rS($mg zX9|>b*9@-SWut{0M{H)|SYqS_-Z!egy3626=gr;uSsNwh>y+3y1iJw`Gi2-aO%Lsd zxyKY&N{sBJ)@E9>zF)Yfyt^igJCDjTyV3ZgYL^?)b3dP$aT;}WyeO`` z3=lX~pzM1LvWpTk-eARKr(sxX1h)IL&YB2OGxxZAy-N$4iJl^yZcu(W` zPl3Cg#_6-a}Nc(1jdo zcsF*f-0Qw#(Ic7Zt}H82U4P_ThYU~v2;cd;>Cb;Sge9|*xYhd&gL_Y(3t4@7=q5~xF*3jN8{RPm1`SljieED%r6WQ z#sw36%jgOL?^QgSPpsIHRNcdWku-nS=N~!s-G!#l({98;(f}Q>66E*86AzDx88X&b z{+up1bRm_#=BKpy@mU;(HZ3J4mj&_uPd~vi*pAEzKK*Q;*Q+6u@kT_YcwH0Ia+mZf zgUM;Vjm!HOFjXNTh4#kJJ*W9$_G@}M;Xi^W}rs}kZ;0gpIQ|ZW1E>=y)_|v8kh;ix!*6B$K9$zuvumPOI zNX$l23N>8+CQ{4zPymnoH!@H@7NTVuy>_m(QF2E8`k){f>GKqc%$%;r%B%M{6>&W+-AD*eJm8;1^3=}(b#;Z2F#z4Xbc}DF2-OvxoAPI#4AqtXzR&nLqnYG|0NN1 zvQim2JF|E<z1`Z=nRBrsmkZ|&EkpNi`A6dP9;^UJaLAj3&Y{}Vz{Q9Z^D;X_+B zz?>OWbZe?_8@y_R<$E06Wxq7ZJ?r-aPNxj+V0rgz8pC0fXFtDwnR%nxXuo}}1m$Mi zjP=*Ua$^ozPuH~u*Bpd8FfoctD^0*GeVEKPg&|;YPX3=MyEytXXaza;TKjF08IyleV(5IWp{jw}Ro}m5rjKr2uJuu?Dp?hTu+z1Wb(cwL;--k}5>W}2%{KFw- z2yDUcjDg)<{P$74IGVn1M3u)37q&yyS~&i8G6LU~D`d_04o*~gAhx5w zN%DYN_{de-8ged~~M*5iy-fEiKqK)LA2; z=qA-uCZ~FzUCLpxmu2m4B0MFjE*qfY9>&WRo@z~wpw-;ZYKUGcqyJ5ore z)Hlc6-IlM1Uxz<%VVWZghTO92`pr;V*+a5#cD(wZYq?e8Q+6`AC2U{_=F4pp%AdI- zOAv!SL#&eL!D6AkyHbwzJh7eLiO{G~uw@RkFR5w!ts`D+l(|#zp$VBFX)$y}#mrZv zrP2M2Csx~)G0r4vz~g+&^S=ELTp^WA5Qa?JX%d2({D%Xj|<(b z{p-QIv+dNQ7_I#I^++zAzohHVc~tWMm? zWa%PPf@`oyYzEfUM+Ugcqw+c0=CH&yCUz7Pl-BsvjJ=jRebOG>Y3^A0?Xsl~1HQQE z_`THu)#0p)DeM2QY?u;Mz8*HY_9X9iUG0He)#tK^@k|wg%$}Ypb0bEtdx%E=b{MA~ zd&BP&=Pc!2QS17kZdj1K4@7sI5dPuB2R+ii#0MgSuVB4b{@>Te;?y0r7ZZ{nP0|dK zTvDz3)kQvja!&+R%F#k3CV0z$%${T@Z7$+Xxe`OxpLC=56$Id^k9rCsbllEwY&(GI`C3ZcxiKJ11k zwtrw>{7&=K09c;%=$>DFzuIlm*`-u@23~m2mL<>x8TtKKetk)dy#(Oj4lVkNmV7Fw z<~`ou$2LO%M?kp0{Zr#B)BP^-wPvh0u{KGl!POR?e3~s96o+}_Q{wyv%lrFZPFy#2 z&ajXcs9_@(2h^il7Fv{L{~5y)OKV(|s_ z$7_B2q&?CkY7GP+%N7Di%c_eUE~b4s?r{|IGxizFhhgGYj0BWF>^0EveO;P<7GZ(@jmv6vOzr^J&{4Ba5^oPkQ5s368Uj zp?J0mi@uXe(dhAb;xdQV%E5nl0!Gu=So)4~ESJvFbbaM|#l^BTZ`b$ky_pa#FhUIh z4n}Xv{4MxLs}L&Vxgi8yHz`JhB}sDXGi-rf6W{tG#<>0p}HLdZhg z@q8*xwTx`~k)-oSx!lS9%*)MxyBK3Tv;+ESvk~{g^Gt`^<0I1FzMzllyvcR!S1p#) z^M;<4Fsk)yzveI2S?Q!!#9t$3DOW1znEnoUkuuC^m~AzV-oEA9O}zgpM!nJKwYvfc z;>s=S!OrJ*7(?GTi@^fu{k!Xf4}oafR6nM<$mC1YR2{2lE_^f~+_=kd%fvMIx%3R3 zZNRehefXqEhNJ%^%Z?1AOU|TU5XfcTxODT7*;10F`ZJLMLvkB9uv=A6oTzsj?3@8LWG;XQPa@@UbVuX)dI8X4J3px#$~wn z5tMAlPlHwO-TurZNFwU=?1QH~J7EFu_1|5A>j@R|{m|4GUFZzps`SA4Z_gIvT-SSe zZD(8%Mfj+xX9$Eq)t^Lzd=zM#%c1u(<4>|Q#nS6C>;oQEZ%ve278xa-9b(}Wm?rDuSH06cm*-}j=_gx zyQSR{7r@^he}rvb>kQXo#FipUnKTr#+M0w(NF7YJ}2#)be$qPMyFsPuDp{~ zB{3mkBT#&gaYG(bKTTCz7xX`ms_?g=>v!#)0XJ^)b+bc6A81Y7 zyLHGCw!0EzLE&>to~L}rM>r_RLez4+W)>||)kPk!sRzE)#qkxZjjY0&Fix0KZQfA4 z-r_o5c2?R`J)8KjC%5s}7fQ@$14q6Oa=Y$_?}HWub2^r0KUadHZP6uGJ}0pQ2esn` zJ4A*x*gP>DZK@96KDA#@{+79_r^qA{wKFR~zdXz4&+tf~ys zOujI+SsR0eqRCJwSUs=BwmX)bi$F?lvU#3y{XjRK{tyeRo}Sx)lq+qcjA}<)eue30 zT#e7F>I(O^N~%cEqJe+UT8Z&$C*%e<>vg>>u&kivKg)y!6~R4jk4@AUG&9aymgrEs zd}Gf@v}(gI**8{h@D*K3-q;h_nt`V8yQ*>Nc;Dp-%h8M_$XPp$_q+EEYsgv*r+izZ z7Tc7=3&r?MT7{}C9`D|onx>Jk(KB$NiPejoUr+$Yctm-nF{COoi;YXfSTK@ zo8W_q|0h2-OX-P{5VTtF#uuwnv#ike)P$#kC#W?-<(W*`;&=gThCJJ}%w>Icr|-k0 zE`p|<8;HKbFKqIpkF(=y?u_VwHiIme=eDdSsyEd!l^8CZqL<#^XBkIZ&kCsPj=q zyyJPIG{wfwstliFqDn$L2XrYa{2;jRmD}{d9voLGe4$M6nQ8+ zkrZ=ZzQyq|8L4)juKq|jb-r5m3cYmVm;Xbm%M(J;Q$C{itR00~a4LIaorwVBt^Lf< zc+9xt`57nnIq$6js@D0HC&atAd5InC(Mm@qdrKUfS;J2}migj;@(L|@yFlR+U%In0 zlYKApj2gIwk3+gtSb2FxtX$pFxFJDB)pYkpAh>!Z>hrR}PDqmNLvKDCk6@gy+;4`>#Y`v9HHx4otgZ*eZS{l6V&(KCDfgu*7>gY z1H69s&nB?y=l)+=o5xKld`l!AnH#c=JOBFTd{Gtln?k&75XO7ke@v${o7C+2V*&nN z%j?q~ETZW$UlU=8K*<|X6lO1l2Q`@sz^j!#qQ^Jy%yRP=75O|=np(H{cXWTj7o&R> z=xI*?-TOh489V*;?;pBV;QL#&3fbnOhPza*89l5)0x=w<$Ljt_?MtUmsP@Xc6Dxb` z%YXYdAGSrDmIhnf!BO|ENnK0^OAMRJ7ZAqK*en>?_2vu`<@!Zv?wGMfEQVO~guIv1 z2J~EH&iK=l8D77^k6Q0IaX3LQieVMo341K{Sr&xp4++Gsc|hzmR|#Nc%{4yKHCLyk z%x7kn8qHy)zFRi~JNK}&oV#tt1{$e5d;{{Jg?+aK5$gL9F#}cmuLjI!_-$`|qwgAA zg2e-&0VL09fe^07bd+|2-XlCVIOLKnp)+;A<-H-+l@d)ntf~2X7%oNzIMnc#fnzQH zJmMbWxKpXZdoP@8K!{U@5~o&7$5y(cz3#hU?9U7R`-RBjnvQStx1&gj*#=S>W8i>x zEwwQj0nDq#gWf9wfnw4v$*C$yNItMN&}Nh+mzQZHJu(VUJU>et<{8vS9HeW@yiId-t7Hz)9)J0o} zcSA<*;!rwq^VHHaxg>ab@~2Q6C>YTg3TuV*q8;GGaUt+NSS3m#!`?QtINXM`$O?_R z>7Xk2E`y8abiP~~`g>n>I%(<4MavAG`lifR+1Xaa$U>o<5*B&Poox${+lbfU8=JmB z@hSGutnG`b%zA+lut`L_1*Yvy#Lk3%&6{`OX*Y7ij10TDBQYW7}JL+zO8 zk>aB~F>`~d%VQi%d_#H8dNw$8^x7Px5<*H;piHN94{qdt4%x4047ha@jJ~AKGXDH- z9^NFCh4lD%0{SOVj1J-L=k}i~KO+*Gp{gO$6TjFNBf*ANMF}u17%=5OeRcM>w`+ed zczLeE;fc%O{UWaK?^V-ofOhq+v`rTxDz2E_zbntt&g_8|Nk#YG7TSw{!b%HP#k7hu zp|$G8&NZ+1o;=s-abXcqJp^>vkURSGbxk8%i)it#wG{5nTC=oE$Y#y!a5hPj2B2#w z>+sbh!HFj1ICq<$SAcv6etE)vkhq+AR5y`D)#AwiL@o=dFwzQOs;^YbjY0m18Pw8~ z@WeBM*#>j!V2TI##E(Ci`itH_R)2bvQuP&Kg4Uf4o@{m#mpVR!C}qR#wOo$wG^kpj zlE#jf0q#s=KuYO5NmSOgXM~gWNLRv@9+oQXs^`iyE7)@?>W0@CMr`xR6=vQKp<%tX zyQzThrQdetC|gs2yjv&+1(a_+{IFHGR^-!DWg=%)N_PZY#MJ|{pLW7)ASte1_a50% zx*D8-B$(=BgwbS^y&rLVJG9HoR#EZZ4ta>*ZR#xt`!#;UlksTKB&YKbpCmg(g=qrrm2BYzTYl_s^D$w8os0Qyh6Fm?zxH%wCl}h$JaxqEGLe2P|JOT| zPaZ_Xi~ByKqZ5W(1Olv92{T3Ou1}(p@XwtL1F9)-X5x$7wCW)9DPn7P?r{T-Kv4{ zm@ONOGDT;(vkI0Y8(JNqPtawkmSH=n8%3Z`210VqoW8!xLKPOovDwiNh^)+S^@eQC z7`{}az2Ew%JBq+Zpf}7IE|SM5eI8}5f2MRPz(1vHf=B z@VzBs`hxwthJh(fS}N>}XN{?-Pt3~i?!0jk(G&-ue}<`2#8!63kk`mufb63!{&~g4 zY&NH6?t!T5`=he>p>71lT+Z=et?eSE_}OvIXRyiE?d9<&dQDL9h%(Z2iw7?gfMDmS z_v>}P9z`p1nhIGn{bS~opc3h(ad}X0SizEU-i)D9A-YEAo&;nea!3qWJiW&sdbaFp z`=L*k@@D~Vce-bWN%-?5W%h3$S-_tz8XGXgw|EdfRD&DtJBke^S5BuS#6jVln*&Ip z9iG;EpJZeuBMdgxsYyy>G|*@mPA0o&*D&nf2lGj#urV!BO zwVh$qBwL`IpYco?#<3lFYBf>G;k)O&)eqF&H)!~goHuQ^S)+KzMHTm3!ltViKueshC>Jg-y`-Ehc18w@MS%q0|$ z_f2QaYJ1_Ua7W6LK5*JdItM%N3^rH|19eus9*Glgk#jCnt|o)*U9E#U&eiY42(v!z zhIn#Rx?@L^!O^@aC}@6V9rv?k=f0K7P1byuislk=sow8n*Bt%(Ks_$N{05>OAcO5C zQDmQ)Fz}XRYGV-xO>2wPvZh2y4JI|r`sMq2k1^!UQ9@p>eV_f+d1%XGWC}0s?dM_x zWoQu5;hh>P=kvcBS9PUAikJStTI ze#%VbXP;M+w)d81{+?;0;NN=qapUj0)l4nXriF*h=K{CspT0IBnm=%B3T(emm?coK z5fJk|WW49f%Bvw(DSWF~~n&7vh zOoQ~a$dTXTt(A{NT*&cI2D9FW-f&EPzF$%&QZgYiYw@2{Jr*AvHNW$*Keo9lK* z#HAgdWP2oJ*bOhwE_lS99To&6eum^0LFVj2W&omLQ-hlx@8a%2xhGFf3#xZF=~~iZ z1nsAJ%RR>jRS6e*9O18}uKt(9_Fg2@i00=XM?v-5*Y-p*P`{mRnX9p4Z;4Dbl4p~v z39n;7p}86TCxX%>yg6~&^*V?5t>p+AXhnOTc%ZzuN*nRl=Y?bHy*hc(+P}w`7Ue++ z$6$7?W`FzopEV_bhVl7VvJaub?`{%G#_HpO_Y^oMk*7$T>Tl^9f`>dcHVl)+n%OP-S{&; zWYh$hROO!>$+j%>U4|)X+8xS0SPJZ#k)p9k>`bx9V1wM?ef5II3SU1)*>nbP_tk#= zihcn8?WG$vI-eVF)NxEped$d54^cG*fYd9)3Ltm^4jDB}1FqLGL6sYr3q50-+~a3( zkIY!`GEh^^E9W%iNsmXT8%a6^*Jv46ly&j<`UxYf3Z7Uw(!>u8c%sm_hiiQwGe26g zs}AFX`gvvl^2-0u-kIyjwxrkD*Ha`~Zq`xE10l+3hepe#PaL&y8!8SUn-|dAE7rIE z$Qo|TZi75dRqRt$nYnii|FDLJT9TwaKar!gclu;w~!E`2-?=tB!Mq`DB2F%rny9fq?4RaVjl z1`9yC-slm`We8+LCl;Q*-EW2xQS#F2%1M=q#uGE7;<~Chx+)RXVEl<&G-+c{C2XjR z2-pE2^JI5!(w7mESJ)vbqbw*{H)Z_|*^g-Pi{+$1u~OYvnrW(~rKO58Z#PYmMVZpt zlQ9mnO+X|`9G31BbiHJ{&#K8g$~P#nVTVE8LXsXKHm=&D+>ki#k}4){OMe+5>-3Lj zQUI#h4FWvtGv*O_}B$B3n*#<^vz z5CMj!mtR9@IS4*M<#Of2R?k_!MLKgye@o`g!l+g_rc$Fg&clTFt+JQ|ii+WD%cw~; zdF*RkHdTxe6)xc?=bDNF%*OMZhHolZ;=HjH-H9EATd&5239CSCp4dbRjRsYDHb%mf z^9zzi5@VH&BM3s~ zTena}uu5wZPMyjgGs=}qEuwjKxP@ziyIbSo%i&e_A#MiTv4B-^SFV@sXlD6T@D6mQyP)Im zALCsJmFBj+g+=?+)~if8e#>!l~?taE;3A7V9M=^+z z^s;C(z*WY~s@?2QFj{nARGPkRIlC5U<=Bv7Nr7}&l3(GVA7-NPKSQ#&GD4u3X80W?sxy={>=R&@i+Jr@fYeN zexF~D-zKs5{~|>%${l5?yFZm!0~0aggqSDqB(`Gm9TyCYul1Gq0bjMW9WLcnROi=! zidh(g_&v8j4Rp_f{c9_X9Wlp%H!3{{0ZhqCEq4QO&|glpuahQwLUdlailD3~p7rsc z{=v_$$6MmnKEG16hK1sIJjSyjbrKG-&gIu`HByBPHhu3I!ii~FDZX7zi) zK+VgZ?W+0bXu?&KS(N9J-8CEBf|ZeQW1#{K@T5-E4)Lg?-k0%MR{(3L$xZo-4>Z(v>Sudb^$?gNJ&h!F4v#HjPZ{E?u-W31EK@t*E!u*YH=XjFGHIY6SskZ&EE11IzUX30=)^W9W+8 z(s_vVF|>A`U|q=$jQD!2$*|l3id8$R(BDUmI(Dc}jXTb9K?Oz#%H`8H6`3UsdLSm`(>;zrvmjYH)MLJJ( zX4jI->O> zzp^D`gz?kKNzTFYYa2-vnwl*tK$J4@CK{2##E1eMxAj)tzy~Ilk>Ad zWbDMHW92R3JmF#<+44y#D9c1>XT)S23i9O#g4C}5=s@d8uoaSCRZTMa{zS}P=fnCs zG%LITWE~qP*-ztaEK@CxkvUesURV}lX^7{aRXnZ|iy^f)zv>;RsS4!mglnOrE$6is z(iyqyH#SP&@;OqSRCwd>Lv_W9MZQ0M>#~@tVtwXxTREaAs+MlFUf49^(wY3mB~k|u zuLRcqD&)u(s#_7Bn(WE+XQWVq=Lp)0=7}e=5i29la0SdR6?RMVydkEG0!%lcQBY*% z`n!5Gw}+}DF->~RlkQ3Y0iY1n(x?r7-bx1pv;vv6JaVbI*hWc({iBz4^{ zY_<}!2ge+`eB$qFapKsz29PgRFpBj7MKSLYql-hWnc9^fN&!@1u)Cy^lT|kg>Z=<6 z$$!3Bx=`Fdnqq^#gBst{rexWSX?lD%0=<;!k-Dd@wHvYw!lJO-Z7_3obK|PSF1V_; z&~LyrBDSqxG8VFXgR>%ZRd`(DYkoaaD!e$6yj{IyzBKM--;V9{^#(L*4cVFvDNHTG z-AQJFPpjW@_H;8T5WwHac@9@qhP8+{_KPqNkU_v9PNYpoK&^%Xr5Il-W9g8jj~vyL z76KOMUQxyPJT7r$npvdx@_md%poQ{^I-v=bOph!}kYB{49SOa8@47(6dQgy(PVsVa z{qu!ks}tLLv?Y-ZUzqoT&Ek>PFUZ4u9rG8PP)6E2VfB9*hY-fr9Ueu^!0%&@W@ul! zw;uKw>REMMwO~N+FT-Z1L4__VKV%PE3>~hZ^}>?~mpo})lV52Pv=OtmLjq7*uw0@a zgO!76y}6Z;PUWC21w6@B4HZizpYf~9fYbLz-$Xwbv-buJ)^+v7=7Oflrr_E7RTRNM ztZrDknO{IW&21;6-*)1_tMF~D@L{#|0l~Tv5u*A|`#Fsn9RKc8(wmbN9lHBv3NQRg zHR_HMW3y)I$9Kp-QcedW_H9(OW$6}S_w34<`FDFPnCt!t?M7RG_83=`IJ$GTbHloY zN@5{Rs>7cKFCzmYkdNGVh7=9Ul}Mqq+=NiUV2jk-d}G;y$jEdg8X4jN+c(a5aC@pAZ> zF>Wzl(X1(!3O#q6vR_)5IFIKGMqyQGa~ge|S$`khQNo!XMoOS>hK=_^;jdiJ<*ax0 zc9r_kiM9+lo#+!el|bx>9`_?#>#$mojFl?q!g|RPs4UTYI2kZ57XA+-uxGzDxNK2{ zScyejhh<#Q!wKb50KHaU_eWWlS)jmWCA*En;0ljumWr$7&I-2w$_%E|>OOcJy}pMi zhIU@m9Euf8^ay^s@1&DhB4b*pOU}^p)3de8UM>7ORZdH|67Px@6*UnRL{*Q=78ATf z??@Dt!7d8rYVBy%HTV*uG@z)|8MK=?kEpWC&)JwIaq|HYBr0^mKL9lql5v*M zx^N>G`$AE5TMuo_G=DRe`75l>Qbsl`G(ks`?nx6Ku*+AP|1ZWzlsozbJCj$;*dsQZ=;z*q1xlZijw3;NBD*GobQYC5TzgipVO^fZX_Uc)sDb&!4i+PA7djDWj8z4@t zTw4*26K-6B(nau%F4c(>rl+-K%iG8<0uCq5=R{}&+FUhUqKJU$WIkCQq&Ltpbq;$c z_F>iLFJny$;^mp$Z2B}rO)k{ns zw9&mYHd{sL6vJi8RzbNItgurXA>xI6H$fNs^hpyY2Z0C8Vg_p>`mCSv1!t1huF2%oL&A7Yv?ZxzkKEY9~#bzPPw$@>LdRA{p(;@gH4V4lj=QCxhUuIm+v3;{Tw(u@cyKO5g!R9 zO4?dda%}B#H7!oMa~z7vtkch9OSG0m2Q?ScHqqK!lc%`xrsnMP^XZqIeIK|fD z%ZXx0SZ=+iKRosc0ai8&=PdEwUtixJ$TX?cBfS)fMfgJfxDWz1ssQfd-~o6EP*{8- z_O&P?HSEhwU)Azs;;Trot`Yiwyovvxum0EDa^$M&TH1Uy%rhVZeW}q2HY_I>gPL-3 zSGO$Fazp-40KDP?d zWHN1B<@-{Ny{I$0yS;5{I4^1$k-8e9u>zIfVQl2rj#-<$Q!m^NaNO%Wn zS+YF?MeVC+y{b@{zVh|9RPR_>Z4T8o*4@*)N>RxtludSp8=0RkY#FkLM~es#4=9GA zd9CK58V0rvJ}FeT;enY?VgiGcZ$yW(@U#VbHBJL{58-p7eh7xJyF_Gh*0E9s6+F7e24gLNhmmJ{!Fe`x~C>B(x=6)Kx#yx2i%G}cq8ROiX z^D-|W%W^M=ukRLNz@_kn?IkEy*{UkL(H2^2;^0%*klw_8y#i^>!g)tmPjw$bjMus` z2=lhoyQiE`0Z7GIwx63q<5o#PRs*f=gCo^@W^c<>YHU3vQagyDfISZW`u$J}%Jnux z5TBrRe|nQzQ3Tu0!SWRufWTse7^=a|bci=(>&q?r)ENK|yL_m#3XH9G+ixWUmtL0? z&Z5*@As`ez`y@uRxVQ^}>WX-y#C}u+Tx!y3l+?k>!Bv=9Mf-=%o{XEV^sOLQTE=Ib zIT^Y+1be>ZxLoXFC8Rt(#sJ2MlzxOgs(P8u=&1x5LN5U<6WxmiEK>%R@T1d?Q6*7^ zC>BLserr=qGChJ6cSkReIoMv@jWct0kNV5`m`1Rv7jix#?s}VYR@eUl0Z^Utg#k5Ga7Rg9hqyguY&E6b zz(>9*tmnpnNs~NO4s%?!DT~<&ga}k45Q447pUWgV?#t*lIe zwW2?HvZC&K!^B4QOwn8dak6w2tJHp?eSQKIeyt8wygJJ`I?@WOUIckPzbIWYQT9kTUXArU4Td`s^nqg*_xf~lC;m*`%Z?E zfiz_|$F=0fFk?sy zt2ka}h^4ZY$OtbMBEseSN5f8*o(Ypg8hlbLjPZ_f)aN#}9SY{L42Kxx7JJDNij-m8 z-m2jY0;$HIRqgE8B{lf1->auENanVbC1@_oeqqV!8>pq+UyZw)*(ZJi^5lAQ$AV>t z>=C|i_6f7}(AgI8v+vPe66CykD*`=pzfw9%LMO<8qySS=Gb%9|cSmw)TNHnYC712# zER3LdOlK>bhA#{Xmsz!RO)g2qqWK?UOc`Z$Db_^u#{j!FZP%j;enIBYLND7w?AxmM z3pAkKAPPo;Y0?$nWDt@r!FR@EYTs&esf~C`0xPYLTqT)5B5pMFKI3;K!b&&NKzS(u zp+$9)e`_3^qYe)7o_3txYuJUrtb$pj(I}(a=whgqZogzxM`}g_yCoZfrLt@ohzRZ& z*(9a~#pvum!%b`zWSZ;d$WNoO8r7~cmisZ=v;YupcRPnQ-JU!H6)-iPE!)CYVWlu0?*wq5fkU93W(sv*zkrTlCL=SS|Ko%-8 znv0vNP0MMU;b74cRZV_1ek>O0hA*haT6T;B9Aoo@gjq`NGn%X%6RPnJME`VdS(I3= zYN$)=LczV#Ugp+=o9K0k1CAbXS4Bz=Hj4f?QQxM{L$*=%bm4E6ZzIKonN#>|XFG=7 zL(SlA>(>+P7LSHU)zT+`DBDZHwO|98SX0tu5G-5o;9_k^oQXtrTeAn=E zR3zEVi2O1Xzcs6f(QzSr{Pdij*nBm*{U1MZHl_OMg3^WAgF-=dby8a`2qB+&t;Ru8 z5C+M5V(8Y;B56u{c3svjQSDurLIoXN;jGYzsEUB;QYtNg%EBwOWaNS>UPE&S>q0{9 z_%tkKQnduUBAhZY5|xr=B@?0FsTGtfyyK-*Rf~J2d3oU#W$hU-N)q*abywQJBXbl2l7M> z=?1OIZkDU$S91)|N(=e;){7lTBq!ouO6p1zSp}|SBswqXluZaGt#&`+<|~?$@B#(o zr@odiXM2?$MTsbi7ax~MwBnRQy2~}uuuU0Or3K1J>aTHrjn${FF1QtU7u1xF;HK6D zb?i)aH_os#G=ycFcfAlKQ%@@|oA_8R@!Y`a48T_gMlPuCMktKykGehYkK$fV|G*Ib<#ly>o4PtCtH5jVgwI%T;nh9|m zm7RSVAGN%GvtcOsR_1R8%j!*N2#hPgTc$BSrvCLRK&ybP$p(tL=xIWb~R;tlAgGKt87={`@DoxX5F5cO=Up#fo#P}#G`nu zrG?~9$|Ku>kO{w}l{J(mXGpbip~Zz^2DdUt*zaEk0AaPHu2aw1LWTV`zC}^*6eTK* z#~b29`x(6&;AR0=(tRVTZz!Nk?)d4M72GUg=q%@fEAe?}b&-{i$ePcS0JI`GAhmcp z6m%5Yw=m9U=WI3=)YO&KgPu($l$Tb)ra9AbRBl6Kk}K30V690PO&r}MgwTdOsbWMf zg6)7WN4!=rDwI%{ZFp2ZcGkciKC!aFC)ID5b0+T zY=i~yC|BDRXg9V58TmV;LNZJ;iTj<8C3Le2mR2|#VF~LvQ^F~|1zEgh-oA=GPGo8~ zX8m;~si!Qbhi-2e>Cr+*TB*$(V&t1x<{UOVxOY~@>d3H>nptz|?0HGgOWxiajM;$4 zGQSNAXn=yi^X!qCZA|Dbtp_y_Wv_R^`7KwXqk0V;Hv%CP+xSQnLA$wbwaV8jYiJed zCP<8GBvdIDw%aGx?SizQ{Z=#heGRrcTF{N$Xh#We+*Q8KDJLzh7j$BYOwtkp3 zKubqxQTH?73;pB5wR2BjzX!=#^EGpY0^paJQ z;1$Duhf_$4q$HyoaZIf~l%mpsCUq`3!jaBn9gQcyN*!CX=7>Hq^X?2>fNJ34=PN z2$elRV!ax1kkCx$rTxs&pL#I4V15pJWLCeYZ%o8B6I4xydp0m;)MI>hi6f^1z# z7V;A95e>)_^HTL=zwf~}ntGD7nSrB%md)#u@yy=Lj- zrfw#hVsq#4s7DI0EZN`AaBfTr>&cj?H!W4?m*`Q0U|QT~VACT=1RPGahYjk9Evobd zXWNlZO{0pdKyQJZA9hVoG+I&NR|Q?OJxx#YrecEPQ*}%5c@fhO=$PD|mC04r7&BIg zIGrX`Sx{ZIP`flTi9tg0UG83ws6vU1p2LY!KygmgyfIm82!>88VuCFIv)t|NCUYb> z5Ax5TQ`@XTb~c!9cluFWLqEYYnf0#Jc9Fk48|KO0*{k1BXb~1j3exOun5QhfvA0^C z+%7g#O;6mnB%mvyBqjTGI{i)62iTI95^ZMj0dOxMu{kj#c(@$qoAg_*Yf(p6GwJ?@ zL+O_2VR-&%FG1$BM-4-0R5uH|8v7v%pbcEg3sP(GV^;T47Sxizw{5I?glyTWz7058rvBz+Kbry=RX!UpK>krAB9X9c z^>dgQ!_K!mu29bEJa%bO62wYwG%9GtVnia#X!Ye5Ca5x@!S{}^KyBEB*q}O<2GC*S zEGhIr%u&L(``7l3oKywz6WS5zO6D+eiWAjJ_LpJ4!#n|E+TK>uehxF{(&DEccMf`4 zD?cd-RDwPgg3f-WH-z;*rHgmKth3hJYF6Q7;#5y7-A9BulQ+< zt$VqDTk(L3k@n2egNBJG zLcak7ZVKyN}poAkP^%M+E7d|P~eq<61rG+;+Jfv4MU^1&( zh6uH!#uGb-D4ebbAxt(MxhL3i@S!$rWlJ^qKpQ z7DgKCQPpCb3@X?QgF0I|4M5~MB=CGIXmVu?nuH#*6W|C9@+?`ZaC543lZJhzhRvj< ziCpkEE7l&Vh{2MWoKw&29{{}k&AN@18)AUIS>sH~zytio2s6tnLl)jFBIA4r{CO}W zLmF{iuOOG(ICEV<8f@`nU|xju2W7d2T@{lIn~ZfZSnKwlrld7~xSuTb z85Dfmp-I!y_kliVi))yuY-+kzWsGU;b4U`KTh zqoFD62 z1hiJYBo<`2^Cp`s#OO#<3JV7$bm`T#Qc@@Zo~LH}hC?bhB>F{_#LLgishPJ|i5L5DuV( zS~c)zmypRW+Phxazk1t>_Z9w~b4_SFRPB3sWSqyBp=w}{urM?(>ZYHg49_85pqjoR zIja63pFbriP=^bcKa~ug9ExXHI|qqvk58%!!BBC8kb>EMrm9~_Xaqx zpFN?4=7%i;A$zESx?G?T*jg?4j>@|8HXHs)C<&R7g2HP@&nnCL^I;bnIbtfI9X2QL z64wzhXf?~~EmR$;hl)@0yZ`d|`1fyr{=e^k{M+CC??1i2|IL4$Q+e0#9{=rsynf`r z|I0u8`IpbX@TY(JKmYdgAO7W!fAjO7|LLFp`18;I{I736|NUQo{f9sQ`@f#QlfL}% zKmD)2{hfaQq$u^vf1K<8_>lFvkQvXSZ&p}CYgV}5o`(0-9r{em!lxcCJqBqBbHoZ$ zVDN?Fd_R`K0mYch>J?#*DB+Qmsk`e(4VNr&&9tS|5 zYvOJ_lrap7W0(0n)Y+_ng*FFPnZbHiaW6n!{SmCf2J%G(2)18)s>;F0nm=rmw6299 z>)=*400V9RvjwnAz<1P!h-{Y7fRz-A8y&spjA?o2ieeteiWVMagK1q~6gWcT9K*$rQ-U~P*wXgwQaJP>G6y4m-wETX}r97JtA zYFW+c8tU&~hV+YU&rzE(t6#^}STQy6Mhx>E{o)>B9j~u0C$I4P=rfwk1snk~rSzz? zDwX=QfIUp^2D-8-HEL#wL;~BOZU2U4fMu!leYoSn3IDKLjMvAQxVyQrKJvG5+Ezm` zJVn^y=!-9-7lnu7zzGs6ePhTj@Z+VK6`vZwXW)e_B{8}iPs`z}EG{wJ?2ZHIr{mU+ zHYUA7sBOuOV^xn4vg}^eXCzxr{U3H=z1bBbS&y#NK*gwStX7BfuCocqTkeG7(Qg+Q zS-YVF2BnuaS)C<@>owE@8W%|tF7$a=hHcH^kgwLz_No~h`V_yU9HsYt(~Ax(!^_k4 z8c_nXNrXKoi`wsMmeX9UlU_z7i)FdK(a7YeJ%C0KS$MKq7sUY?&Lu}eqP(z{iXEd> z#-rStE$m^a{geQadSMdo^>Gb12VtN=&by|2BTEhgZ{en>%w$-$%uf&ccB!%5WZti% z!?Q23LHPy_-!BE&7YRU_;qrGCM6#lLa85~5tLYb=Dvkx+FOH=fDB107q?v+hi{3a% zMG*lD42STt%hc>1zIuLiBU}<2_n=)d3{@t`b|wExbI%|zfx3L5$A!s*M_oxA@t?04 zUHi#!HePMBgBN`zTgyE~diaFAlWl%D4EY_#Uwj<{aG?iwLjTB(M^vOaLlmzh-;7e& zw(-ot@p+_tqibbpwk&lk^n`TTDYQ=mJ>@*DQPYi^Xi}ok?sBb0S+7RlZ`&Wqx?V6E z1ZJ~c+uT1hS&z<8jzUT5k?5;4xSijPKt|Dy2A^3%V%1jf+CFNt3?gWgdqOO{3w_%G zY#-4KFJ?O%g}<$_TW?ftc3Dk!+wfUd@$F1r$m5xJjd332>$C{kWdi0+Rs_#i{b?`k&Uo zl$p(0_L--wdVpp-8Je~ug_7(cI=F-bR4Wn(#o@&ulXQLLv~Bw=!A!-}-FQIMH$u8t zC$ey>Kulcek#M6#@)9Cv1;Inx?UZRWzdFkaA|E8RjM`YOcla^ZCbc(^2UWx!ik+B? zTwmaR&jqP~Ng_s}6V7v(S|B3goK)%daMp1)@C`6`Mrz(b<7oGF(_UlcrH%q>>-0Dg z9Q!&}H%MW0M+a%l7`8jq(I#yF#T9%T(i$;RYk!n-Dh&?^KVeqZtQmK1=AvfNhTX=_ zEc)ccn*!S>r&6SL$K7Kg-^Ct)4LI(I zfic$$1w`t(_0r{5vmjZXAv3Gw-o3+G?RP+jMk}y*(w!`O&alVyMtNzmoTa6`z6+Od?kskZV>U>7v{ zPI9^B(IcQ8zS;Ku_<^P;wn-_W3BAVfolV`wcsO3#HMwIdk+3a(SaD?-Dp6e6IT@z4 zU{EC@1DP&`+h*hJ95^6|v`Mjz4i1xLWkV;Ebq$a(bd6NytL)`P9@VE;mvb21BWx&^ zqy99?`hJ1Q3@S+yj++Y%YZxwV;&AwtSMW!aD!n$UhziPOQjaO1B*u#OsCYVy` zb7wm0rdSkJx-*PV>>9;-Uc{$D!KjvX5`}GHu1*(hpaz7KmwP+Ym}vG}ss8d(;tYD+ zUgI|uwrsgtwNB&0Yn(uc6II6de5s?VnYYy^d+p)o znucN9a-g=4?QXPJw8w0>r5r35yK4oQ1RF8SMI}iP$K>a5+_1_6~S?}=qr?+VEyV` z3*0>*M)+;n9nE2Ibl%%yo#z{8*k#%NQt}B_erN?#w7l(;NHmKBo8ym~HGTE&z#u9x z$RHz$012AucJsdm+DKjh^S}Trxw(@y2%Yh|GUJLv)KSv_GeFG03P4DGT__(Hs@rId zIPuensLb98+DgBCuO=t^$CUR%DfP*x#H<*mw&4!aV6B)vMp8@V>F<(>VrYN1q$@AO-XyQWh@1~Pw!m?P*{^x7HxTOA9^on;fX_T zX{rk81z%EY^tGYHgjYHbs1sZBbFC?thom|Li;_@C!y`xP(xcJ$e} z2V4iiPU$(H=tX?Sjzy@dAfUi>s$%Ao&Po^7sIaJ(!vvHT5Oh$&p+7qn$P|Rt*z%i1 zPI!>wS7$U8kuJisRXd=N9gx)q#vD`biC{eyCrCHJH<3_yWxOmInXwcL$jdzKt66)=gxDTvo$sCi?ua^|rxuY?{7UN>~PupgX)BZTQAC5))UukO6o zR0>LO&0dOA#u~cqO1!#Ta2Tz?7de^o+2-n$3{TTyJ4fvg6f{nDB2OTZYF694`LoC+ z1A=2L78b&64lBFF4XPFzt};rZw$kZ8lH;zZzFB#3i|rh3v$UsKi*;II<*Xf(>b1@( z{G0&P2YxXHaIFHM z2O&+Y)P>V#v6xFq7HXziCKhJuj!uL=@~1u@Xc{jhcN3~k=C7-Y;ev-69Y}ZAA847w z(4x*L*5)*X$DYSa%@O3C*52U~UkV*oO)|%eli~+RC3Gs)o7mIozo%29Ue7E-!~pbk zw~1xGqDj+T;-nf}AiH&#+mdLA7s(yKdbEH%9OPUZtZMC%3kfG{VHLT^(;%T%RSOPf z8;v04PNtOARlHn>U;R9=>affi-9|pfb55u4hR8@6YA@hi#S{{~+lIf~)kzehiJa@a zwDTc2nJO_EzC4N6&rN?&Pk@ZbIU3d0iHLW3X*s8Tf%3AvZ5HAGHqaT)Ov7_o-w(dm z)=gcunp$%rW-8Zb?@>J2DMvI-4V^exAigz>d(WXY)NWpu6qFfKEWm?y4A=_L@bw+%?3U5O5 zc1yFoIA%CE*eyG0wlCN4nPEl!X;Y<8^jwLyL-a47D$tePylluTynxM{O7OZD5W z5$Ri+OJV^O`U#aHxu(c$q!%YmHPJ~?yKkuUKtL1?K$`A?*V`?$o3x}AzcBewc5*%pB2n6I%?q$@CAAz)PlccD_QcK_afIN7DCTik^nQIJqrNyT}K z$r%1#HT{{BCoo^@GAbG`iSDNQ+nu1^RyqWUF_*f0vb6WM_ZuP2@~;-V^h72Kze zlw2W`IIIEE=m|j15L36IitKQwb}eNAIw%C5FEscJeQL5BOdKB?RuswQX- z1tpq(qP?L8uhtRT2u@KfS%NC^6?KKfI9Flqz%y>8&cm=d){r59l)S zlh_&9-Yw6!Y>3U+9mN|OerK`>0p49U)*e&++FGXN134~}J&@wlp!uB@;n8Z<9wU>d z#|vA1_M$$0$WhuB%WLq{=d~N0mvfSJba)aAUPo$Q*br7`%P6&4h44Slg1R3ifh_IPPOg$a)wM zM5Is}togN7(i3&TWT*~Fyf&k!H^NxTHGKQ)I8i4;Fz!CnCjpnN5D4s_AHO=siRqLK z(?*f3cGCeQw)_I)0Z$iWTDROktcQL!N0-E=(2nnxuF);nvL>%F%Cf!xHiCPplV#E8 zjt*-={d{Pn&I~t}8f@?xQ9Q5b1fqPi2hb}P#55ZJ08XT)p$fC?Tr-M*Dt2WF$M9o1 zHiU4zf9@Qc10qldgFTbXN~_q2IyPUoK_`dkA;ONL##Pn{c@?e-AUiu2lk`8+*)53r zCgx7(sh?>?iBhLSo%h7yh&vBcxCB|>APVhW2{+aWtb4l@uUr?c9_3ET)$1;3MpPsh zLb#aAw>>0dLtC&hX6~lnPryNJ@E*{nyJ*6H~q~4g_=N zhq>11^K`SiCg)1Aj)c1Rgy^BOFsM(h4_w_)qDoTrLbgk}iYlmUP??h-I>m}cB~_j?k1FWHd1Xo; z8=7Aq7HeK`oD_HORP_tnLV>EUo!MH|ToGGRCkHjw6G;@#npOt2V0D{e(Vab?%JfWb z9AvtWOC(`R70W8I5^QK2Y<0Gb>(jTFQ?Xh_ z>6tV;pd3!&EJ)OwkumX#co*yan-eO#2YQamsg(9ha;Uoik7Sw%w;69J-Gx+8IBijC z#a`mvUC`F0_`Mfv#D74wcz+r%=%~8(l?msZ(h3 z@s|a?@m)|zS9aWVm3THc;e4Pob7dfXCQ3m`kfxT7W38mpOh-(%+c*n!E{A@?HT$6t z-hS3ob?Kh@Q8s$0!wN8x{yQ659pt$xia&i8ONl|kGzBwMQkDHgBfKF+oWlSOFSVj~ z0*RZ7j26ejO!nQ%T)sbCQK!RjC1InS6{#&Cq~S&*MN2`ku#8V&T%J#%Bb6V=t-Yc> z^8i50JRZq^2$<#~p;0_5cD&Y7#c?;CI7F3u1&0X*#DPS2Rtco|X{oy-!x$l-PQ*ZU zD7AY_3jJnvFtYnV5(IMbLJI5>Ht)8J;}33rMTT9~B`(ot!gqmwW7aJC?Ev+P;=lZT z(9p@LKSC)~((*A~t$4zUw_${E)U?eN2cAsJGZ883UM+gy0;P0}K(^I}KNZvuHM57i zDi!f&z%7SWTG6%OO>I^o_QeGn1hcefU z26Z!16DVJo)*}Ut5*j&)8blHEtR#01HciH2n4dt7^ppbgLd0Ix$nP{7iezKm>lVzS zbD9jlTY6jVM}W_U9stFYBLBG(Ml)nnlDo51h}sR>s8PD1@K~SW3;Y&9P0N}}u=W|f z8$oy%#FH|DM3sbARNXuUED|*$$*v9eJeJsP!i4!k4$(dCmQ{Uzpa_6uxDnGe(?5~0 zF-jZi0K<>vNJv#wGx6vrWTa-HH=V6&;!T5fw(9IOL~h?8vkPW?gcNSq<}O~f@bat( z{=2Ca#tJ1Q@9+y;fdb0SGVeTykh^Nv^GC6-EB~T3xPzFUXpSGd{Br_NH zD@4Q}ScTUaJ*BZ^yz&G8Hzhkr+RnWUClDP&&ttWe15qoRRaBc(Ea?g|%y?~$nn#9e zi1HW6zSm%b_0k_sM4U}<@-u8^t)(Xq$V*so%IX3BE zzgHz#{@-U`tf3Gq$^B=yT9;@R)vwTp%}r<9I7iBro1IQ&RLivU-ypLgy=JD0$`PI!U{z!4Br8(`zB z^w;Ow-jU4;9Kp`(>KCd;)$PB*wyOMlj&Z%bY7IHU3=MVvn?q35xwY-h9|+_L%+M(5 z!_`%lV)G8SBCr(_ZfaF0`f7uo!kqRT!82z~T-AWY_ zC=lX&8R0b~p^(iJgpnjCQv*2@k~EApzvC9VRETMNLA;*%>P0{lgiY zP2#=`0m~;4<`pxmFJsc!v^eLwRs3LE&G06Nvvbm@Wud|9lURx;Ak0u=fzCaYYZ1-h zEeq=j&Rv1mGdjd%)#{$ac4%Fn=OfwKUTvba)f1Vqs3nb>3?z2=GpU>R84C7>9b30k zOtUAj*kz5lLf+I{c@5Rl4gK3GaRM{9bQ)|oQPhc7t-k}I(obQXEcICHX>~**mrdy8 zIgZfA?R`{4(wfCH3$pa1hkCRiU(#ceIv>ijg%m&2_L&@d8BIheJk#7YcKd0gG;sZ&pyRB4Xt$_n#!QOF77jct~++^H0@S z0ByV+cG#Z&;zm^M<9-uD|I*FRpT23Ar+ISQ@{$rfdoUQ}ZN)kjkG5^85&14wYaba+ zAlCpz-BKvnt+-r39<7jHXd8&t2?8yd!xA~C;|vKt`sj#n>=qXB8`WP3I9mB7ySZ1? z%u*)oJ|=F~?}!LjyHpzBBUp8yaN1uOtGH&1>e_5ymEkopu0gT15O6M6IJcV-)a%Ut zja{Sbabw!ByUIvSs9e}Sw1gF|538Bj5124UAJN^*)$)aRLazb^QEm_=Ruvjk{>!!1 z+u?S7ts73%Og&T=RlN!oP(sEvl~C*2bvfo9xB{wLvn~YzY>p_{qX)hOpe`Hkv;%w( zLgH-Shv)@U7LrUFU2VxYP+yU3i4H`$jw@9ZN-&w6(u%n zIGT&qWC152S}U18675EQCOd7_NhLU`o}N8(v^m?pcj`=Hd&l^sw;f3^t}DukTlk3S zlc@czrU=^>PzQO^n`?&MaIP3)_90-!bf_#y0Pdq}THRyYiU#2Vc_BZ%vvlg6ZZ+y| z{DMCzJ>~H6N{D_Z(+i}iq2A)QEy73!Omytbbb(sAdnwS*zl^9ttZW;KZrQCbLnIl7 zi3RG*d>#_Oo;LzWD@Y*I!Y?+L++hY|hcdW}VRwVzw#q-*@Kfcj-$vRoY@9Pv{moDZ z2WA9%#yy5BhM6ZqL!edPI0eG$DxBjIIaeNK6LUf$clZP3Vv$TrJ&G3q!W1`1E9PwwZHg^vP1_#`8?R$X+sB%Hsy`X{(Yk88=n<%LJ(Wu7Em3<~Gt;gIfNe2}P zv7I7M6g~SZI*W{eDiO&ZsL6EMDZ!mgJY75$RQgqRp2uGp4ngkR6$uV^S=MN2V?=?5 z;!%725MPve=UcJC{M%wg||!iSM`P! zVq{}NNx)!$6`Q>c2VF={nJNNhJt0czAVGz9tATphUqj_gW#OkeVzfU-De|39-)6*V zT#7*?Rfspo6uKEZw0pq#scBZI1UDw>0D>>ULnRqN`!ndW0*i z1#3KK<0IdFi-}!7NZr# zW|-oNyDXPqtOVL5g)BTlk&_m(n>sGUk8o!58mWcXop#TUZx?VVV)c@D|1whXOwJrw z3>s>k8aik>L#q@}PzSdlK-!H}ftg~q4cJ1`(6ZI|S>>qLj7NyrkVRkJn^w(deaYTP z*+MzjHqpG$qg;psE&O*(MuLp3sX4)Iju|OOa%^jf8iv}M5Tef#Q2#ojN>%}|(8A}2wyLi?<1W@tyo|FCymp#Z=h#Df#pUUo%uAI@qo~{x_RD-I-~Qa-*3SN7@ichOk-dcn0;mQZOw?&uEio%D2?6WmQ{~+vT_SBqC=kWmWf-h= zg;m)ZJ#yG&I&Rs;w4^CqD$FbEGLI5Ew*{^BEL<{TQ6Q?IIGmi)Y~p5~QjoPo)Uy@q1e>x>rCtPhV3uq4)9^Wp`Q!k_Hm40L^QB>ZVVW_EF zg8!KCWo-oIk#w3K5c6WwIZ!v`gD2mG>=e8mXA~?o2$iV-=PL{XJQ@J`LJ)R@3_&_g_bplESS@PO;JbtaR}N86<{Q296fI(`7-XN<>07 zJVNaj$uf~uNk;d$+>XTiFWYXAExnQ}O<|SSbu2XFD$d;=E}Uk&wwbTHL^I$yO*)yW z4z#M8+P?ZEYvj;Xr4LKtqJ*4VPd4b;LLI1%JXR>Gf-AT>SA{Hm;^pP6d{6%P3C*t; zt?C3hCK0LH{QA8F+YcjJA+ZoC zkZj)aKztt5Kyq2B<9fCs^x)!ROR1y-nB}idI->90jblmBYrCx$hQcILSv8m%f%gPf z(^2OnaJOL_A>J<~Q7z31yhFt|jWIf~bVW%NSGH3|G+Rpx$+1r(R~XdE-QlPtH3R`G z2-Kd+xtS&15RNi<)MUZ=mdwdCYO)5H(2Ph@Aos3Pw_UFMKiQIWhbJMMfQV(-PyB31b0ajSi7>U%w<-gjcLORDo+&0Y+h2(BrK2~#MQ{1fWawe|n9QZ!Q%V4bVCTrzhh;g>5avRjX{q-FhEO8rm+d9h?%D5gso?a)S-GtZ3 zu370?@lZqWVHSi**|3~?G=z54L6V-1=cujYOBOsr zZOTey`Uq=@oXq}cGcxi%;Jbn@1cf8o*3_m)om}Ul)S6wjk)WN47&7_Rp+w6gqiAN1 zah0RPKpL`IHCKKcu!agOBZc8o0i$NGmSN%NtBLh0Q{$Ch&?pR_&vPK^Zb8^q_BdU4 z$mG1LcTQYxAjN{T8#Yz4Sj%o^(Pjum=cON*iG+*nW~y$}TF)+6uO|^oVYJ0foRP3q z{SnfTKzdy=>DJ;+7Mz2%W4a|zB?Za>qGWOI)7|JlOEm|>ubA6O=LcetPo5P7GNb*j zW+q2Tiw;Dad72!XR#f)~N~BHFWk0RWfMNo&Zx+^^<`fN15G6y)j?E3}LN!)VFc4S5 z>OGwc`cutO^WKme=~vWH0BFU(WF;JROCW5|6VHHem4>9W*+N{$vPqXvMekr)d<#Du z$w+yr5Z^QobyKOA^&54w5$qD*OnDRHV%dBxenj!Z2~fymfO=CJwU;>ws)ZmH%FHRW zaRHAFKnIfucv9}G7LqoCu2*Peg<8P_EzsfK(ZaKu>_Lt_s8>wm*k>=9|!al)O!Z)Pyc?wZOhrs;7C@5HN80lhIL1OJ3hxjjF|3d-a^ zl8nBJ6^28DbRi6LD0*hPGgjjE6$NABSSF^_u!@zwRWC_(Ak&tvrkg01<9Q|BWYt|w z&l#CyGsNzyoCk9tF84A7#x9{rt=wuyk2NqUsygbp0QS@T ztGtlM>(f{Ug%i^1w2P$7E0#&C2r*`TCS~UfEb0kt6O)?XS4Jui+W{HQtxDk!3jlBANjFxYZ2ln^HRwnAqlbi3d&t=2&?3Pa)1Bmn|#!I?0&{(S%9* zIyT&A$U__Nju1#$Xn6%lR!`cnj!bY+l$e%{_0=-0LkBt3s!iEvNg*Fq&5pn}8?Vr> z+Z2$ATr}JO5US;VlPUG^+>+l*Jky!GD@s3+_U6-dCT?@jQpJxTzK7lZte% zr_|=o8gd5&SF3G{M}q){oPw=^ZC~G#)vsv_m-v5#i?iX*46(qdv}YS8rP;LM4eUs{ zo@xN;hXqfh(>=S5t~wc;wp!|x`jkRlO`|DJaE*HG!Z~Na4t8lmASxMw;6X-XJIgR} zPCTBn`kA;G1W_7dIL4o}%J=M{1yBSF9^8b7fm8U7se`W-sq(Ob8o9kGg%w0W)fzQe z^=devcg@uI2TXZ!#`EnyHfQi|7&o_4t9Qfbv4=`BY1ON2u2&t%F<32O3#U_^^j6A6 z&DspIF9DP4PNbp`WMODZwDHn#7&;G8aKH|@yaXJaA6h3)d!>al_StBw*QAQwSW8;J zgzOogzm2=Dc+$1hK$4xc4$*={74$vvTWBaLm_XqHL7a`ePMgl=CX{VaGx%9mt7%2# ztEW3vmf4o69y18uev3kh7i-h5hUB{Ri<~BHZnAe=yE9ERDrz&RdFn=6X+q7(ZA}a^ zar#)Ov4pZ4=E^D#+gLYwGPdPP!WM?a3K|6x##T)&lOkXZ$Nb5n3LKx56h8B~UKRBcAm%`2LCnGXkm&0pItg#Ll@N`Gx9B{etzvn^o_M(QDc)|QBDxxZ zL)Xq6_;$7G(3fb|Q&UqQ#S|1SRw^XwlKH2H#yStE1G(=Bc+JM2K8Ctg>&n;fDft+t zsZcm@`j$^mIXv^QVO5!7g!KhADVl4NPPLMw?2hRJhAvV{yXl?;8UDkW$wRul%;>ixb>i?rL2A|Q9((0;t1?i-zo0ojDKp?DQd=ao z4!p5VOtqz2^obgeTSUf;&>FE;r_ps1M~DIyos;Z{##>hrqv)-=Vp^lU?d50NmKiTx zCCG-aR#>&JxF$SuphQ)6Tu3gDSZ}+CFrem`-(Tk^GThe9XYuc-h6ImI?}Kx= za+kJ*x7@b{W}yvNJSS8rGY_HQ0V65JFxYBa7j!r`imXJ#sTe1(1ga{Kpvbo^GOvy) zv1&D%)K^Aaq0YI1!DTR*VZ>C6?Nxgir=6viP&My0*{3I=>{9CG;OkIVXthTmUS*}A z{IhtY*b=jl`rUMyFQ}(cNIfa^qD3A=hO>7IL$JJv2YYPB_eTCsl#x#K@VZdr0$6u- zSKOIcpoxAA)YMYjbQ|M0Dy~O1W2wUVWF%D8h0O98ih>m?ZFZogctsa>4dSrR zBbcs*GL*=a?@3|U5_) z>I7L9sp>rJVjE`#iup_?*P72V5~x(fdBfTj_^Tl(X-P>qD)2u`CC87StyE2kVW;JV z;O@qLPAukCNW3JwssuB4WO-D+wZ&^|)Xh%h$#berS(8ZAXt{M*@Y4M@c)kh>1(K?# zZsXNVuUz?v3Qaa@lg>%S18X!=Hi2!S^DO~~W86Gen>;s*PMp`vF>H6}1h#_;|M+%B ze{iJy>HFe{%)N$e4KYw+1J4n3Qu_YwrR9Qo%3%I4WjR#WLsgQ)Zh2%^h$;#LGd5h=a0z023(6|CDyS;1Cl9*VNyKk9Kb1VRmU6X{ z)+p75{*BH%^5Ep4x{J|#=0T{@Lz8F%6zY!z}n-jIJeL|g=v$do9 ziSwh5Y^uzIN|69)kZ{u8R+`%$pNw4yloC)qcvadEbf5NeM@{MwA=j6z6bnnzJ)4|N z*4)q>1*QzkHtA2XeO9EHZew0#UX3Nj4=8qRuWk>kjp6a*{W@sKkigzj*n;dh>U)TL zY{XO5<1yU9P#5atipQ@P{CrpxQT}3&aBx>!jn|^Amv)s3t2O-+l%fr&W%nr3<=PyZ zNEHW=j1Vhm5=fKNlAsC!B~PiSIEmLeZU2F6W5($oIQiDTu3iAbvQpA{T7)c1v-2D( ziGx^nC-F|yy*u~5V6CU~Px~yK7xn3ZuoWn_4IZ{O_3<#IsM-?t{N-To4d~daP|8_^ zA47awqpbB{?Lc>+&q8v()9rQA`DfTfuyr z9s@}R?;=4mL?WkUcdLEn5Ybj?8o@GI5^VHWJ>23RYep!%5YPpAd?`CAa3Tj1o?R0` zo3D5_S%h9Cu-FIN)|@P5QPw<m=$ZTbXjciv9N%8cuEdN|A)4A-AyZy(qb>e5tAg{yC@-NA{)N{N0cP2v!l56Iw z_%Y!iETPs!{F&GtTJlu?Pb2NkwP_b4_Y4OGwvnB~)IePL8OCZ>z(y?(bjLvI4mSZL zrEm}ci4S!&8ECba;q^jhJZ^HYb=6T;#;YEvNwwZgZNq*UO?DJ$GbH1@yM9Atjhe?b z;*#VlUcSR}o=`k(!=r*c`VgDAi_ZKuQ=0BsN3B1$j9s^LjYJsD{AY>qgP^(abmqOIG z2g+%wLJD+S9Sxl`w1vDVvD6h{ouYlRNf@GnJA##_M|{GA2@N1gT>^BPYWG^OS@_%B zUDvB660;B=5ynxgpgG?{SgW%LfS%w-ZO!)j{es(8^au(#$jG|<&W&jWJrUfYZZt*P zc4b%_$Yp8nAr?G=Q}h(o4(}E)LY@8PMyzw)K7`xQWx)c%$?m$Ig2So^c!lztd0lf@ z1Zm;qAPx`q;hps_103R2{!_^RN|reGaJwK`0k~u6RMdUw)u01c#|%!d2D_W=)Qf8a z!I(K)`X(P-(=Lv~PQ4voA6u@1L=)a;vNPrhS3&xzfWVCr*(?XO#wE~fNw`l}W)t+? z`V;nnR>Tv8%M&wBsw=2{7m%~z=bE!S6A2Mx&(2{3oU>~I46<3unJ^+4hXh*d$tD95 z5;q=ba~A}^1DMGcO4qy50c$l}n^yR9j-4$lIV4Gi>y|p6-!61J?Am8CaveKXM0Ty( za;VlcvzfsXuE2UlRX&jizr@-L zq}ph(Vv4)E`kAxM4;M)V4be({SO%W1-Z|a_bp?vqo$k>uM)ta??wAO;b z4c|=a-P089vj?=;*)#!1`>_3@?qMVW`eO78;cG9HwY$*<_L6DS?|N8f$0q}qO1FNa zP8+S35!|hlD^oCBKR`|w)SIUvXftb0YY$Uf$_Bz_b_<2=Y;OP`RAVhKY`_)Kc844) z(1gy2tunBJ&c&$V3G_(`frd*%>P8< zNi{&Djnt|cJ8U6ok8O(??&1@$Z97&<@HuuS)!U#|On75;s*I5>P9`F_TR~^ZhvrFX zacK3ZUaMSR1B+2Hdse8uJYN@eTQItfy;YthI1#xs%X zn|E}T5L^pjD*pZ-bo_9w%t*jl zWq$5E6pRH-Ch3HdwPc0TVbulgz0!yl?>B5rpB0M1B2$<+*JffYi}wPiOv&QCs`YuO zU^rQ1zkmK#k(?IGxL~b}OskQ@DCG>7?{iQ?ExRMyM0806YiKBc_>}76@L6~Vxr?h7 zF11<_o%rkGr)!pE!-z*LPF2fOiI+hWcs}icSaq7m5>1`95KSWvv!}^=>K}<=XTrcJ z)qMk6Tj+E|dJgOd4SZpRIpYK)KxhvT1CVOY5!}G_I4oW%ft+Oe>Hef8y^5u89cSPd z+|8C;_tR&f?iH8ees!|dAAz*k3GvaG)$QEHREwtO)mf>0PRj~TQ0S^pc6AjOz1OK! zj3>RjW8AYCmzP=tKcZfv&~?(u#Or%Q&0Qs+=wQH2m*PxUvJuZ$mp7`{+^l712s7aY zt{XYf@LKQIn_8%4>E?&gffO4qT(DEun;G&2P0KV#u(MrG%m#jBlu+%KVDJb=2Fau) zcHU}BouzI#<)MNKlfCf7D1?5RLCXQUGSqkhzw@7~W+cBq7QQt$L6B%LXb9_~d>#r| z(vY0CSx=IPWsJmpNTT))^K{tZpw>#<6=_gQ8LMP2Ero4s0{z{0OAzW^u*6zcx9on~ zp7yR=!gj0LWM9hS)B11LdO#UUx2vKMvfwQ=0qcZYLd0QI~EeLj8B`Z+$OfXJS^e$4-SFS1nC>@qNZV;-J%3vFLnTTtY zWYmV1y*ZM`FKDE;=xUP^Xp~vtAWA)*APe_C#Re6B<|QvImzhEHa+jBSU)vN@SA>%Y zA^J)Q^xIhV>F)ipSUtctBPYpr9eA01+OlPoRIyNHGVx@@OnO+WqL}Bb@@-U{qSm<7 zgKm`laJM_t(>xJrY&N*~+{;C~U;X>Dspw-(F&AA!V_@|z-4JEU^>h)Uy#sv> zyC=>LOOu_4Y)733w0QFt@b^$FJ2!f)t@%fUUo(?=Xe*mO4h+>nBPU^JF9~mTQd9xL zsHuI7KzAU=Bu#8!z@Sp}064DnrV~2GjJ4UGc&G5AR_mbzEF^nVMw4ZFlut&A1N@G_ zjABA4f@ug15mg5E#NCHUtgNjzu3Ciuf5}AXC1xukK-w3K9+CzUDR`^FdPRlF}m>>E-rn=&{c9N3#ZimAv4K}N^X z0(>X1FTJ~Mq+(jp+?4#EA(_MUKJkX6=?%QD8y~-vP=t*ppHhdgvh{#eOe2YRHwa_z z+b)HqR$T5A24Do;i1I?0svx+HHj`_wGk|oe@w*;97Wj8Dh8HHGC$;3G?A=pfzINw@ zTS`}mNR22+ru1@g7Uh*C<6??vt`_z@r@?T7xkrAHW5C6BF=8EDL%Q1{-{Bu z4>K%w--=`iKq_TF5&3_$sMjTcjd@C3o*>5h3+JqD{+y*5UZy|SToYkax*DI!=Pc>7 zX~GCZC>b+YwMus&*%xIa!7RjfuKqiD<#@;=L@i6HpqlLd-1?TPWVJI@7}NsAZ$#XHjyE@%CijFUqGH9Vn$R zBk@v527rUo8G5~b#CMc=6ICAd`!d!LWyQDBbk+&6dWb2{9mCKx+_sfMS6dI$lC+Zx-oKI&SJs}nNif`*Kj^W7E%14)Qg!WB%VG9r|!M&ctTsa55& z3*!vfchhR+x#23aRijZ2y4YJNSL-(1fm(qj;a=BU5cSo-bx%N8_Vk32vMT*2Ln@Uq zU#m_;eO@T()5cDXp!~W>ww-oP+)dtm}8FmN)Fwr(W zj0YwqwHs3oM|O6X9v1#a!ZYhepCkZg*q9PR7H;4{Cty)-+{KngQ>8^B&Q*k{ofhZ- z=F|O?@?6&*yjI0KQJu5@*WSDAN|u~gn)7-J2OEv*2K8y*1z4)IT_UyK_W^6B>^6{9e&+=o7sO^7ePPHpYk*+J*VEkYDnUT zE3R}5u$Rp!6m;q)=`QV_GwVW&-0iV}>U%!?R7X~KUtB<;`{>13ss1eTxNV%gNUGrC zB2R|`XYm8!tCU{gdG5%yHCsgw${heJ>y!DC^;CRPOUO|+R)(;i{^=QIsYtKlZJtfH1?C|Xt!Ztq5c zZ^?Gcdp>5~C$p$(vUSw!1CUnczVNxQw|{X!mK__B?;>&$MFadCl3=4|KUvPW1g?bz%7GNkF#0l@;w$ zGzU~>Ws4JG@e#m9d#O;?OS}bDn+RD8IkWJKO1wz= z)SQ>}G>cRf|I6J(s5atWQ?OLjaZ`B|p%X;J4GosI8CcXociS@VI<76ePiyHFn`DY0 zh0inJY^3Hiyk+~*lLw?sD^OL9Hv_ci%H4TYQsyv#Ml3qnf_D zi^PvBS5=@<9r2`ye511#3cM6}R9`RJX;wZf(uutWW6e;bugeZJV}_bU19>2@X)jmB z3mngA%@!EVsy?$*=D~&2_mF0xfh`+{x}6R98up}HjXb9beU?* z=J@CE6FV;ecZ((&OUj7~ghHvy&}>d+8-Y;t(g$-LzK5g$FmtHX9|y0?(qBgh%en^3 zf^@3*SaeZq!f*+2rxZC=SAg~Rw<6J5_n`zQM_TvIPBQ`t$KzImsfL0q?c!8=TpluHfAJ}HtcCt};SsFYs6>JV+3G1X<$ zr&^gDQ26C?%W;3dE1*?eCq)v40199xK2mfjx`@QofU~ff zz%TrIMxc3+^|&guF4n=;aqGpPx0e$mEPg5qGJ;&1WG zkT#gc*==;vNg2D9)O9F;`kh*S7En}cQs4xZ++`IiIdiKBo#t(0G)nzibSd)WLU$9N zrCg|9a)>1w#Rc+_cjL9K=q|N=v7xTyO}GKcN>!NHY9yQm9N?HWSc`ViJ6aFX-kKN) z@VAmkmFb509{JtaA`&WP*YxJ0ib5OB!g)K z$qQ~Tu^B=mY-so(0TE8|c=K4D_fXB3pRoHA&B`vlTlhWsRxcV0p^H+#F5$L==ae>I zv%#)CgDND-(PP(F$*ZEsb8V1Qi@Dt`io&C2OS(e4R}BFx#HqQqc90pNmzdIFc7vRvCDy}yvr5a(-|EOK&v2Oyays9GP{xBNDod2b8cpZ)AgmD;dqhufxVdc}#eFmAz)bvu)q9MkEITkPB( z#mkU>P=qdOHqcIv{_Z*{zcax!lNX|yy*QyhN-c9**nO8-qGG(d@s@c6TSFBM0@Q-$ z+WHVtRW{>f6T~UTgcnsttaBHT90Fa9uB=I`>T%8yZ{A&Mo|wx>{=g2768eiZ`P|Gk zpw3VQ^jTF(<0QSqgxc91y5)6wsg$J^l-qjP6QTej@MYknH4KAZY(nMI_u2HUX0I}< zC(T**tXO-ZD@zC#JV^5vh+txoQRmw<%jIh+I<0g!mXO`EDZ#=Mc4H$Y?Z_@GK?%L7sN(b~m@7 zhLM26k?a{;8rtbZQ7a(lsT#Z3ZGea*|7AaXb{{HoCjhJlG%o;&m~$A$jsjN0*kW*u zRmnx?wcIFqe}H!2w73I%?(R=CrCe%DsFJ9!hG?5nbtjaqmnP^0ewa8y(l_n8-J6H& zF8NDapr1F=#E8k}>w;cyo9AHxg zXDcA+boFZ6>IMXzwl=-F3^BcAbrW@^f}v^xKJcm!rO6jOJBw-z?nfu-MBPGB7}bP^ z?2@WHLnm)9;jW{%*aZHy5O`N|6_u3i@35o| zxy2}^+_t#+&wa1J;f_+qpmu%7s{3RY$UrPK1Vxa>D{cBZ%Spr$2X<`LuF1?OZDY29 z6ql8<%oz5L+Hmr1lRQIoSZFLp;~FWv;&uASHv^%pJX$SONzMgRK3bCN3?x|1jtQua z4D4QbzMkE=3k=%<)>xvbkZ1yJN3OMmmJGmla#M+M(7m?&O&+a9C$@lps z`s3)i7NcsOBZ~Qy>-(bdE09IO_a0Wg=+d2=al1Q^2lArQg~c*^6KW&Mi!vt`(cPlW zkCCZdi(GfF9N4EJk9a{WwhiB>G?>&y4cv6x_cMrXf<_41v2A6g4ta>JaEaC>wn9;8 zbv0Nr8t^IzZ$q@3#D#SU6QZew5;e$_nmNbCivLO47OsQm`1FAnhV*Z47PMAzbOJ9D z`sEs-lB|U(?#ObxX!J73%^vaX0j; z1SU>3t{Vb>)<2mB=de91Bt1)yv=I~(R4`9<5mnn!K)Q@@?}wcyYYXzN(7a7OIvCaB z2V>unqcKzL>!+qC$|1|;Ss@H2LfLn#3j^KtI~b#pqmsMTNVAd83wY)Aek2X)T;0;R zET0mQYY#L5!XP;yhD5G{9PlD?lJO9a)A>o!te?w~tu#c4je@94_M&e8iS88^s4FTo zmGr^)w8^XfXcsI3Yjlgcpdla-8}@7}1$D6t1>C5TtjManDS0%7M5?EEFc?%;a9-{% zt>6U1YQ8$Le@>IR!FPuCjTT&eT@Cb`(~ zf0SfNJksQ_!E1j$a3rQ|b0W;LX&X_vQ|N4waVa;RdF3_hW>kqH6SYXu8rIU=8Zy1b zpk#GL&<5O88w4?cP0=h@CR&9GJQMtzLI>%DK+I3twL13AL-`?O2vej3q;QZr|!O+#U(6JaIN;RY^_VFjd1H)3Nrv*#sU9PB5bSyXCr zz-mW!kYVhY6_Ym3`khj3ukq@ zdk0!A)cLgc!%-^Ez-4^E>Q(}26>Me7uT>6B_Kowcp* zR%o9oGQ9-x3{&fG2kFDGl6e`g9#3w%X2AD?eZZ{|qc1eMlv((1C!Ug|q+w;o;br(f zEQjiZs8>Eid|4c6=}y&vO^#s`0!XYcmZXyyux`}X54E_2fp33101P_j}Zr-eIqZD>k-hTQdk7mFay#cgl4M(O*HiDu+(KbUT=phfg(Gx~{oJMcBAnknV zgR9^zlzhT^ORx$T&8gK?ONtLu#nrviDyJUT`(YU-G+T=G`B=HT^2D72tY)peQJOo3 zbmi46qRi9n@lcDccF>~FD^L|>1G@?J0PAgv;cfiYR#O76#r&gJ*daO#;rPA*bE38%Y9=e)!u>zgN%P{L=n*}^cZ)}8v)@v2ePQAJv zQ$W%l3eh@C){s!XdNu%KDT3u&pgotw0A%mymD5CLosV`|LGjLg4x+tnSNk6R?rj#M zfje=8gdnw?h37p($q}8LJOS~~7UUz!QhncRbj85}@!8$t$Yc|I37+BW$3=~#yCXO| zf2fA8dqCf1SnXS&nvE4k!825_CVhrz`_r^-X}hb!Wt^#Urnkdb7pIY|w6xBGK})OB z|AxA?imCi{MiD;jX@LW{-j3|BU_bl9+rAml12o*|8eQ5#h)>UDrR}}Ys{P8LD1^2Q zp7zzqw|%#pnqU;#yIlF}_QaNPhe<3G7dBMEZR(66DY~qvALmzAybzd9_hEhyo*(6TGOVvtcDo5$&pt5!-K0* zh9WyWCqLkg0;~Bsz|+zFf{;BOzRDcQi5xE9XN5+jwzs)&M%C*LdAfC9&jLX4sb)Eu z=Aq&n=~@E^q-|0tn4EogZN?L)oI7{ct^g5}NG&*r)@m_g1(({!GG3uo76J5lR*vdp zkx52~%g*86_Z`%9!WL2ubJ~+?V^8WpErwOy?RDuPC_g>%dz5l?MugH_Z->*D2zb=m z`EoZ()v0kF)*@-Iw{`c06~Pw4(wS4#z(Q6 zHA_;7-kx$t^mfdh)Qq&!e-5!3ZJ5=#eq~80y<2uv3&F(RUiP|{X0m}jj$Srm9?hNV zv+0fEBCjV?93YizS7WLrcSFwRTiqVXEj+_d;0h9_;1&nIA-HX7+sNZ# zJwIr_s>U!4Z%vmRoH`P_Tun#KkYZgaKn;$&sn=WjP7P3sJs|;KC}zAJ zgk@{GT|-r+7Zun%DG4QXbkXz>Ku${xIn#*^D%%Xz_dF&wXqD<hnA&9m zM6AZe?pu1^nZ{!Dal0JP=LcL7mQdB?solN_OC3HW9V<5gb|?aBND3Q@;il)EadbdQ zQ{w933qxy%oTTpR=x@qy6O+j8bDFoJT+%Z4oULiM=cV@FWlmO#!3|r4J|Om{JPHY| z(nG{@;FV}|MekD1S-4nbel9^GMfzRcraXD4r5Mv?Unwb|VsEVE3oT@ulwmiNSDho- zemX?H7^CUE(bkTwDP?A;Y{;df}dM~mZKU7V|q2q_}uIWU&Hpk$^1%3 z5b#pIfSjvgz4~2CDL85jsD9Nd->M24P~9}$r@~+rmyL#;(zO)V5h76t@RWgc3F3VN z;HY8LD88u?+ZIKXW{p4<_C^WFjm-@tw1dIOWj-S*eRYOfw}2wAVMbndJ0fNc1chs{ zCL@5!m*yO2piUTmST-C{5fMmSiZdNYkZDUhl$4X@nsuPW0aw)L-tz3kHjC-2ax^^0Y&TR|V@-SD<_Dlq? zh&%?3Ry0ht0Od_{T70etdms%lOU zP4?yJ>6~^Q(F{8au2{YO0$WkgZyn!+f@AE&$?HTU|Yb{$y zwt{&~)vr1qscE$^JX;hu(N&lIx_^Y_eDd7w)%XO;=PZ38w9$G_k<|sY5+q~6qEN3$ zTNF^;m(wzoJ(_dU=(9hRO0__$)nvX?Hpg!4^vu0vR~w8D$jn3De9G`eEmawtX=%ZWT)a z64Nx&E_d6#Yt)SOpMAoTk=kLfq8My<`!J^woCbH>v?`Bk58lw3FE&mh3LVRqtGJ&G zmm&s2YB99p#l|<)pSNKh73_G=Qa_=XpO87@hMm>(vEllEg!(+s>J)`JtinY|0liprU*zqZBEcZX*I42L~EkqZXVSq+|UeN$Un zXNpv!-ghf`Y%^a)e1PCGfK-#JpFIlPpAb}LwJsk)NQWjU;4xv+B(lE<{Lb7QI(ZNh z=QwBie(tGrEhQB>W!8B=k(Xji{ks6@_pVoBt6=E-{iMBuQk^d@In_QyLzyB!kEQ6X zcL@1sWsz%ImxlidjqgYD49<=Wq*%da20hnO$a{{Mo*q2|M>T z;c~hyHbl_7*|~nKFfUl3>9C$F+)%2Gch;GX4>&u3MZPLTaWlm2ltwwNC;&e*zVGWm zj3tLf>$Tt*_I&8oTzNS^yqb%1*4<@R5a!IND~@Xy2c|HudLL%-&FkY?^%~kswR1DB4esQOugX}P5>js_tI6tJuMs5Tu~j|9}= zY&b=2{S&90XilZ6x;v;N0cV=nOlxsO;tqlmT*?*rTL9-``5j0rDijp@ve}m0U&H2I1`z^B2r(A9&!njusKS&fLFIvCUO_( z#i0N|gpoZTx9B)bHQY?4;9Wt_D%^1F>ShdT4ApwL_9Ii*NA>c*yY&WTP+}j2;_wMi zm^(9Dq5PyBc$!VAE(kZw_oL8vb}$_5?39KUp+frVROuvLX@pyvkhVfG5L6m#e0Ge> zS`H;peu86j8usKEs(O(!)}mDQ1&0=aj*R2aExHFS+bPkBb+SlE9H`F&;(&`UJU^7j zXg)>?1!t&m(XSFH>?$YWKi55#ic9}hQpwWIe8E2=Gx3bPA9U3RvNMKH3YOlHRt*8{ ze7S0YPs`L8XSc_Q!1r>4R>&|XRoJs{sd(L`i*ItciUg=umR6#_P$V9DIW!U3K}eC# zFB}F5ivuGo>W~>OBvdgaRJI2Rpb2|mJJ6jimnhXN1GqPyNvw8h8>@-Pr>2AC&o~uJ zZ?MQ0S;rE!0aJGrT{KbB@(f&&VAV=x%CN}BSvD@rj?{$l`+}+FE{@8p5Fv~~8zEGL z{DW(n{+umf8eUmz*Esf63ioI5MiSa}e*0;2FrM!{%<1(J* z*9P{>bT}}q)oPsTgh=t*SMTgLWn7Y(=X5O2Q1J=DDa{UT%EpAejc;n?sV&F-eNL>V z<#-#Ws$H#HJjt$)#T<5-th+guWOJd|+8LiJaFFstSQD++wX9}}e7KOV^@8iF9+?rC zJU89>&I-x-$|6smt;x+y@rEr@_kxD1?%eIGf|&f-V(PkV!_+9LW?+Tjn~FyWlfNEf zeKHl)a<-N0+5BV+7fE$|v74L-TDVT#F=vA-cuJOXZ>Y8^O*=v547C9;iQtC~Y7@$7 zD4WNGVIsr`A=lIq`3evHn=JIOlcsmjwgoFCgdG}K0=v^jJMj}zn`hU|771c!!;IJ5 zK(NEYRnKM4*bM2R6(9}?`UA{SN(mr#hcj@gZClr`T8y685Xp`eFn_6FyJ93+TbaCz zIkDWVkr2S`QjvHG840DyTlgg{X#&^}VwP_GF`-dVINBC_YAe%{*foq|g>+w%JnGh6 zpN+7u(HHdkToRNt6?P$$#^V}~RTZ#IPF>3&iLE$*V1F1zEIM1*l}ac;Q*Kj%3%MI~ z!RxSMldVlkx3`uy{c0a^I7|4bkWNyl8y1|&N-8vIkup#Tat0y#Y~SD|s^V`x*a(qo zyoVst)oxGc^zg1{ksZXqv)FE<+kW&cd%na{+$GVFr5;_;X1YK9X?4p}GzX^Y$>0yV z-J~T)QH5Djz0VYe-LZcXVgtRQja$7iQWX-inEHAOSYO#k_!T$@n%S(Y(*aV_LtNPe zNZ0s9C+FT3?rT?b@i-LT(~yeP?V{F*#$O^sIS7RoHtg6_+dHAx~D-D@I4Tw#q`l@*gFH?(Jk94o^l znmZDVz|%d~i!P$z9EN(v{EeO@0&XL@5g>-s21;4NB3(UPr~^COz2!u*Ug1v27I%tZ z=g72J-(L$F>Rz>k*>-SZSI#c?BZZ2tA>-TQ>oQ@7twT~;?NJ|LwNtvM$Z9;)M#bR$#c zVp7`ybK*DSCP)0`7IyAoLu*)B4^+1eZcdDE8_bCcwJim{dPJ4bW|1?w@AY3EQ0kQt zgYF`l8)GE#$2*~X@f}c!oz6T<7QLMvI(D0CQ(+XblwoFgAnKTOT?nBZMPsiOC=BpP zSq}72q#sBRPxU>pBI^JQ&do8LDrIpcp*~udLQYsximw*RRtNv20Uuy_6zL2|Zm$D4 z70RU|{@-Xb7h-y$?ZnrH3yv7G)Vn)+_{tUs=5j-tCY=-&6`ED8|EMtwy2!JTDFk6h zRJ%f^>=H!dL{2X|N_6Jq$P0DpN$nL;BgWMId_Wo>l3YIuw6)T?&1$zB zZGQqoOYN#ZtZkxJw4W1Da>{eHS;%-8T%hX560~s@yF4ruXlMXmhE=_qU2($qrHb#F zERRkTBw^40{`=QP4%&RZtGaa=165HjO*IPQJL1N*+N9h~&Lz*iD@; zyAc=>a|tmWfrWA0a35H2a&H}$n~_CbC|O5<_$97sv2ZX9W!w9>x$Rq|pQGSTKWUxp z>K^`8XT2>JVaM!nlh0HeiK&!-Il{^eyawl9@K7WL^i$NCJS%NN_fv)sM&9CTA?pz} zfzLg}g=h15Gt%Pe@`{8-W0!NnkB;CXAz&yzoNx)J=m%!xN=MRX$9Ubq7Yu zObC!;Wi9Cv?NVC)9DjNrst~{>T6mccVmt4xq63PLhwLixSnk z6-g7ErZk*kRVMB>Dg}Ams2s;XrfxmSjKCv7$i*mjhd|}5wYvBBjj(LCPmS&o6qE>O zwF0Lw5TiTVo~COp(e!{e+)O4iBBe-M+?3;lh}r#;fM$uv=7(78thEqJH{P2T8)}5Q zb7JsCy=ct8{g=tuGKK#gS_<+MhIUgbk=TaG63VJVROza!J&BTpZX_kBCF41VQkY~o zyWLeh5PpQsl}!F&JwpU^uJpX@-_b@=5bbJk4R~Hgqz9C;0y`5M726sPT7j8`H(EhE z^Wtw;pwpM9!}*dsKYXl2zMT%ALIh?O-bo$mGAuusPoQBdBpEoG>T|9y`*5-YC{SOu z*%1(JzZ@sbeo>??E_Dl^QNe9M=Kvn&eF_;r+G)v)dNJ=(@C4=SK*7%Bl5GYl*SqmW z#ispP%G}qudkkP|cGOUky`|p_jcJ2r*H?>S%FhEz+GIW*Uc)8N5N}t)oOFIwR%#US z%E{0TDXkYSNIi|gW|0HrT~KRenS49uEk>hgplVHhH|E<(5L6TmiehhvS_cOv3^jQR z7-9gzM4T`-PT&JGxZq4H2m5U1URQXfX53&?mT%k zG*}kOgJ~dy+?8FTA5*%E*NN0hpeCFQbG3^AzN)WA6A53dqAP5$1j@MsDc_&#vetD+ zo#{g{sn>m}iuPz2jA)f9Dy?Rmw;DAoOEu+KRv-sh1Ktei+N|FgKv+m{dE+@*ynVKp%i;xK zceTS7y=tKhRGT!MK9Jm2oEvEJfMS#yqgkkw#}aubX&>F6Vzl51#)cQ+zATUe;aJPz z#w1o!fR~UBdz-jD!}Y__kgd_pKce4dq&@gr>~l`c$Wv0yR)VdVrb%tReC+^|Z06hy z(|TWx?Y^kJlg!F4YgL$#Yb>hAuxOyc-fxCV;nRprd3e}k=mkG{?;0r}NDdP`F475P zU)geb1c|%NNSxB>(Uo@txC>c|6tWF4-PjV^ZD~3==huS*qVC|d8`RE2Gm!jInYXtM1QbNU1Z_tvtGPNb6 zlGnuLQx#ky%5C?+lgFvBSB8ad#)=>d@f^wdXwz$8dG)5GFmHw;InuO&h^9g~%2HU& zxgbT8z&Om}?>g-vo-v9EkW~q7s|8h%~2EYdye9g6hIHqwFIUyvnJ z2UdYu#z<*}t@^?frDn>SL(U|+KaJp1M@K2dGQrugm7W#GrjMZsG*XJebfmo7*$Cii}L4>Q+ zMT4*-VLQNLPvyLrFjV99Rff@Q8s9O*otXbC|#{$@_Tdn^9U*39K-q; zqO(>Jc~151u$+xd1y+#4{Lw9!6S`_s7z{=pD3?Gh-a^CVwfHE5QnMZkHU?5ak@~Z} zXqK8n>T}Tykh`Q(My+OVM|vjLw;FMhKZFkVSz7f9d2;_m+qrEoQp_>>*7=@ zgMpYiOB8wvHGdDKDc!uJ7I*QN#b``F6^Lt>Fp@<_ZkV8$Z6~A@x>(6FXXD*$&6I4+ zn!C5{9tDa&E!Tty0 zV5>f@7_S(+O}!9oLYn6n2fW3s6kz-v&F|GsYD~j^Iw-cVS@|~Z*2?V>lEDdYgga$D})}=Ex2D& za*em-z;V_cAmKq5ccAX1#h_a)xOK#h1l_f+dTs%-t>J2_4vtw+%h6AtWKx@GfH7RJoG9gb#v>Y%Ta1aKqH(AIp&Ol!~d&o%RH3X*64Rw~F0Kds>X^ zb6i6J3%kv@txbiNarg7z;opzfWA*PY6klIhuAILx<{veXM1M!$~&C3E~>b03uSrWnpV!06f4yg5YI_27WXEd0(Kuu~=TH3XyQdN+e z37dtABeM7ssz;tZ0WhQKS&W>OCm1#b#UMKw+Ni?5W_W^CQ4u=}k0eyLic}TkmzH1E z@FqqYIe}bQp8bMjpiH!|IoE4ch(daIEtd;W4(O(Ds~ETY^+z*qPNv!A?}4P{vP5S$ z6F`4XI2Wx-@iWD*EpoTRozS~Q&oinA+CmL3y!*xan1>JEWr|KS9L zC}+Q9Tf@O}ef0$Y@o4g%x&hrSA7QAXpYIHwx-C0VQ}6xNcxT;ICLU7w?9+6dPFLka zvj$@t2+8lOIe1REqLMnzhV9y&u)DDG-I~bQP8JR&{6(FIu|ur1Z}Nc7ZiqyBvaw>f zo4XROY-R??$BRrSkvFT=zM#}#57ZoJ7_|fnDxI1D*iI*}zx;sihJj(pl+qb->bjHf z6(1xCKA*M`4Bav9qPNZTl2}H)#LL{RMm|GW&EdN3ns5&H)#qpYB|^vX_>0;I?d_4u zrJZUFD!tL`Uu_S4;nO6>%|#RNpQwqYZYa=2qCi=%yc#VQqjRM`+99 zDrZW3jLJ8EvKt`XlG#!DuE~wZD12jLfmQ8tuRg&I8rI{`$LC>a1PU*CnrB}(Vi0eJ zU!a%P7SY;dR;Wjm6c86^v5GWp-0Mjrj^sT+XBg}@$SKq)a(hS}sc2)(JYA52UvY`s zs5jG^yEoEuzbqBa!U;h>>5>dTMueyZ8FNJO6;c9E8$g3W0ts%SVm-gZ21MDux|(*^ zKU~kb-Xz|Gw}^MBw|G4-k5{Ac=6~fYoh1$-ZpdAdf};#JQQ_^)fx@6e4b|&fAuLBR z#rk-`WR-g_4WExthOD-!OQzjtn2~L8vbIEeUXKb3>y+~Ze?s1OsUgTWS#b*aI`%dI z4Cy#jm6&bK5u6y};c`5>tn@Id$xX_as1Ou5oT1n;x#ebGF8U8(uh2VT#i*(bCPbArp2}j0SjBHEvb-~p25Zk=O zEfy-QG)RX9Q%A_KCd%UhLC`v2ReIv{a{|VbGOD$rdJRrSh~;2L+TLBhhR-omK2b@E ze#tWMyT}h7K%AG6wjCxaZVH65j-$pN*w(aAw0_wn zL1~Vv7{6jzJlw93lX4W|YliJH6f|fdd$d3j+J7@ED{nhnowrjQ!mQAxje+QI3j!6TuMGQ+vG$&pExfKbkEP24N_N{S z?EU=+jKrnMHm-4w14?wg@Vt5ewuSz=q?CR+Mr>hGExBE$SfJmDtz0TY7a&JqHvzgE$Af>RHnu zduUptda%(3OR`R2$XkqZj;>-+Sd7 zp-DS1Bsss}Y=nDtG|ZexsFOY(f3Kgt?(y>bDw#5O(@8dFP^psV0()@6S4SalOi@TmXbf+2_}A`AO#v>Ivif~U!%U&5lXszuVU z2eZzTgr~p)Rx1fq%_4+;Lo-}BppCfQrd7Gc)@sF-m~8%5PP2uL4^A!Aw}f|*SWPu@ za1<3@m73<}p5%!XrcrnuyUR-I4@`=yAzx!eOtW{fYQ0IJF4mQqR|F_?cK1l=n_HOF zPxn6R7({bfb5YdA^?vvTmHA?^9sYiD&6==(TtL6<9b8}0gaz$TRAquFlbnW{mak=t zHGC_dQ+qdMjOH!R`(~I}08B2{ZqK-0V^2<*32$@czYjZVJU`gj9obm>R(YhwaP1^` z^07JtlpvV9a2fFPhK2dDJb~-+=0HW)W)D`hbCz;6L$&o~8?@18hT2-&@K)9&v!rrL z4@=K>mN+rTlCceXBNOtRfke%Ai1AEiTBHHp$2ErKG3=6@D^{Q#?}s0`V8vV;v=y(znX}tz*Ns4%Y0|KRNB~}IYw49-0spAFX+nF zxGspB7HHxn@&MzuB`9o}?@k91h*2{EbbIDkdB4Q9vLAw_LKIHJ=HqWvpPPia_yd!W z2QhA}n|%wG;lIWXp-qw`to(hbAnFnrM_Q~*Riz%1Yw!NwxKRbT%a{cx4M5B z<4tNXW21q+OYzklHM!Mq1V!Cy9n0a}I+0=(C>|5$z@;t@07t#lf~tAa;TRtV*Qoq# zM=gO2tycCE+@vC~7>dl;$eSOcMo1-MPSpz718Ba-8z^Ep_Q}@%-58u>rY=8nyI8Yh z+DTz8B*03xx$;R!P^^@#CG;43bom=k{E@-`xxOG!@(s(z`W*j1=R)@Gx@+EhJ^tEt zNFl>Qsj@{K%oXQ_c!=;%)s9@%4oQl&)s*aTOpG*{l%cofk5wqMgumLScIKsjN&y)Q zT*cGS0Gsd>ZqN2*hTk?mEa^7{rBWNK8igr@W6sJG49V96vZ1;x`Tf$x>lo##6VV7i zf1JHllZ({6L+3&48#5RzkrOBx_Rbq=wR>9N<)ao2&+U{lq6>A_#s&c;o zQt!vA)q1VyZyu|W*sfI$@%XeJ^({n`t(JA->%7p^w83O9xL7o_-c;a|kBn>qO6ZSi ztg0`1RJuY|XsUPu&QN~XQgBnFtXoNVx==+nB9o*s!-DNZ{Ci&-A?FknqAyqChpq>8 z_&sGbUOxEEOIDqTe@MHYUdI+dOxmkdmR`-7EGNid*O;WO$q<`XmF5ad}PNLTd|2 zrgN9=5$Mf@&vx|@(U50zIb99;<^~(OK`Nzco5rvdtfuZrge=>bHba5}J86hpYDXZb zX3p@O%|IswklOpCn1mGP3iPxHtS~TE_L@BGQZ$Q4u@u%1P|~M z)~)F3u?#~;G~3+HaZOqkxXm^!!mOPdb_s{8GSSn++I_Xpa zX$Dqg$$7@m4FzzDUAxgAJctABI-vV;Nk|xd^jt#4RYZ31$yysTPIg22jl$ckZhhr7 z_`*o0=@ZyiT!XURqE9#pJLz1I;^BAkj(g z;Xt^D)$&?v)-sf`%=Xf2B}O;a=U$%vViqBk_8yil9a4zW%^2)yu?Fz?GM(l7V_h-pQrhWw*U^PoxVR9q0lJdxn|r zSxNJAsIJQjKi!m(%{_C%c*Zg)kYb~bqIbbx=kCg328STu8sx6M3asHZ%wr|_%jz3* zdZ`y21@(<&p=ejasV9O%?vG>eLzfpFLHU=*{W(_u->BWmdUrIUtyE4ODdhUcDrfRGa)B`T*4CVmcEVs*R)K%9;>r~0QEGz z1q)oW1SLZTNd$KyJ;OkhlYczN$vj#x?&oZ)Ja-cF6!^cHDXO+%gR((wPOSoiq-%!| z)8HNCY$yhRHuX8K_KH9dq}0)IZE7|=MjOo?uh+KFPyw%7YA04(5r#2xw2Uv>gvvOw zbq&VK(!n|Rnk)UN9@JDFU?%C|s!H#2M1vuvh03CN8l&u@OQ4IVlCl_stjyX%CGG(1 z8>bPC=9QJDF7WOu7qe(ySOeyQqhe2lXjX^Q=5_4xkMYt(Yp2+N^X`}VRenDXyJZG^5A3rjR->ujIIQ0Ntg8LiD^#n6u^T9&}a8=9Bg$khBZdql}3}?1OwasWw zpaee!?DVcDlDo_ow4UFwoz{*b^{0)bDjk43TI0)iuS;7wn!rOu{Gd>s)aJ=$+Q#f9 zS2|8=WyZl-I>6Gvh)-sjsKC-(=(7e|!Xz-Ga+X2JFGn(FexDh*>~w2NiP+=F+DmN; zcI)*RB;P{28jEL1KiUR<7t774{GUNESFG5UXpe}}^7SnG*)K6{g%Tx7{VTi5nAA|B zeX@z~=PpRyUKf7&7EKawdEnHT>+)ekUPhGp`7PI({h`0xXhL z{Y+hm{aZLaeCf1-$v9d)P}!J^yCu+)LR3Ycvp`cWR4>H78E#ONuE1A2rSdtoAM)Tn z5Fy^yI_^J@dP41gxam@6FyV8DzItTOYefFGq_cGPw6g|K9=T>}9mBA5J=osCS8m>M zv)i3c>Yy~ab-`5hsCs)|GS|!V)fa)XZtI>OxG|{a&J6FIU2w+3hk$TRQQAV=9 zuK!dEOeVo7Pp)Awv63O}HhEhbplUCYaA**TB!t{ifp_VBECj?#LkCV7u{@pCy&25l zE(r@|qWF$3gKWr(|6V6YWbP9W>ty{G!M0K+qq?6fr2E)p(<(s+Cd`cvmsfbCD+CH2 zu5yEDVWHz}P=15rHR4U$<-&|1HL?7d^k{R2b|RmPgrQY~H8*In;(mZndi=S#mcpZ{ z^1kM^Pqhr6b1bXB=0UfZnuq#<1g#dC5|&w~xuX&W)e*&k5H$pMS@5B3SIGn-2%bv3 zU{X0tg%(CaA}Fj0OzV+!6XNL!RKL6%-j3LoaO^JAZIb7Zx?>Ljnoplhto=vjO3{C@ zsYIM+j7N-zLCZ71 z5$l&@_3L}ikuU5Rml)%ZwevuE1k;rF<1gWJyB>c7ZUo5+%%+!wH%E*HI_Zn;j)gk* z&7P+V@I5&Q8Ztbxc#FFo;;7FH$25gbu%vn#ZpDY_dmAPcRs9I859-B_?yJ0NkDqgx zSS_)V@~^t+V9CsY4oz~AoF?m3j+R~W+=kA-(6=ypVO}JMo&D7-j0zGPhmFF5yOH9g zcy+rCZ7Ns;xZ}-EOEOo{Tg)ES?>JNf@hjjIHK))G{_Ko|@l{9XRCFEESSM4!+^sE$uu6>Sg)ABlyEkG$4y zX7W$i5ecXzqW6xllAyBcz5`W1lXIYTHlz*9{-zFqruPc;PwtcLLuJAS;31>Xg96h! zYc#UNgFb0HtF$(osZyy#m2g%aro*eKDEr&9L<+@6Co!Wrmjt>~gew4)xDeXfu^VY9 zeT+qkL_BmA2$Tf!(?g{%36h5N#BP>|S!TN^^jBcd>%vrsbEk_gu+~_OW6G`BVf{z0A+>>AiN2}~u5jqBQYs0JF zh!cYVHbYW{T#0f}T=Tsy0EpFRfN>1jT*?S3Z399i@Z+EsguNONaSu(A{+a4~M$=+p@h;^lzzt zyD7Saw2Ot~i#6RO=iUrtfui3j?5*K8zryE5BJR$M#@k~9>hf^UZ_FOS`KsYLx=S|R zH-%AC4ab%OnO~H?I`L2mC&s<182o~0w^n%Ir3HAto||J>>zz^vBEa7p9ffc z_SP_~ah2`FncMZHTmt&MVXo~UVV#Q-GGa!CSgcf4;{DYFQ~vdAtu@)t=*8L>g&^ z`oCN6C(8jSw~^3RbioGH;r(V2! znd!oyB3eQShZ?=Q!Ad_DUm|w06IVJ!gx;ZmPx8CFScmkCK=OD@@m%Y?*3KQfT;52l zpsmK11?>qXCZ98Pqbm(@Bpi}T*UHn1B_E0=xw-_qMjm*s+D1E5;;aESlAr*jN&uV+ znxKBFgMr0N*-SZ=YFaFr=+04bOjThpYMY|!oqZkhY8Ik`WK3k%fo|Zt?h z9rPl^&U7bhQ>aL37Vd1;!5J0@R9NgAoTQAXvd39pq*0}g9TFr3Ne@v(z^n+sqH{Xc z-qKyv!lrc&8ZKui(N=TT(xsD7B$Sk~*|Q~ikz9iE&@9eKMh6lY95hM7sV;V&ga+?O zzFT`jg}{bht9Zaj=*We{iRy$do^iP6IynL{JKPSr9Q6lsewET)rfNVqerbwgpj6Rr^dzD1y6{@!Q zdYe?;v_~#snk_Ml;ApEBfJq`M7`Rexy2hni1NoPLUa3+oD;}|HT5Q?#v1O|QaAHa& zB+`h<#rl`3GK8qM{i@ItP-7D#Wu2LV5$}n&L1;~_=o#YLU`Lb9tR%RErU^kr&~+#y zB2w8wlUh}4*ugMk=<)VIwO6kK@g$M1=fIbycb7|(dZKY)JPv$BLbl6pB_1q)2}xRz z5L#mK)zE(LZBQq}=;G~|`~3tbd&6Ur%Hq3m!W`2%{&wG6O&Myoqk+tt9SKfa#TG{c zgkfP-V<>WWyS7yvpVGM!_F@{Qr2!lwtBHdzF2K7xz?O zQmBc!o@!jBgiL0-xVbZ-ATPySxB1V_tUzCC?%AwsIZq+8*BpVQ#XJ;Vh9lU*)u6CK za$CS>w@b0-jH^Jp&0(u!v0`?+r%D)ZhPAUP{JM***U3ut=@vJfY+9GKD}3Ej49niv z0TZD&75$6bRJsvi=1hx!L&E4HbU(9xc5z8ff<% zk)Fb?*Lklk0nwlypkL3nl%NyKhvf2{7}8Z_rxY8N_-#+h9-i6n!p&>#fzm6Wvg5Xj#WOhh`QM9p3n z1y`Y16}Ww~W1J8HXrEI%Rz$KntblDzrT$PX6L-u63;F=EnC>k=~!yp=hm|4ViVJUUZK(6AW~c6 z8*7hzwW`&!$FV0!g0NzCkLQ}+d%knz!PWOsj*UwU|O`M2weN~#v@w>FlX&+Wk zc*b{EdQ06V%U|$rw?(kr4PCkBlOvM(yyOU1NhdL)O*xg--vdxYt||Skhlu|9xQeJ? zwF;jQ)z*a<=9v=n@H2y`1#UuwY=IwS^iphZ zEASjzD=m^g1t%GDCEvQM5E7>zSsn_ZlYTRocTx0BfZP;_h`o=?NDaGBUAm#1(FvJ^ z>=9^embP1(&XggB$?9jjN49Tj)rPes${C!<{rUg_pWD!)0jpi0dkxsCfGG}9Vo0+} zd3KYh?N|<5WH-=>TlQ5&I7?bb;-%Y=QGvK3aQSc#yTU7tLSup~kNrg2npA&lUN-L( zDZ?_fDH|9iU8#ZS{6-5M+emd!>wX+kV1-g>HfkL)28jx6i&>n=^-O5JprlyDQ>BfQ zuVb=~J*g5lD{~ind{;;$7;?Fb)seWJ*v!Iqm&BI#ys@3N%V)*{eapILBHuR!MPlQF z=4n`mD0@&f6}7>QmEz1~xaZE7+;;OT9N9~l9DgqnG|^3E-2v1igsVVi4H3cZxT`2f z)Z|3R*p7H?n$1-4oaZ%$KRHVHjhCF@R;-Y+kP;o1)q)9ER}tiGC9lX0XtZ`-gsoQJ z_NY~VA}S7|1;}KY7A{#OEJmH@UR?ap{j@`I(_Q;MM(|3cbKEilNtYX# z3a`T^juY@ird0ZZ%7;L6Y?X#n2tadrODUav$c7`HHEgU$G5EZY^Q0_H%-CXi7d>&; z=60@08*t;0J>jO?^qmLj44H$wjc>;8nE)2>;8sr)#StSu)*ex2@9-ZB_AGL@5!tR? zC=UZ;C0&5)J(^%cMqM^0p(b>eXWNd8RIK*Ybj6K)Ku!EmlIY?CX-l#ejiPwm??4e0 z$?7l#&-xsuJ+2L%HMUybPz#qZNbZzPAOzYD%}6R_pgELmTlamEx9z^M+{(~en#u$T zYk7q7-akDoZeBNTA zm&vu#Tw47uO8{@4HZmW;EbtCVOtx@s!}Lu?#*v? z?v&Fg>Qvb(i@sb`G@jUt)~ZzEJn!U0>MtlC7x-5me`;KdnW8Q4zZfGg4*Aq7IneGO?2)PG426qo_&BRU(rT7yCy5mRNOwm+Ox86*mDFi; zyJ#J3piA_57g*Iv)FxYY)NnGcT$<02$u@q8Ijoh*um$4M=GIf-l=}w%Q;{>45g_g zaFMff$XkYvDN2=ozB?0Ag*sEB8tnmuiZaX1Nq}i6HwZ~g@sS=TdtN0jxIIBnXt*p}Pw_%ER@oB!_NIih z%5Y3)==WBYTTH|yUaXzEYZ7rz%dtXB(P{!zr&akZOQIZXBBX@0FQ8=@ycbq@O?Hot2*6@UTNZi%hth+boL$-5EI? zE6+9ZqKdUq(q4=5AS`2C7>eU{A-g-0{)vi;qG%MUpjE+%65?xu-Y5$av>iy=4RE;9 zd-VWtwJ~Txg4x(9_OW74s{qT*lcTMVI!JN!c2YALtB*$|YDuCo){jE3FmU^Vh{-P( z8?#u7EBX_L7T(#?MFPc&B|sb#17wO!j_Gr=xH%znj3`XI0(H(%bB9XJb|O36((RS! zPw6sLVfXgSuvf;16az6U-&zMraXlC|u0k%XAGh$;(r$jmDk*`Weh$98XO z4A*-_Y?!9B4C`G_Wzv6l=5<$#R@wc=H%wcdcze=^21(aCjx>Xjo7!6tCt?=}*Xo{c z*lB7|x^Q0-UY6t|ru!ynZ=48o%u_SwRsS?BlXFrR%WL*%#!n<3s?e|{=1w-iwS{UT z3m}j*q#vcsMh!v&{U)xiCQrgo0aPy*)hxYOJUgWkg5W9I>KTV7$(zxH&KTPoDmR){ z+RqH;MPeFV3`lE|Wuwc>#VKzMSwF2B6sI?yoSzJI3%;V5k%&SSyHsx54ZS@OS~S`Z z(j=aWfED&JKW2m89f@u_fpBp>h}=xb1=PX{txM%F8H_PYAhO8iaT6sJmI3lX;$P-S z6`SXlovqlb=bh>0dyO1vlmY^o&(>Z1$dTr9d{2In3h98*{bvy)tICM4EGFv?^tB>! zoe3PCK4_J6kuw{)*CdVs&JoRivaM3lRQJwyhiS>gu2ojGocOMh zg|{R(3Y8Yoi&ZRsc8!Jr#o%rhn_;^?dy>+mnLGEwOHXd1s1ctje?lYO6F7_xMs2De zs*#BbaV!ubdqXye!g&RQncvh!@;~+*!XoS~pcZg@DKjsnnu6sm^gtE_fDh zt+Ur9-!Ka}=85+BWy+z(Ml9d5A*u@7xK@2K<&vwc9JL3*KewrT^qC71sytICf2gwE zL;E(L+(@nJMMYBZ-nz)<=1%6%oj6wNJNl~07hDuYOW-XIspiz2uJ+hN$kwFv(?Npj z`{5?49CuqCLY;e(Xj636uocx;bsRP|5E43~h`44~U+J^UHNdv=O&W?+byrPBjt_w$ zv6s)uKdE3CBL>y%E~zY`ucSLt7w2$>9eo1{k!e)vV`>Rjz}ZEpvP(K{c?Yf>Qfak? zeyyg~6lxfnjOZ6jvjV$oLt)Z{;a}^+(Xo-G9w$^f?W^b{R4Nmmo zFM8fV-y*$voNz#e1r?70hZrB%5PQM40gWAXtrEpSF!16Aa?32$Y_Kra0WA(EBcjr& zGt-CxMaoRVuj;MN`9owzxofi=YN0v{4bw= z_}gFo_Qy}3e*M3t@6~4g)$MQouXxIT`kUYR;q$@o{=>Uh{^6(3FFgMBPk;FN_uqZ` z4M)Cw_w$e6{q*_GvH$+VkH7o)_Rt?c|NO(}??1jh_T&Hf;nz)7^|}7(pMUe|$De+9 z`RJFPW#)hPlYaZ%?|=OCFTehe|M&UgxBvXzPk;Q6zj^ubr~mrL@4x$}A71=6|MbK6 zKmPW|@4x%};~#!{^Tf|T{PX9R?>_zbyPtmj$IsvW{P{PZe)##Yk$69Paz;83VEN9$6~>WDmD4?nUukhybse08hpROuIqP4@b5T=QRl|A(J``r-S}fB5<3`~UXym+wCRx1Z0= znO;5f&7JN`^!iT!+SytPTg3G3YYW_2RK!~-LAgu%!;AR>Dpjd`P*8l?KqoLBp83_d zsmg{ns?`F}0z{G^ceAm@ZP*hQY0Wz}dbLw0CF}_=Lh#*6^jNF6t&<;tJ&~pEaBD%X zVrqb`@L>_D7Ej_DW;{jA*VhN6K5|d)Lq=ypd74GsR$Zg)}+L$omJ5Z3eFwV(niR5f?q*>Hqsdz5BAv29h6MK0l3*b)4%ENN< zHKS$m!;8IC!X`~#43&>;V+BKCKsAd825?BmQMVFE+7p8+0!cHR!_$dy5xK;G`U?|E z^)t3gQRIw5fgXa>Sj|zE%|gM{S+6oJ+MVi%ZhxAHHp-aPE)|;>H$xEH+#G1nPr`wU z6qBkH=Y~K$u_wH_%4#OGB>Jr~ad5&R?z7)l_k?75Cfp=If6D8cCPdov_0w}3s9g;e z-eatQzhIN`ds{*!d`D(4u&seav>m*T4i4a&aQ0zSrg4uML5P=KKTCD)i@3^<=wsSg zp=>x!@d|!58BHE4NyPy;c%C|3bsq)zmS7Y~+Q~SrQsc=mf&rS)1Xh)lm7v`HG=b)~ z>>OW>bvJ9$h}2qpHP2VAf{KznQ|6qlS-!qh&Tq9;Zc!Q}S0Or5RsrOT=?puB6ZfT& zx)R!1=N@xCM(V||juxO8AK=vNYhJ{RiscILWtI!9AMu4#9j~BsUsa42EE170-9goo z-XzAlLE^^DG0RjaRs9*t-xQXX8~l#w49cDg?zW2DmWN9!Q+^?N*K)nbQ*RC}iW?R{I4E#utv|8RR;rH{8b`1Si|f1~?nxPPM@Re3LfTtsXTCG$jT z-6jzyKoK%j^-bfyP5_Ll=-I1X06Nz#ZTkau*E#oOHO%O`!~tmyNhLI8Yo88fO^>r= zI7nBKNw5$c=eY>waa(!Ka0hvUHvtvpb&mRbUEu3wS)OCu?y=opD@Tqh^b3aAgOz2| zl?N@L)jGc0ODvUlZ&_y6YQwmDevNB=EBE+&?V~l;GiwZw%mQ~3|7HYEl2rdic<~W%WKy^X*jPgAV zBAyV*m`$d)a?TYVMF&uYavZhgGh1CsZOQjBFd)W#4T=-NYpoI>{vS1G4zQ*mbVn0^kjl~ag z3v#1ny;V$7q4rBmTe;}yJLb;YOBWSLsQr$u zeG}QU+dUJR*zBImUcrvBKNqcQ8k4vu5#A%KE8_FL&wX|9>zDakm6^{%BYyQ_oH6G!?6(igTVt}u+bh2c-^329 z<8ff#qjd8ON;Q?Ibwbl~zD4cgjxrfVh>R|ow?ssJt38ir_=FB|5+7rqV#OoUpDDKC z?DQ9};?eaTuNqv0tJh~6tg}JK#h1Cl%V!}>XNmH|$T%(##+c4?0{jrU7NLfPKn5#O zxMAlIooZZlni2Yt&K@|gU!Snws@OTUTISLW`^9|Cf6jazf606;@26}1lhd`lpRVK2 zn6CT%bdBZx^?a?rn6LGJ!F*l6oUiT8d|mJ7Ykfao$Df?9>oH%`yQw)H3=px+mM}fl6 z*{Mw6smf3F{7vXcI%Jei!5B6YF5<(f63}Ig>b1_yWm=oaIdf)ptYrP}F-H zDRpolF0B$2iD#%t$$ft}?A@UFJ*4(QbwW{Nw>W<@AssuvlVayaH{Vm?+31NhNZkut zqiS6Ox9u|E&XmGjeT~9Rew1Ie!1RgcybG}UwasfCH!U0zr|e7l$~`7BSgGE9&oZ;W zdi#}cRp$_GeD^N@#y7X$qcIjgy*)0k(wO`J9}6*&sml-|NfR; z*!d^Au=`yXHvd^|*kcp0Bm5rXo2p^nt-Zz{b zEY3uF&OHnl)goz+o*IT1o#a!Uj4%ba1Y;cV$*T@7TQ2Q&^-+jo*wt=USUuj{F+T{Y z&E{1-&roKYnD$7+257&py9fx?i^EK2>&tLO5hisy`zNSM=|RL&@#s9dq_@Gbs`n&$CMtd^f<4=u*HWpvPU@1j`Tpvq zv5=O_tweIB`Bls1>P@^{!RL!L_xB^x)q;;$Z-Ot~#5peH1h!&RFD<QF(k!pST=y5BVJ^i;t;5n6G}ByZ(9I3V z@tAX8pBUdN>pS7~`GPX8dX1fYwHpZkK8B+pH3ck8UeRaxV3DcF^CT~k>+?W`5iBfr zEVK;^RTQ{dyG8Kxy62Y%J%+1CI9>hiXE!Q02dS()>|U;mmgr)v2du%@rCu1NtKI8m zmi6@AGAnHm(FtBJvu7%$WefJjROxn>@QuxktSbt5>dm{iHA3%JqK#ftHzcZ8JwbX3 zmZ$8}9Q7*eE*Kra+#?JbcsvhuAfp^6GAgni4UY zwNO*qWZTL3 zwT3C?pV1%G7Gsdg6fqJ33y+;A5BGGoYI6Zou)*pZfJ_Q8%UCP4e(ItXV`}E=x9cii zJ*;P~1c1PWNhnvhk^OeHP$p0PEMi|>HR`v@J5LrT(%cquUAdNY5Wv2yebv|sQQgVQ z<$7y`j03$^42^qv9uy5u+m_R@6M*M^Gx-+X+LJ7~sg`}Oc zdw&sOV)`W=E3d2v)(Cr1SXqHj6UVE!R|g!*4I3ckoea6?$Auqgz9K)X z5f-rsXS}@HMFOpP^`MnU$Q#7PjN~nk7W);+X6ye3ObI9=-iN&!nV}UU+XBemVBmjV z9k|ZH7juQr>fjB@4frp7aRuRDzq7toin-Q8J2wBt6^;JBqS4-0G}fQ2X!O^pp!XGx z`ERqL5wX)>RMsf}7Rnm+TcxDi?DtyVN8U7azuaE&ieAvZPZ1HQ+0woJWE4gt_eTyO z7q9S6&tDAgs;?^9vdMZB3bY45k3y#0Rp?goV_z;7;tR4}2x*D@^)YxOI>K&VA}j!x}#dO24C9kXnD zF z=f739xl6{N=tN+%X`LJv7iSxBylUXgid-n5qB&9_ohYHPBN560nsX>yuOWn73+D$& z)up?aI9sGmh@8EDeqg-=^Ie}H0za|2hUTv`5Zv&Es}GrZv9*Ei`}w{Mlc?Vc&Zp4qz5lrG)) z)P!v1_Yc&+OLwY2YmY!F#*f-{;9!X%^X@nl z5wc&G6nEz-EAB^L)D5zi)H4X+hHq z0UUYg7029T-+6_Fj!cyEH?n2y6vxd_8n^OYbKB+um%cFDT|xB8Q&LicMi57+!_{qc zG-t}^quc|5pKn)HUI574A(82e`vbHAD6X@@y3dd0@N<2huWQeVp1PR->(NNKI(D)D zU7ecZ0N6#8I0NCBS;FfYTAd0r>SDY+axResolution > mode->w) || (conf->yResolution > mode->h)) { + // The offscreen driver has no screen, only a 1024x768 it reports, so a headless run or recording + // keeps the size it was asked for. + if (((conf->xResolution > mode->w) || (conf->yResolution > mode->h)) && (SDL_strcmp(SDL_GetCurrentVideoDriver(), "offscreen") != 0)) { fit = SDL_min((double)mode->w / (double)conf->xResolution, (double)mode->h / (double)conf->yResolution); _mainTrace(conf, "Resolution %dx%d is larger than the display; using %dx%d", conf->xResolution, conf->yResolution, (int32_t)(conf->xResolution * fit), (int32_t)(conf->yResolution * fit)); conf->xResolution = (int32_t)(conf->xResolution * fit); diff --git a/src/singe.c b/src/singe.c index f3491dd92..a60ccd9dc 100644 --- a/src/singe.c +++ b/src/singe.c @@ -634,6 +634,7 @@ typedef struct GlobalS { OverlayLayerT overlayLayers[GUI_DRAW_MAX]; // The layers this turn's guiDraw calls opened, in order int32_t overlayLayerCount; SDL_Surface *overlayGrey; // The overlay in luma only, for overlaySetMonochrome + SDL_Surface *blitScratch; // The overlay's size and format: a stretched or tinted sprite is made here first (_spriteBlitFast) SDL_Texture *overlayTexture; // The one holding the current overlay, one of overlayTextures SDL_Texture *overlayTextures[OVERLAY_TEXTURES]; int32_t overlayTextureIndex; @@ -1174,6 +1175,7 @@ static SoundT *_soundLoadFromIO(lua_State *L, const char *method, SDL_IOStr static int32_t _soundQueueDrain(int32_t *finished); static void _spriteAdoptAnimation(SpriteT *sprite); static void _spriteBlit(SpriteT *sprite, SDL_Surface *surface, const SDL_Rect *source, SDL_Rect *dest, bool stretched); +static bool _spriteBlitFast(SpriteT *sprite, SDL_Surface *surface, const SDL_Rect *source, const SDL_Rect *dest, SDL_BlendMode mode); static void _spriteDestroy(SpriteT *sprite); static SDL_Surface *_spriteFlippedFrame(SpriteT *sprite, SDL_Surface *source, int32_t frame); static void _spriteFreeSurface(SpriteT *sprite); @@ -5990,8 +5992,10 @@ static void _overlayResize(int32_t width, int32_t height) { _global.overlayGrey = NULL; _overlayLayersFree(); SDL_DestroySurface(_global.overlayBase); + SDL_DestroySurface(_global.blitScratch); _global.overlayBase = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_BGRA32); - if (_global.overlayBase == NULL) { + _global.blitScratch = SDL_CreateSurface(width, height, SDL_PIXELFORMAT_BGRA32); + if ((_global.overlayBase == NULL) || (_global.blitScratch == NULL)) { utilDie("%s", SDL_GetError()); } _global.overlay = _global.overlayBase; @@ -8576,13 +8580,17 @@ static void _spriteAdoptAnimation(SpriteT *sprite) { // it is blitted, stretched into dest or at its own size. SDL only rebuilds its blitter when one of // them changes, so setting them on every draw costs nothing. static void _spriteBlit(SpriteT *sprite, SDL_Surface *surface, const SDL_Rect *source, SDL_Rect *dest, bool stretched) { - SDL_BlendMode mode = SDL_BLENDMODE_BLEND; + SDL_BlendMode mode = SDL_BLENDMODE_BLEND; + bool modified = (sprite->alpha != SDL_ALPHA_OPAQUE) || (sprite->tintR != SDL_ALPHA_OPAQUE) || (sprite->tintG != SDL_ALPHA_OPAQUE) || (sprite->tintB != SDL_ALPHA_OPAQUE); if (sprite->blend == SPRITE_ADD) { mode = SDL_BLENDMODE_ADD; } else if (sprite->blend == SPRITE_MULTIPLY) { mode = SDL_BLENDMODE_MUL; } + if ((stretched || modified) && _spriteBlitFast(sprite, surface, source, dest, mode)) { + return; + } SDL_SetSurfaceBlendMode(surface, mode); SDL_SetSurfaceAlphaMod(surface, sprite->alpha); SDL_SetSurfaceColorMod(surface, sprite->tintR, sprite->tintG, sprite->tintB); @@ -8594,6 +8602,134 @@ static void _spriteBlit(SpriteT *sprite, SDL_Surface *surface, const SDL_Rect *s } +// A stretched, tinted, or faded sprite the quick way. SDL blends a sprite onto the overlay quickly +// only unstretched and unmodified: stretching it, or changing its alpha or colour, sends every pixel +// down a general path ten to twenty times slower, about 8 ms for a whole 1920 by 1080 screen. So +// the picture is made in the scratch surface first -- sampled nearest at each pixel's centre in +// 16.16 fixed point as SDL's own stretch does, the colour key turned to transparency, tint and alpha +// multiplied in -- and that is blended on unmodified, which SDL does fast. Only the part of dest on +// the overlay is made. Pictures this does not read (3 byte pixels) answer false and go SDL's way. +static bool _spriteBlitFast(SpriteT *sprite, SDL_Surface *surface, const SDL_Rect *source, const SDL_Rect *dest, SDL_BlendMode mode) { + const SDL_PixelFormatDetails *in = SDL_GetPixelFormatDetails(surface->format); + const SDL_PixelFormatDetails *out = SDL_GetPixelFormatDetails(_global.blitScratch->format); + SDL_Palette *palette = SDL_GetSurfacePalette(surface); + SDL_Rect src = { 0, 0, surface->w, surface->h }; + SDL_Rect clip; + SDL_Rect shown; + SDL_Rect scratch; + uint32_t table[256]; + uint32_t red[256]; + uint32_t green[256]; + uint32_t blue[256]; + uint32_t alpha[256]; + uint32_t key = 0; + uint32_t rgbMask = 0; + uint32_t aMask = 0; + uint32_t rShift = 0; + uint32_t gShift = 0; + uint32_t bShift = 0; + uint32_t aShift = 0; + bool keyed = SDL_SurfaceHasColorKey(surface); + bool indexed = SDL_ISPIXELFORMAT_INDEXED(surface->format); + bool plain = false; + int64_t stepX = 0; + int64_t stepY = 0; + int64_t posY = 0; + int32_t x = 0; + int32_t y = 0; + int32_t c = 0; + + if ((in == NULL) || (out == NULL) || (indexed ? (in->bytes_per_pixel != 1) || (palette == NULL) : (in->bytes_per_pixel != 4))) { + return false; + } + if (source != NULL) { + src = *source; + } + if ((src.w <= 0) || (src.h <= 0) || (dest->w <= 0) || (dest->h <= 0)) { + return true; + } + SDL_GetSurfaceClipRect(_global.overlay, &clip); + if (!SDL_GetRectIntersection(dest, &clip, &shown)) { + return true; + } + if (keyed) { + SDL_GetSurfaceColorKey(surface, &key); + } + // Each channel's tint (and the alpha) as a table, already shifted to where the scratch keeps it. + for (c = 0; c < 256; c++) { + red[c] = ((uint32_t)c * sprite->tintR / 255) << out->Rshift; + green[c] = ((uint32_t)c * sprite->tintG / 255) << out->Gshift; + blue[c] = ((uint32_t)c * sprite->tintB / 255) << out->Bshift; + alpha[c] = ((uint32_t)c * sprite->alpha / 255) << out->Ashift; + } + // Indexed: the palette as finished pixels, the keyed index clear. + if (indexed) { + for (c = 0; c < 256; c++) { + SDL_Color colour = (c < palette->ncolors) ? palette->colors[c] : (SDL_Color){ 0, 0, 0, SDL_ALPHA_OPAQUE }; + + table[c] = red[colour.r] | green[colour.g] | blue[colour.b] | ((keyed && ((uint32_t)c == key)) ? 0 : alpha[colour.a]); + } + } else { + rgbMask = in->Rmask | in->Gmask | in->Bmask; + aMask = in->Amask; + rShift = in->Rshift; + gShift = in->Gshift; + bShift = in->Bshift; + aShift = in->Ashift; + key &= rgbMask; + // Nothing to change in a pixel: copied as it is. + plain = !keyed && (aMask != 0) && (sprite->alpha == SDL_ALPHA_OPAQUE) && (sprite->tintR == SDL_ALPHA_OPAQUE) && (sprite->tintG == SDL_ALPHA_OPAQUE) && (sprite->tintB == SDL_ALPHA_OPAQUE) && (in->Rmask == out->Rmask) && (in->Gmask == out->Gmask) && (in->Bmask == out->Bmask) && (aMask == out->Amask); + } + if (SDL_MUSTLOCK(surface) && !SDL_LockSurface(surface)) { + return false; + } + stepX = ((int64_t)src.w << 16) / dest->w; + stepY = ((int64_t)src.h << 16) / dest->h; + posY = stepY / 2 + stepY * (shown.y - dest->y); + for (y = 0; y < shown.h; y++, posY += stepY) { + const uint8_t *row = (const uint8_t *)surface->pixels + (size_t)(src.y + (int32_t)(posY >> 16)) * (size_t)surface->pitch + src.x * (indexed ? 1 : 4); + uint32_t *to = (uint32_t *)((uint8_t *)_global.blitScratch->pixels + (size_t)y * (size_t)_global.blitScratch->pitch); + int64_t posX = stepX / 2 + stepX * (shown.x - dest->x); + + // A row stretched over several of the screen's is made once and copied. + if ((y > 0) && ((posY >> 16) == ((posY - stepY) >> 16))) { + memcpy(to, (uint8_t *)to - _global.blitScratch->pitch, (size_t)shown.w * sizeof(uint32_t)); + continue; + } + if (plain) { + const uint32_t *pixels = (const uint32_t *)row; + + for (x = 0; x < shown.w; x++, posX += stepX) { + to[x] = pixels[posX >> 16]; + } + } else if (indexed) { + for (x = 0; x < shown.w; x++, posX += stepX) { + to[x] = table[row[posX >> 16]]; + } + } else { + const uint32_t *pixels = (const uint32_t *)row; + + for (x = 0; x < shown.w; x++, posX += stepX) { + uint32_t p = pixels[posX >> 16]; + uint32_t a = aMask ? alpha[(p & aMask) >> aShift] : alpha[SDL_ALPHA_OPAQUE]; + + to[x] = red[(p >> rShift) & 0xFF] | green[(p >> gShift) & 0xFF] | blue[(p >> bShift) & 0xFF] | ((keyed && ((p & rgbMask) == key)) ? 0 : a); + } + } + } + if (SDL_MUSTLOCK(surface)) { + SDL_UnlockSurface(surface); + } + scratch.x = 0; + scratch.y = 0; + scratch.w = shown.w; + scratch.h = shown.h; + SDL_SetSurfaceBlendMode(_global.blitScratch, mode); + SDL_BlitSurface(_global.blitScratch, &scratch, _global.overlay, &shown); + return true; +} + + static void _spriteDestroy(SpriteT *sprite) { HASH_DEL(_global.spriteList, sprite); _spriteFreeSurface(sprite); @@ -19591,6 +19727,7 @@ void singe(SDL_Window *window, SDL_Renderer *renderer, SDL_GPUDevice *device, Co _overlayTexturesDestroy(); _overlayLayersFree(); SDL_DestroySurface(_global.overlayBase); + SDL_DestroySurface(_global.blitScratch); SDL_DestroySurface(_global.overlayGrey); _progTrace("Destroying console font"); SDL_DestroySurface(_global.consoleFontSurface); diff --git a/testScripts/README.md b/testScripts/README.md index 2a2235391..06ed18dd2 100644 --- a/testScripts/README.md +++ b/testScripts/README.md @@ -69,3 +69,5 @@ screenshot or two and quits by itself; the results are in screenshots/. | 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 `` 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 | +| scene101.singe | 53 | - | Calls no other scene used, over the disc, each checked against its Reference entry and printed as `CALLS ok` or `CALLS FAIL`: `collideRects`, `collidePointRect` and `collidePointPolygon` on known shapes (a concave L among them); `discGetState` through play, pause, `discSkipForward` (which keeps the pause), `discSkipToFrame` (which plays) and stop, with `discChangeSpeed`, `discSetFPS` and `discAudio`; a channel through `soundPause` and `soundResume` with `soundIsPlaying` at each step; `keyboardIsDown` and `keyboardGetModifiers` with nothing held, `mouseSetMode` both ways; `videoPause` holding a loaded video's frame and `videoSetVolume` in both forms; `spriteFlip` (the screenshot shows the arrows plain and mirrored); and `singePack` packing a game it wrote, its script read back through the container | +| scene102.singe | 54 | - | The 3D calls no other scene used, each checked and printed as `SCENE3D ok` or `SCENE3D FAIL`: `nodeSetName` found by `nodeFind`, `bodyApplyForce` lifting a 1 kg crate, `physicsSetEnabled` holding it in the air and letting it fall, `playerSetPosition`, `emitterDelete`, `navNearest` onto the floor and nothing far off, `navAgentStop` halting an agent short, `navAgentIsArrived` once it is sent on and arrives, `navAgentDelete`, and `ragdollDelete` freeing the Fox for a new ragdoll; no disc, -C 720x480 | diff --git a/testScripts/author/adventure2d.forge b/testScripts/author/adventure2d.forge deleted file mode 100644 index 57a938854..000000000 --- a/testScripts/author/adventure2d.forge +++ /dev/null @@ -1,140 +0,0 @@ --- Genre one of milestone 3: a point-and-click adventure in a painted room, Sierra and LucasArts --- in one table. Walk areas that become a navigation mesh, hotspots, a verb line, a text parser --- as well, an inventory, a dialogue with a choice that changes the world, a cut-scene, two rooms --- that remember themselves, and a save. The "painting" is a box, since no art is needed. -return { - title = "Author test: adventure", - size = { w = 720, h = 480 }, - layers = { { kind = "overlay" }, - { kind = "parser", prompt = "> ", - words = { look = { "look", "examine", "l" }, take = { "take", "get", "pick" }, use = { "use" }, open = { "open" }, talk = { "talk", "speak" }, - key = { "key" }, door = { "door", "gate" }, guard = { "guard", "man" } }, - ignore = { "the", "a", "an", "at", "to", "with", "on", "up" } } }, - vars = { score = 0, allowed = false }, - verbs = { "walk", "look", "take", "use", "open", "talk" }, - - types = { - backdrop = { look = { kind = "box", w = 720, h = 480, r = 40, g = 45, b = 60 } }, - floor = { look = { kind = "box", w = 720, h = 200, r = 70, g = 60, b = 50, anchor = "feet" } }, - hero = { look = { kind = "box", w = 30, h = 70, r = 230, g = 90, b = 170, anchor = "feet" }, - behaviours = { { kind = "walker", speed = 160 } } }, - door = { look = { kind = "box", w = 60, h = 120, r = 90, g = 60, b = 40, anchor = "feet" }, - behaviours = { { kind = "hotspot", name = "door", walkX = 600, walkY = 330 } } }, - key = { look = { kind = "box", w = 16, h = 8, r = 255, g = 220, b = 60 }, - behaviours = { { kind = "hotspot", name = "key", walkX = 200, walkY = 400 } } }, - guard = { look = { kind = "box", w = 34, h = 80, r = 80, g = 120, b = 200, anchor = "feet" }, - behaviours = { { kind = "hotspot", name = "guard", walkX = 480, walkY = 360 } } }, - pillar = { look = { kind = "box", w = 40, h = 200, r = 60, g = 60, b = 80, anchor = "feet" } }, - marker = { look = { kind = "none" } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "hall", - depthSort = true, - scaleBy = { { y = 300, scale = 0.6 }, { y = 460, scale = 1.0 } }, - walk = { { 120, 300, 640, 300, 700, 460, 60, 460 } }, - entities = { - { type = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, - { type = "floor", id = "floor", x = 360, y = 480, z = 0 }, - { type = "door", id = "door", x = 600, y = 300, z = 0 }, - { type = "guard", id = "guard", x = 540, y = 340, z = 0 }, - { type = "pillar", id = "pillar", x = 360, y = 420, z = 0 }, - { type = "key", id = "key", x = 200, y = 380, z = 0 }, - { type = "hero", id = "hero", x = 150, y = 440, z = 0 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } }, - { name = "yard", - depthSort = true, - walk = { { 40, 320, 680, 320, 680, 460, 40, 460 } }, - entities = { - { type = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 }, - { type = "door", id = "door", x = 100, y = 300, z = 0 }, - { type = "marker", id = "arrival", x = 160, y = 420, z = 0 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - dialogues = { - guard = { - start = "hello", - nodes = { - hello = { who = "Guard", text = "Nobody passes.", - choices = { { text = "Why not?", next = "why" }, - { text = "Fine." } } }, - why = { who = "Guard", text = "The door is locked, and I have no key.", - choices = { { text = "I have a key.", when = 'has("key")', act = { { "setVar", name = "allowed", value = true }, { "addScore", amount = 1 } }, next = "ok" }, - { text = "I see." } } }, - ok = { who = "Guard", text = "Then go ahead." } - } - } - }, - - rules = { - { note = "A click on the floor walks there", - on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true, - act = { { "walkToPointer", entity = "hero", player = 1 } } }, - - { note = "Look at anything", - on = "verb", verb = "look", - act = { { "say", text = '"It is a " .. event.target .. "."', seconds = 1 } } }, - - { note = "Take the key", - on = "verb", verb = "take", target = "key", - act = { { "walkToHotspot", entity = "hero", target = "self" }, - { "give", item = "key" }, - { "addScore", amount = 1 }, - { "destroy", entity = "self" } } }, - - { note = "Talk to the guard", - on = "verb", verb = "talk", target = "guard", - act = { { "walkToHotspot", entity = "hero", target = "self" }, - { "face", entity = "hero", target = "self" }, - { "talk", dialogue = "guard" } } }, - - { note = "Open the door while the guard objects", - on = "verb", verb = "open", target = "door", - when = { { "test", expr = "not allowed" } }, - act = { { "say", text = '"The guard shakes his head."', seconds = 1 } } }, - - { note = "Open the door: the cut-scene", - on = "verb", verb = "open", target = "door", room = "hall", controls = false, - when = { { "test", expr = "allowed" } }, - act = { { "walkToHotspot", entity = "hero", target = "self" }, - { "say", text = '"The door swings open."', seconds = 1 }, - { "fade", seconds = 0.5, out = true }, - { "goTo", room = "yard", entity = "hero", at = "arrival" }, - { "fade", seconds = 0.5, out = false }, - { "addScore", amount = 1 }, - { "setVar", name = "outside", value = true } } }, - - { note = "Use the key on the door", - on = "verb", verb = "use", item = "key", target = "door", - act = { { "say", text = '"It is not that kind of lock."', seconds = 1 } } }, - - { note = "Anything else", - on = "verb", - act = { { "say", text = '"That does not work."', seconds = 1 } } }, - - { note = "Typed: look at the door", - on = "said", verb = "look", noun = "door", - act = { { "say", text = '"A heavy door."', seconds = 1 } } }, - - { note = "Typed: a word the game does not know", - on = "said", - when = { { "test", expr = "event.unknown != nil" } }, - act = { { "say", text = '"I do not know the word \\"" .. event.unknown .. "\\"."', seconds = 1 } } }, - - { note = "Typed: anything else", - on = "said", - act = { { "say", text = '"You cannot do that here."', seconds = 1 } } }, - - { note = "The verb keys", - on = "pressed", key = "L", - act = { { "setVerb", verb = "look" } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '(sentence or "") .. " score " .. score' } } } - } -} diff --git a/testScripts/author/adventure3d.forge b/testScripts/author/adventure3d.forge deleted file mode 100644 index 6062449de..000000000 --- a/testScripts/author/adventure3d.forge +++ /dev/null @@ -1,53 +0,0 @@ --- Genre two of milestone 3: the same adventure in a modelled room from a fixed camera -- Grim --- 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.forge") - -return { - title = "Author test: adventure in 3D", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 50 }, { kind = "overlay" }, base.layers[2] }, - vars = { score = 0, allowed = false }, - verbs = base.verbs, - dialogues = base.dialogues, - rules = base.rules, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 16, h = 0.4, d = 12, r = 80, g = 70, b = 60 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.8, d = 0.6 }, - behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 }, { kind = "animator", idle = "Survey", walk = "Walk" } } }, - door = { look = { kind = "mesh", shape = "box", w = 1.2, h = 2.4, d = 0.2, r = 90, g = 60, b = 40 }, - behaviours = { { kind = "hotspot", name = "door", walkX = 5, walkY = 0, walkZ = -3 } } }, - key = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 220, b = 60 }, - behaviours = { { kind = "hotspot", name = "key", walkX = -3, walkY = 0, walkZ = 1 } } }, - guard = { look = { kind = "mesh", shape = "cylinder", w = 0.7, h = 1.8, r = 80, g = 120, b = 200 }, - behaviours = { { kind = "hotspot", name = "guard", walkX = 3.2, walkY = 0, walkZ = -1.5 } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0.5, lookZ = -1 } } }, - marker = { look = { kind = "none" } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "hall", - navFrom = { "floor" }, - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, - { type = "camera", id = "camera", x = 0, y = 6, z = 8 }, - { type = "door", id = "door", x = 5, y = 1.2, z = -4.2 }, - { type = "guard", id = "guard", x = 4, y = 0.9, z = -3 }, - { type = "key", id = "key", x = -3, y = 0.15, z = 0 }, - { type = "hero", id = "hero", x = -4, y = 0, z = 3 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } }, - { name = "yard", - navFrom = { "floor" }, - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = -1 }, - { type = "camera", id = "camera", x = 0, y = 6, z = 8 }, - { type = "door", id = "door", x = -5, y = 1.2, z = -4.2 }, - { type = "marker", id = "arrival", x = -4, y = 0, z = -2 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - } -} diff --git a/testScripts/author/bowling.forge b/testScripts/author/bowling.forge deleted file mode 100644 index cd33e8c68..000000000 --- a/testScripts/author/bowling.forge +++ /dev/null @@ -1,55 +0,0 @@ --- Sports with a ball, from the catalogue: a ball on a body shoved down a lane by a charged --- press, pins that are bodies, a gutter that is a trigger, and pins counted by how far they fell. -return { - title = "Author test: bowling", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 100, g = 100, b = 110 }, fov = 50 }, { kind = "overlay" } }, - vars = { score = 0, down = 0, thrown = false }, - - types = { - lane = { look = { kind = "mesh", shape = "box", w = 3, h = 0.3, d = 24, r = 180, g = 150, b = 100 }, behaviours = { { kind = "solid" } } }, - ball = { look = { kind = "mesh", shape = "sphere", w = 0.44, r = 40, g = 40, b = 60 }, behaviours = { { kind = "body", shape = "sphere", mass = 7, friction = 0.2, bounce = 0.1 } } }, - pin = { look = { kind = "mesh", shape = "cylinder", w = 0.24, h = 0.8, r = 240, g = 240, b = 240 }, vars = { counted = false }, - behaviours = { { kind = "body", mass = 1.5, friction = 0.5 } } }, - gutter = { look = { kind = "none", w = 3, h = 1, d = 2 }, behaviours = { { kind = "trigger" } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ball", x = 0, y = 2.5, z = 5, lookY = 0.2, lag = 0.3 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "alley", - entities = { - { type = "lane", id = "lane", x = 0, y = -0.15, z = -8 }, - { type = "ball", id = "ball", x = 0, y = 0.3, z = 2 }, - { type = "pin", id = "pin1", x = 0, y = 0.4, z = -16 }, - { type = "pin", id = "pin2", x = -0.4, y = 0.4, z = -16.7 }, - { type = "pin", id = "pin3", x = 0.4, y = 0.4, z = -16.7 }, - { type = "pin", id = "pin4", x = -0.8, y = 0.4, z = -17.4 }, - { type = "pin", id = "pin5", x = 0, y = 0.4, z = -17.4 }, - { type = "pin", id = "pin6", x = 0.8, y = 0.4, z = -17.4 }, - { type = "gutter", id = "gutter", x = 0, y = 0, z = -21 }, - { type = "camera", id = "camera", x = 0, y = 2.5, z = 7 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "The throw", - on = "pressed", key = "SPACE", - when = { { "test", expr = "not thrown" } }, - act = { { "setVar", name = "thrown", value = true }, { "push", entity = "ball", x = 0, y = 0, z = -60 } } }, - - { note = "A pin has fallen when it leans past the point of no return", - on = "frame", each = "pin", - when = { { "test", expr = "not self.counted and self.y < 0.25" } }, - act = { { "setVar", entity = "self", name = "counted", value = true }, { "addVar", name = "down", amount = 1 }, { "addScore", amount = 10 } } }, - - { note = "The ball reaches the end", - on = "enter", a = "gutter", b = "ball", - act = { { "setVar", name = "done", value = true } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"pins down " .. down .. " score " .. score' } } } - } -} diff --git a/testScripts/author/breakout.forge b/testScripts/author/breakout.forge deleted file mode 100644 index 088c3c403..000000000 --- a/testScripts/author/breakout.forge +++ /dev/null @@ -1,55 +0,0 @@ --- Arcade physics, from the catalogue: a ball that bounces, bricks that break, a paddle the keys --- move, and a life lost when the ball is missed. world2d with no gravity. -return { - title = "Author test: breakout", - size = { w = 720, h = 480 }, - layers = { { kind = "world2d", gravity = 0 } }, - vars = { score = 0, lives = 3, broken = 0 }, - - types = { - wall = { look = { kind = "box", w = 20, h = 480, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } }, - roof = { look = { kind = "box", w = 720, h = 20, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } }, - paddle = { look = { kind = "box", w = 100, h = 14, r = 230, g = 230, b = 240 }, - behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, { kind = "mover", speed = 400, clamp = true }, { kind = "solid", moving = true } } }, - ball = { look = { kind = "box", w = 14, h = 14, r = 255, g = 220, b = 80 }, - behaviours = { { kind = "body", shape = "sphere", mass = 1, bounce = 1, friction = 0 } } }, - brick = { look = { kind = "box", w = 60, h = 20, r = 220, g = 90, b = 90 }, behaviours = { { kind = "solid" } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "court", - entities = { - { type = "wall", id = "wallL", x = 10, y = 240 }, - { type = "wall", id = "wallR", x = 710, y = 240 }, - { type = "roof", id = "roof", x = 360, y = 10 }, - { type = "brick", id = "brick1", x = 200, y = 100 }, - { type = "brick", id = "brick2", x = 280, y = 100 }, - { type = "brick", id = "brick3", x = 360, y = 100 }, - { type = "brick", id = "brick4", x = 440, y = 100 }, - { type = "brick", id = "brick5", x = 520, y = 100 }, - { type = "paddle", id = "paddle", x = 360, y = 440 }, - { type = "ball", id = "ball", x = 360, y = 380 }, - { type = "readout", id = "readout", x = 30, y = 30 } - } } - }, - - rules = { - { note = "Serve", - on = "roomStart", - act = { { "push", entity = "ball", x = 120, y = -380 } } }, - - { note = "A brick breaks", - on = "collision", a = "ball", b = "brick", - act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "broken", amount = 1 } } }, - - { note = "The ball is missed", - on = "frame", - when = { { "test", expr = "ball.y > 500" } }, - act = { { "addVar", name = "lives", amount = -1 }, { "moveTo", entity = "ball", x = 360, y = 380 }, { "push", entity = "ball", x = -100, y = -380 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives' } } } - } -} diff --git a/testScripts/author/fmv.forge b/testScripts/author/fmv.forge deleted file mode 100644 index 0c0f67d3f..000000000 --- a/testScripts/author/fmv.forge +++ /dev/null @@ -1,74 +0,0 @@ --- Genre one of milestone 5: branching full-motion video, Dragon's Lair in a table. Windows on --- the disc that want a move, the disc sent to a success or a failure clip, lives, credits and a --- continue, the arcade score panel, and the score sent to the board at the end. The menu video --- stands in for the disc. -return { - title = "Author test: branching video", - layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" }, { kind = "bezel" } }, - vars = { score = 0, lives = 3, credits = 0, taken = 0, missed = 0 }, - - types = { - dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }, - prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } } - }, - - rooms = { - { name = "cavern", - entities = { - { type = "dirk", id = "dirk", x = 0, y = 0 }, - { type = "prompt", id = "prompt", x = 250, y = 60 }, - { type = "readout", id = "readout", x = 12, y = 12 } - }, - tracks = { - { name = "moves", key = "frame", - branches = { { from = 120, to = 160, move = "RIGHT", success = 200, fail = 340 }, - { from = 220, to = 260, move = "UP", success = 300, fail = 340 } } } - } } - }, - - rules = { - { note = "A window opens: say what it wants", - on = "branchOpen", - act = { { "setText", entity = "prompt", text = '"MOVE " .. event.move' } } }, - - { note = "Made it", - on = "branchTaken", - act = { { "addScore", amount = 500 }, - { "addVar", name = "taken", amount = 1 }, - { "setText", entity = "prompt", text = '""' } } }, - - { note = "Missed it: a life gone", - on = "branchMissed", - act = { { "addVar", name = "lives", amount = -1 }, - { "addVar", name = "missed", amount = 1 }, - { "flash", r = 255, g = 40, b = 40, seconds = 0.3 }, - { "setText", entity = "prompt", text = '""' } } }, - - { note = "The death clip is over: back to the cavern, or game over", - on = "frameReached", frame = 380, - when = { { "test", expr = "lives > 0" } }, - act = { { "discTo", frame = 100 } } }, - - { note = "Out of lives", - on = "frameReached", frame = 380, - when = { { "test", expr = "lives <= 0" } }, - act = { { "submitScore", board = "default" }, - { "gameOver" } } }, - - { note = "A coin", - on = "pressed", switch = "SWITCH_COIN1", - act = { { "credit" } } }, - - { note = "Continue", - on = "pressed", switch = "SWITCH_START1", - when = { { "test", expr = "credits > 0 and lives <= 0" } }, - act = { { "addVar", name = "credits", amount = -1 }, - { "setVar", name = "lives", value = 3 }, - { "restart" } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives .. " credits " .. credits' } } } - } -} diff --git a/testScripts/author/fps.forge b/testScripts/author/fps.forge deleted file mode 100644 index bc95000b9..000000000 --- a/testScripts/author/fps.forge +++ /dev/null @@ -1,81 +0,0 @@ --- A first-person shooter, from the catalogue: a character the keys move relative to a camera on --- its eyes that the mouse turns, a gun that fires from the middle of the picture, and enemies --- that walk the navigation mesh after the hero and bite when they reach it. -return { - title = "Author test: first person", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 60, g = 60, b = 80 }, fog = { r = 10, g = 10, b = 16, near = 20, far = 50 }, fov = 70 }, { kind = "overlay" } }, - vars = { score = 0, health = 5, bites = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 70, g = 70, b = 80 }, behaviours = { { kind = "solid" } } }, - wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 40, r = 110, g = 60, b = 60 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "none", w = 0.6, h = 1.7, d = 0.6 }, - behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" }, - { kind = "character", speed = 4, jump = 5, radius = 0.3, height = 1.7 } } }, - eyes = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "first", target = "hero", eye = 1.5 } } }, - gun = { look = { kind = "none" }, vars = { ammo = 30 }, behaviours = { { kind = "gun", player = 1, ammo = 30, reload = "key", aim = "centre" } } }, - zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, - vars = { health = 2 }, - behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1.5 }, - { kind = "walker", speed = 2.5, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 }, - { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 }, - { kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } }, - { kind = "timer", name = "bite", after = 1.2, start = false } } }, - hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "zombie", every = 2, max = 4, total = 6, points = { { x = -8, y = 0, z = -12 }, { x = 8, y = 0, z = -12 }, { x = 0, y = 0, z = -16 } } } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "arena", - navFrom = { "floor" }, - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { type = "wall", id = "wallL", x = -12, y = 1.5, z = 0 }, - { type = "wall", id = "wallR", x = 12, y = 1.5, z = 0 }, - { type = "hero", id = "hero", x = 0, y = 0.2, z = 8 }, - { type = "eyes", id = "eyes", x = 0, y = 1.7, z = 8 }, - { type = "gun", id = "gun1", x = 0, y = 0, z = 0 }, - { type = "hive", id = "hive", x = 0, y = 0, z = -14 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "A zombie walks from the moment it is made", - on = "spawn", type = "zombie", - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "A shot lands", - on = "hit", type = "zombie", - act = { { "damage", entity = "self", amount = 1 }, { "addScore", amount = 50 } } }, - - { note = "A headshot", - on = "hit", type = "zombie", - when = { { "hitPart", part = "head" } }, - act = { { "damage", entity = "self", amount = 2 }, { "addScore", amount = 150 } } }, - - { note = "It reaches the hero and rears up", - on = "arrived", type = "zombie", - act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.2 } } }, - - { note = "The bite lands unless it was killed", - on = "timer", name = "bite", type = "zombie", - when = { { "inState", entity = "self", state = "attack" } }, - act = { { "addVar", name = "health", amount = -1 }, { "addVar", name = "bites", amount = 1 }, { "flash", r = 255, g = 40, b = 40, seconds = 0.2 }, - { "timerStart", entity = "self", name = "bite", after = 1.2 } } }, - - { note = "Reload on R", - on = "pressed", key = "R", - act = { { "reload", entity = "gun1" } } }, - - { note = "Out of health", - on = "frame", - when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } }, - act = { { "gameOver" } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo .. " zombies " .. count("zombie")' } } } - } -} diff --git a/testScripts/author/gunvideo.forge b/testScripts/author/gunvideo.forge deleted file mode 100644 index 7adc2b72d..000000000 --- a/testScripts/author/gunvideo.forge +++ /dev/null @@ -1,61 +0,0 @@ --- Genre two of milestone 1: a light gun game over video. Hitboxes keyed to disc frames, a gun --- that hits or misses, ammo and a reload, a prompt at a frame, and the disc as the picture -- Mad --- Dog McCree in a table. The boxes follow nothing in the menu video; they are where the test --- expects them. -return { - title = "Author test: gun over video", - players = 1, - layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, - vars = { score = 0, misses = 0 }, - - types = { - gun = { look = { kind = "none" }, - vars = { ammo = 3 }, - behaviours = { { kind = "gun", player = 1, ammo = 3, reload = "offscreen" } } }, - bandit = { look = { kind = "none" }, - vars = { health = 1 }, - behaviours = { { kind = "hitbox", track = "bandit" }, { kind = "health", max = 1 } } }, - hostage = { look = { kind = "none" }, - behaviours = { { kind = "hitbox", track = "hostage" } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "street", - entities = { - { type = "gun", id = "gun1", x = 0, y = 0 }, - { type = "bandit", id = "bandit", x = 0, y = 0 }, - { type = "hostage", id = "hostage", x = 0, y = 0 }, - { type = "readout", id = "readout", x = 12, y = 12 } - }, - tracks = { - { name = "bandit", key = "frame", boxes = { { at = 120, x = 100, y = 200, w = 60, h = 100 }, { at = 220, x = 400, y = 200, w = 60, h = 100 } } }, - { name = "hostage", key = "frame", boxes = { { at = 100, x = 560, y = 240, w = 50, h = 80 }, { at = 300, x = 560, y = 240, w = 50, h = 80 } } } - } } - }, - - rules = { - { note = "The bandit is shot", - on = "hit", type = "bandit", - act = { { "addScore", amount = 100 }, - { "damage", entity = "self", amount = 1 } } }, - - { note = "The hostage is shot", - on = "hit", type = "hostage", - act = { { "addScore", amount = -200 }, - { "flash", r = 255, g = 40, b = 40, seconds = 0.3 } } }, - - { note = "A miss on the picture", - on = "miss", - when = { { "test", expr = "not event.offscreen" } }, - act = { { "addVar", name = "misses", amount = 1 } } }, - - { note = "Draw!", - on = "frameReached", frame = 120, - act = { { "say", text = '"DRAW!"', seconds = 1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " ammo " .. gun1.ammo' } } } - } -} diff --git a/testScripts/author/hud.rml b/testScripts/author/hud.rml deleted file mode 100644 index 30844c87f..000000000 --- a/testScripts/author/hud.rml +++ /dev/null @@ -1,17 +0,0 @@ - - - hud - - - - -

-
3
-
- - diff --git a/testScripts/author/mash.forge b/testScripts/author/mash.forge deleted file mode 100644 index cdf7c9788..000000000 --- a/testScripts/author/mash.forge +++ /dev/null @@ -1,34 +0,0 @@ --- 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.forge b/testScripts/author/maze.forge deleted file mode 100644 index 70dcd62f0..000000000 --- a/testScripts/author/maze.forge +++ /dev/null @@ -1,48 +0,0 @@ --- A maze, from the catalogue: corridors that are a walk area, a hero sent by clicks, dots that --- are eaten on touch, and a ghost that follows the hero across the mesh. -return { - title = "Author test: maze", - size = { w = 720, h = 480 }, - layers = { { kind = "overlay" } }, - vars = { score = 0, eaten = 0 }, - - types = { - walls = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 1 1 1 1 1 1 1 1 1 1; 1 0 0 0 0 0 0 0 0 0 1; 1 0 1 1 1 0 1 1 1 0 1; 1 0 0 0 0 0 0 0 0 0 1; 1 1 1 1 1 1 1 1 1 1 1" } }, - hero = { look = { kind = "box", w = 24, h = 24, r = 255, g = 230, b = 60 }, behaviours = { { kind = "walker", speed = 140, radius = 6 } } }, - ghost = { look = { kind = "box", w = 24, h = 24, r = 90, g = 120, b = 255 }, behaviours = { { kind = "walker", speed = 90, radius = 6, follow = "hero", every = 0.3, stopAt = 10 } } }, - dot = { look = { kind = "box", w = 8, h = 8, r = 255, g = 255, b = 255 } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "maze", - walk = { { 40, 140, 360, 140, 360, 220, 40, 220 }, { 40, 220, 360, 220, 360, 300, 40, 300 } }, - entities = { - { type = "walls", id = "walls", x = 8, y = 100 }, - { type = "dot", id = "dot1", x = 150, y = 180 }, - { type = "dot", id = "dot2", x = 250, y = 180 }, - { type = "dot", id = "dot3", x = 250, y = 260 }, - { type = "hero", id = "hero", x = 60, y = 180 }, - { type = "ghost", id = "ghost", x = 340, y = 260 }, - { type = "readout", id = "readout", x = 12, y = 12 } - } } - }, - - rules = { - { note = "A click sends the hero", - on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true, - act = { { "walkToPointer", entity = "hero", player = 1 } } }, - - { note = "A dot is eaten", - on = "collision", a = "hero", b = "dot", - act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "eaten", amount = 1 } } }, - - { note = "Caught", - on = "collision", a = "ghost", b = "hero", - act = { { "setVar", name = "caught", value = true } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " eaten " .. eaten' } } } - } -} diff --git a/testScripts/author/overlayBits.forge b/testScripts/author/overlayBits.forge deleted file mode 100644 index a6fbe7369..000000000 --- a/testScripts/author/overlayBits.forge +++ /dev/null @@ -1,47 +0,0 @@ --- 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.forge b/testScripts/author/painted.forge deleted file mode 100644 index 9fdf561b6..000000000 --- a/testScripts/author/painted.forge +++ /dev/null @@ -1,43 +0,0 @@ --- A painted room in 3D, the shape Grim Fandango has: the backdrop is a picture on a flat mesh, a --- pillar that is only in the picture is an occluder -- an invisible box that hides what walks --- behind it -- and a character crosses the room behind it and comes out the other side. -return { - title = "Author test: painted room", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 120, g = 120, b = 130 }, sun = false, fov = 50 }, { kind = "overlay" } }, - vars = { score = 0 }, - - types = { - painting = { look = { kind = "mesh", shape = "box", w = 16, h = 9, d = 0.2, r = 255, g = 255, b = 255, texture = "testScripts/hills.png", unlit = true } }, - pillar = { look = { kind = "mesh", shape = "box", w = 1.6, h = 5, d = 1.6, occluder = true } }, - floor = { look = { kind = "mesh", shape = "box", w = 20, h = 0.2, d = 12, r = 90, g = 90, b = 90 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.012, clip = "Walk", w = 0.8, h = 1, d = 1.5 }, - behaviours = { { kind = "seek", target = "far", speed = 2.2, stopAt = 0.3 } } }, - marker = { look = { kind = "none" } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1.2, lookZ = -4 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "hall", - entities = { - { type = "painting", id = "painting", x = 0, y = 3, z = -9 }, - { type = "floor", id = "floor", x = 0, y = -0.1, z = -4 }, - { type = "pillar", id = "pillar", x = 0, y = 2.5, z = -4 }, - { type = "hero", id = "hero", x = -5, y = 0, z = -6 }, - { type = "marker", id = "far", x = 5, y = 0, z = -6, ry = 90, scale = 2 }, - { type = "camera", id = "camera", x = 0, y = 2.2, z = 5 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "Across the room", - on = "arrived", type = "hero", - act = { { "setVar", name = "across", value = true } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"the hero is at " .. floor(hero.x * 10) / 10' } } } - } -} diff --git a/testScripts/author/picture.forge b/testScripts/author/picture.forge deleted file mode 100644 index 217ccb77c..000000000 --- a/testScripts/author/picture.forge +++ /dev/null @@ -1,37 +0,0 @@ --- The pictureAt condition: what the disc's picture is at a point. One watcher counts the frames --- the spot reads dark and the frames it reads bright, so a run with no video proves the documented --- answer (dark everywhere, since the engine reads zeroes when nothing is playing) and a run with --- --framefile proves the condition sees the picture change. -return { - title = "Author test: the picture at a point", - players = 1, - size = { w = 720, h = 480 }, - layers = { { kind = "overlay" } }, - vars = { dark = 0, bright = 0, neither = 0 }, - - types = { - watcher = { look = { kind = "none" } } - }, - - rooms = { - { name = "only", reset = true, - entities = { - { id = "eye", type = "watcher", x = 8, y = 8 } - } } - }, - - rules = { - { note = "The spot is dark", - on = "frame", each = "watcher", - when = { { "pictureAt", x = 200, y = 120, is = "dark" } }, - act = { { "addVar", name = "dark", amount = 1 } } }, - { note = "The spot is bright", - on = "frame", each = "watcher", - when = { { "pictureAt", x = 200, y = 120, is = "bright" } }, - act = { { "addVar", name = "bright", amount = 1 } } }, - { note = "The spot is neither", - on = "frame", each = "watcher", - when = { { "pictureAt", x = 200, y = 120, is = "unknown" } }, - act = { { "addVar", name = "neither", amount = 1 } } } - } -} diff --git a/testScripts/author/platformer.forge b/testScripts/author/platformer.forge deleted file mode 100644 index 8287e34ad..000000000 --- a/testScripts/author/platformer.forge +++ /dev/null @@ -1,62 +0,0 @@ --- A game description: data, not code. Forge/AuthorCompile.singe turns it into a Singe game. --- --- Genre one of the two the first slice was built on. A single world2d room, a character that runs --- and jumps, a prize, and a readout. It shares nothing with the QTE beside it -- no disc, no --- branching, no time windows -- which is the point: if the core can express both, it is not --- secretly a platformer engine or secretly a laserdisc engine. -return { - title = "Author test: platformer", - layers = { { kind = "world2d", gravity = 1500 } }, - vars = { score = 0 }, - - types = { - ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, - ledge = { look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } }, - prize = { look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 }, behaviours = { { kind = "drift", vx = 0, vy = 0 } } }, - hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }, - readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "field", - entities = { - { type = "ground", id = "ground", x = 360, y = 440 }, - { type = "ledge", id = "ledge", x = 520, y = 330 }, - { type = "prize", id = "prize", x = 520, y = 300 }, - { type = "hero", id = "hero", x = 120, y = 380 }, - { type = "readout", id = "readout", x = 12, y = 12 } - } } - }, - - rules = { - { note = "Run left", - on = "frame", - when = { { "keyHeld", key = "LEFT" } }, - act = { { "run", entity = "hero", direction = -1 } } }, - - { note = "Run right", - on = "frame", - when = { { "keyHeld", key = "RIGHT" } }, - act = { { "run", entity = "hero", direction = 1 } } }, - - { note = "Jump, but only with ground underfoot", - on = "frame", - when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, - act = { { "jump", entity = "hero" } } }, - - { note = "Take the prize", - on = "collision", a = "hero", b = "prize", - act = { { "addScore", amount = 100 }, - { "destroy", entity = "other" }, - { "setVar", name = "won", value = true } } }, - - { note = "Fallen off the world: back to the start", - on = "frame", - when = { { "below", entity = "hero", y = 520 } }, - act = { { "moveTo", entity = "hero", x = 120, y = 380 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score' } } } - } -} diff --git a/testScripts/author/platformer3d.forge b/testScripts/author/platformer3d.forge deleted file mode 100644 index 409abc909..000000000 --- a/testScripts/author/platformer3d.forge +++ /dev/null @@ -1,65 +0,0 @@ --- Genre two of milestone 2: a 3D platformer. A character on the engine's controller, a camera --- that follows, floors and ledges, a prize that is a trigger, and a fall that puts the hero back. --- Nothing in it is shared with the rail shooter but the scene itself. -return { - title = "Author test: 3D platformer", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 55 }, { kind = "overlay" } }, - vars = { score = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 12, r = 90, g = 110, b = 90 }, behaviours = { { kind = "solid" } } }, - ledge = { look = { kind = "mesh", shape = "box", w = 4, h = 0.4, d = 4, r = 110, g = 90, b = 70 }, behaviours = { { kind = "solid" } } }, - prize = { look = { kind = "mesh", shape = "sphere", w = 0.6, r = 255, g = 200, b = 60 }, behaviours = { { kind = "trigger" } } }, - hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.9, d = 0.6 }, - behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" }, - { kind = "character", speed = 4, jump = 6.5, radius = 0.3, height = 0.9 }, - { kind = "animator", idle = "Survey", walk = "Walk" } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "hero", x = 0, y = 3, z = 7, lookY = 0.6, lag = 0.2 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "field", - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { type = "ledge", id = "ledge", x = 6, y = 1.2, z = 0 }, - { type = "prize", id = "prize", x = 6, y = 2.0, z = 0 }, - { type = "hero", id = "hero", x = -4, y = 0.5, z = 0 }, - { type = "camera", id = "camera", x = -4, y = 3, z = 7 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "Walking sets the state the animator plays", - on = "frame", each = "hero", - when = { { "test", expr = "self.dx != 0 or self.dy != 0" } }, - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "Standing still", - on = "frame", each = "hero", - when = { { "test", expr = "self.dx == 0 and self.dy == 0" } }, - act = { { "setState", entity = "self", state = "idle" } } }, - - { note = "Jump, with ground underfoot", - on = "frame", - when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, - act = { { "jump", entity = "hero" } } }, - - { note = "The prize is taken", - on = "enter", a = "prize", b = "hero", - act = { { "destroy", entity = "self" }, - { "addScore", amount = 100 }, - { "setVar", name = "won", value = true } } }, - - { note = "Fallen off the world: back to the start", - on = "frame", - when = { { "test", expr = "hero.y < -5" } }, - act = { { "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score' } } } - } -} diff --git a/testScripts/author/pool.forge b/testScripts/author/pool.forge deleted file mode 100644 index c29c5ba9e..000000000 --- a/testScripts/author/pool.forge +++ /dev/null @@ -1,42 +0,0 @@ --- The long tail, in 3D: water a body floats in, a boat pushed by its propeller, and a soft --- body -- the pieces a boat game or a physics toy needs. -return { - title = "Author test: the pool", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 90, g = 100, b = 120 }, fov = 55 }, { kind = "overlay" } }, - vars = { score = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 90, g = 90, b = 80 }, behaviours = { { kind = "solid" } } }, - pool = { look = { kind = "mesh", shape = "box", w = 20, h = 3, d = 20, r = 60, g = 120, b = 200 }, behaviours = { { kind = "water", density = 1 } } }, - ball = { look = { kind = "mesh", shape = "sphere", w = 0.8, r = 220, g = 80, b = 80 }, behaviours = { { kind = "body", shape = "sphere", mass = 2, buoyancy = 2 } } }, - boat = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.4, d = 2.6, r = 200, g = 170, b = 90 }, - behaviours = { { kind = "body", mass = 120, buoyancy = 3 }, { kind = "vehicle", type = "boat", mass = 120, thrust = 600 } } }, - balloon = { look = { kind = "mesh", shape = "sphere", w = 1, r = 240, g = 220, b = 80 }, behaviours = { { kind = "soft", type = "body", pressure = 2, mass = 0.6 } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1, lookZ = 0 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "pool", - entities = { - { type = "floor", id = "floor", x = 0, y = -3.2, z = 0 }, - { type = "pool", id = "pool", x = 0, y = -1.5, z = 0 }, - { type = "ball", id = "ball", x = 3, y = 3, z = 2 }, - { type = "boat", id = "boat", x = -4, y = 0.4, z = 6 }, - { type = "balloon", id = "balloon", x = 6, y = 4, z = -6 }, - { type = "camera", id = "camera", x = 0, y = 10, z = 16 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "The boat runs its propeller", - on = "frame", each = "boat", - act = { { "setVar", entity = "self", name = "dy", value = -1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"ball y " .. floor(ball.y * 10) / 10 .. " boat z " .. floor(boat.z * 10) / 10' } } } - } -} diff --git a/testScripts/author/qte.forge b/testScripts/author/qte.forge deleted file mode 100644 index fc2028c4f..000000000 --- a/testScripts/author/qte.forge +++ /dev/null @@ -1,47 +0,0 @@ --- Genre two of the first slice: a quick-time event over video. No physics, no character, no --- gravity -- the only layers are the disc and a flat overlay, and the rules talk about disc frames --- and windows. -return { - title = "Author test: QTE over video", - layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 180 }, { kind = "overlay" } }, - vars = { score = 0 }, - - types = { - prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } }, - verdict = { look = { kind = "text", text = "", r = 160, g = 255, b = 180 } }, - readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "scene", - entities = { - { type = "prompt", id = "prompt", x = 250, y = 60 }, - { type = "verdict", id = "verdict", x = 250, y = 100 }, - { type = "readout", id = "readout", x = 12, y = 12 } - } } - }, - - rules = { - { note = "The window is open: ask for the button", - on = "frame", - when = { { "discBetween", from = 200, to = 260 } }, - act = { { "setText", entity = "prompt", text = '"PRESS!"' } } }, - - { note = "Answered in time, once", - on = "frame", - when = { { "discBetween", from = 200, to = 260 }, { "switchHeld", switch = "SWITCH_BUTTON1" }, { "once", tag = "hit" } }, - act = { { "setVar", name = "hit", value = true }, - { "addScore", amount = 250 }, - { "setText", entity = "verdict", text = '"HIT"' } } }, - - { note = "The window has closed without an answer", - on = "frameReached", frame = 260, - when = { { "test", expr = "not hit" } }, - act = { { "setText", entity = "prompt", text = '""' }, - { "setText", entity = "verdict", text = '"MISS"' } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score' } } } - } -} diff --git a/testScripts/author/racing.forge b/testScripts/author/racing.forge deleted file mode 100644 index c2cbdddf7..000000000 --- a/testScripts/author/racing.forge +++ /dev/null @@ -1,66 +0,0 @@ --- Genre one of milestone 4: racing. A car on the engine's vehicle physics driven by the keys, --- a rival that drives a track of points on its own, checkpoints that are triggers, laps counted --- by rules, and a camera that follows. -return { - title = "Author test: racing", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 90, g = 90, b = 110 }, fov = 60 }, { kind = "overlay" } }, - vars = { score = 0, laps = 0, next = 1 }, - - types = { - ground = { look = { kind = "mesh", shape = "box", w = 120, h = 0.4, d = 200, r = 90, g = 100, b = 90 }, behaviours = { { kind = "solid" } } }, - car = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 220, g = 60, b = 60 }, - behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" }, - { kind = "vehicle", type = "car", torque = 900, mass = 800 } } }, - rival = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 60, g = 90, b = 220 }, - behaviours = { { kind = "vehicle", type = "car", torque = 700, mass = 800 }, - { kind = "racer", track = "lap", throttle = 0.5, reach = 4 } } }, - gate = { look = { kind = "none", w = 10, h = 3, d = 1 }, vars = { index = 1 }, behaviours = { { kind = "trigger" } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "car", x = 0, y = 4, z = 9, lookY = 0.5, lag = 0.3 } } }, - bar = { look = { kind = "mesh", shape = "box", w = 4, h = 0.3, d = 0.3, r = 200, g = 200, b = 60 }, - behaviours = { { kind = "body", mass = 20 }, { kind = "joint", type = "hinge", ax = -8, ay = 2, az = -20, dx = 0, dy = 0, dz = 1 } } }, - hover = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2, r = 60, g = 220, b = 200 }, - behaviours = { { kind = "body", mass = 100, friction = 0 }, { kind = "thrust", force = 900, turn = 0.5 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "track", - entities = { - { type = "ground", id = "ground", x = 0, y = -0.2, z = -40 }, - { type = "car", id = "car", x = 0, y = 0.8, z = 10 }, - { type = "rival", id = "rival", x = 4, y = 0.8, z = 12 }, - { type = "gate", id = "gate1", x = 0, y = 1.5, z = -10, vars = { index = 1 } }, - { type = "gate", id = "gate2", x = 0, y = 1.5, z = -35, vars = { index = 2 } }, - { type = "camera", id = "camera", x = 0, y = 4, z = 19 }, - { type = "bar", id = "bar", x = -6, y = 2, z = -20 }, - { type = "hover", id = "hover", x = 8, y = 0.4, z = 10 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - }, - tracks = { - { name = "lap", key = "time", points = { { at = 0, x = 4, y = 0, z = -10 }, { at = 1, x = 4, y = 0, z = -35 }, { at = 2, x = 4, y = 0, z = -70 } } } - } } - }, - - rules = { - { note = "Through the next gate", - on = "enter", a = "gate", b = "car", - when = { { "test", expr = "self.index == next" } }, - act = { { "addVar", name = "next", amount = 1 }, - { "addScore", amount = 10 } } }, - - { note = "Both gates passed: a lap", - on = "frame", - when = { { "test", expr = "next > 2" } }, - act = { { "addVar", name = "laps", amount = 1 }, - { "setVar", name = "next", value = 1 } } }, - - { note = "The hovercraft pushes forward on its own", - on = "frame", each = "hover", - act = { { "setVar", entity = "self", name = "dy", value = -1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"speed " .. floor(car.speed) .. " next gate " .. next .. " laps " .. laps' } } } - } -} diff --git a/testScripts/author/railshooter.forge b/testScripts/author/railshooter.forge deleted file mode 100644 index 343320ddb..000000000 --- a/testScripts/author/railshooter.forge +++ /dev/null @@ -1,120 +0,0 @@ --- Genre one of milestone 2: a rail shooter, House of the Dead in a table. A camera on a rail --- that stops at encounters, zombies (the Fox, standing in) that spawn at each stop, walk to the --- camera, and bite on a timer unless they are killed, a gun that hits what the pointer is over -- --- and which part -- ragdolls, and a level end. -return { - title = "Author test: rail shooter", - players = 1, - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 70, g = 70, b = 90 }, fog = { r = 12, g = 12, b = 22, near = 25, far = 60 }, fov = 60 }, - { kind = "overlay" } }, - vars = { score = 0, misses = 0, health = 3, bites = 0, stops = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.2, d = 100, r = 70, g = 75, b = 90 }, behaviours = { { kind = "solid" } } }, - wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 12, r = 120, g = 60, b = 60 }, behaviours = { { kind = "solid" } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "rail", track = "rail" } } }, - puff = { look = { kind = "billboard", file = "testScripts/crate.png", height = 0.6, w = 0.6, h = 0.6, d = 0.6 } }, - gun = { look = { kind = "none" }, vars = { ammo = 6 }, behaviours = { { kind = "gun", player = 1, ammo = 6, reload = "offscreen" } } }, - zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, - vars = { health = 2 }, - behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 2 }, - { kind = "seek", target = "camera", speed = 1.6, stopAt = 2.2 }, - { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 }, - { kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } }, - { kind = "timer", name = "bite", after = 1.5, start = false } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "street", - entities = { - { type = "floor", id = "floor", x = 0, y = -0.1, z = -30 }, - { type = "wall", id = "wallL", x = -6, y = 1.5, z = -12 }, - { type = "wall", id = "wallR", x = 6, y = 1.5, z = -12 }, - { type = "camera", id = "camera", x = 0, y = 1.6, z = 0 }, - { type = "gun", id = "gun1", x = 0, y = 0, z = 0 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - }, - tracks = { - { name = "rail", key = "time", - points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.2, -10 } }, - { at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" }, - { at = 10, x = 2, y = 1.6, z = -16, look = { 2, 1.2, -26 }, stop = "yard" }, - { at = 14, x = 2, y = 1.6, z = -22, look = { 2, 1.2, -32 } } } }, - { name = "puffs", key = "stop", - spawns = { { at = "hall", type = "puff", count = 2, every = 0.2, x = 0, y = 1, z = -10 }, - { at = "yard", type = "puff", count = 1, from = "wallL, wallR" } } } - } } - }, - - rules = { - { note = "The hall: three come out of the dark", - on = "stopped", name = "hall", - act = { { "addVar", name = "stops", amount = 1 }, - { "spawn", type = "zombie", x = -2, y = 0, z = -14 }, - { "spawn", type = "zombie", x = 2, y = 0, z = -15 }, - { "spawn", type = "zombie", x = 0, y = 0, z = -17 } } }, - - { note = "The yard: four more", - on = "stopped", name = "yard", - act = { { "addVar", name = "stops", amount = 1 }, - { "spawn", type = "zombie", x = -1, y = 0, z = -24 }, - { "spawn", type = "zombie", x = 4, y = 0, z = -25 }, - { "spawn", type = "zombie", x = 2, y = 0, z = -27 }, - { "spawn", type = "zombie", x = 0, y = 0, z = -28 } } }, - - { note = "A zombie starts walking the moment it is made", - on = "spawn", type = "zombie", - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "A shot lands", - on = "hit", type = "zombie", - act = { { "damage", entity = "self", amount = 1 }, - { "addScore", amount = 100 } } }, - - { note = "A headshot lands harder", - on = "hit", type = "zombie", - when = { { "hitPart", part = "head" } }, - act = { { "damage", entity = "self", amount = 2 }, - { "addScore", amount = 300 } } }, - - { note = "It reaches the camera and rears up", - on = "arrived", type = "zombie", - act = { { "setState", entity = "self", state = "attack" }, - { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, - - { note = "The bite lands unless it was killed in time", - on = "timer", name = "bite", type = "zombie", - when = { { "inState", entity = "self", state = "attack" } }, - act = { { "addVar", name = "health", amount = -1 }, - { "addVar", name = "bites", amount = 1 }, - { "flash", r = 255, g = 40, b = 40, seconds = 0.3 }, - { "shake", amount = 0.3, seconds = 0.4 }, - { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, - - { note = "A miss on the picture", - on = "miss", - when = { { "test", expr = "not event.offscreen" } }, - act = { { "addVar", name = "misses", amount = 1 } } }, - - { note = "The stop is clear: move on", - on = "frame", - when = { { "waiting", entity = "camera" }, { "test", expr = 'count("zombie") == 0' } }, - act = { { "pathNext", entity = "camera" } } }, - - { note = "Out of health", - on = "frame", - when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } }, - act = { { "say", text = '"YOU ARE DEAD"', seconds = 2 }, { "gameOver" } } }, - - { note = "The end of the rail", - on = "railEnd", - act = { { "setVar", name = "done", value = true }, - { "say", text = '"STAGE CLEAR"', seconds = 2 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo' } } } - } -} diff --git a/testScripts/author/rhythm.forge b/testScripts/author/rhythm.forge deleted file mode 100644 index 7f2a53be0..000000000 --- a/testScripts/author/rhythm.forge +++ /dev/null @@ -1,50 +0,0 @@ --- A rhythm game, from the catalogue: a track plays, notes are windows in time, and a press inside --- one scores while one outside is a miss. The notes are a track of points keyed by time, so the --- editor's timeline lays them out. -return { - title = "Author test: rhythm", - size = { w = 720, h = 480 }, - layers = { { kind = "overlay" }, { kind = "music", file = "testScripts/tune.mp3", volume = 60 } }, - vars = { score = 0, hits = 0, misses = 0, early = 0 }, - - types = { - lane = { look = { kind = "box", w = 60, h = 400, r = 40, g = 40, b = 60 } }, - marker = { look = { kind = "box", w = 80, h = 6, r = 200, g = 200, b = 220 } }, - note = { look = { kind = "box", w = 50, h = 16, r = 255, g = 200, b = 60 }, - behaviours = { { kind = "drift", vx = 0, vy = 200 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "stage", - entities = { - { type = "lane", id = "lane", x = 360, y = 240 }, - { type = "marker", id = "marker", x = 360, y = 400 }, - { type = "readout", id = "readout", x = 12, y = 12 } - }, - tracks = { - { name = "notes", key = "time", points = { { at = 1.0 }, { at = 2.0 }, { at = 2.5 }, { at = 3.5 } } } - } } - }, - - rules = { - { note = "A note falls two seconds before its time", - on = "frame", each = "lane", - when = { { "every", seconds = 0.5, tag = "spawn" }, { "test", expr = "time < 2" } }, - act = { { "spawn", type = "note", x = 360, y = 0 } } }, - - { note = "A press on a note at the marker", - on = "pressed", key = "SPACE", each = "note", - when = { { "test", expr = "abs(self.y - 400) < 24" } }, - act = { { "destroy", entity = "self" }, { "addScore", amount = 100 }, { "addVar", name = "hits", amount = 1 } } }, - - { note = "A note that falls past the marker is a miss", - on = "frame", each = "note", - when = { { "test", expr = "self.y > 440" } }, - act = { { "destroy", entity = "self" }, { "addVar", name = "misses", amount = 1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " hits " .. hits .. " misses " .. misses' } } } - } -} diff --git a/testScripts/author/shmup.forge b/testScripts/author/shmup.forge deleted file mode 100644 index 57e7f05c7..000000000 --- a/testScripts/author/shmup.forge +++ /dev/null @@ -1,77 +0,0 @@ --- Genre one of milestone 1: a shoot-em-up. Kinds spawned by the dozen, bullets that collide, --- health, a spawner, a HUD document, sound, and a game over -- none of which the first slice could --- say. Everything is a box, so the description needs no art. -return { - title = "Author test: shoot-em-up", - layers = { { kind = "overlay" }, { kind = "hud", document = "testScripts/author/hud.rml", bind = { score = "score", lives = "lives" } } }, - vars = { score = 0, lives = 3 }, - - types = { - ship = { look = { kind = "box", w = 28, h = 20, r = 120, g = 220, b = 255 }, - vars = { health = 1 }, - behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" }, - { kind = "mover", speed = 260, clamp = true }, - { kind = "shooter", spawns = "shot", rate = 0.15, offsetY = -14 }, - { kind = "health", max = 1, keep = true }, - { kind = "sound", hit = "testScripts/crackle.wav" } } }, - shot = { look = { kind = "box", w = 4, h = 10, r = 255, g = 255, b = 160 }, - behaviours = { { kind = "projectile", vx = 0, vy = -520, life = 3 } } }, - raider = { look = { kind = "box", w = 26, h = 18, r = 240, g = 80, b = 80 }, - vars = { health = 2 }, - behaviours = { { kind = "drift", vx = 0, vy = 90 }, - { kind = "health", max = 2 }, - { kind = "sound", death = "testScripts/crackle.wav" } } }, - hive = { look = { kind = "none" }, - behaviours = { { kind = "spawner", spawns = "raider", every = 0.6, max = 6, total = 12, - points = { { x = 100, y = -20 }, { x = 300, y = -20 }, { x = 500, y = -20 }, { x = 640, y = -20 } } } } } - }, - - rooms = { - { name = "space", - entities = { - { type = "ship", id = "ship", x = 360, y = 420 }, - { type = "hive", id = "hive", x = 360, y = -20 } - } } - }, - - rules = { - { note = "A shot lands on a raider", - on = "collision", a = "shot", b = "raider", - act = { { "destroy", entity = "self" }, - { "damage", entity = "other", amount = 1 }, - { "addScore", amount = 10 } } }, - - { note = "A raider dies", - on = "death", type = "raider", - act = { { "emit", entity = "self", count = 24, r = 255, g = 120, b = 60 }, - { "addScore", amount = 50 } } }, - - { note = "A raider rams the ship", - on = "collision", a = "raider", b = "ship", - act = { { "destroy", entity = "self" }, - { "damage", entity = "other", amount = 1 } } }, - - { note = "The ship is lost", - on = "death", type = "ship", - act = { { "addVar", name = "lives", amount = -1 }, - { "flash", r = 255, g = 60, b = 60, seconds = 0.3 }, - { "setVar", entity = "self", name = "health", value = 1 }, - { "moveTo", entity = "self", x = 360, y = 420 } } }, - - { note = "Out of lives", - on = "frame", - when = { { "test", expr = "lives <= 0" } }, - act = { { "gameOver" } } }, - - { note = "A raider slips past the bottom", - on = "frame", each = "raider", - when = { { "test", expr = "self.y > 500" } }, - act = { { "destroy", entity = "self" } } }, - - { note = "The wave is done", - on = "frame", - when = { { "test", expr = 'count("raider") == 0 and hive.made >= 12' }, { "once", tag = "wave", scope = "game" } }, - act = { { "setVar", name = "cleared", value = true }, - { "say", text = '"WAVE CLEAR"', seconds = 2 } } } - } -} diff --git a/testScripts/author/space.forge b/testScripts/author/space.forge deleted file mode 100644 index c87599d8e..000000000 --- a/testScripts/author/space.forge +++ /dev/null @@ -1,48 +0,0 @@ --- A space shooter, from the catalogue: a ship on a body pushed by thrust and turned by the keys, --- bolts it fires, rocks that come at it, and a camera behind. No gravity. -return { - title = "Author test: space", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", gravity = 0, ambient = { r = 40, g = 40, b = 70 }, fov = 65 }, { kind = "overlay" } }, - vars = { score = 0, rocks = 0 }, - - types = { - ship = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2.4, r = 200, g = 210, b = 240 }, - behaviours = { { kind = "body", mass = 50, friction = 0 }, - { kind = "thrust", force = 400, turn = 1.2 }, - { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" }, - { kind = "shooter", spawns = "bolt", rate = 0.2, offsetY = 0 } } }, - bolt = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 240, b = 120 }, - behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = -30, life = 3 } } }, - rock = { look = { kind = "mesh", shape = "sphere", w = 2, r = 130, g = 110, b = 90 }, - vars = { health = 1 }, - behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = 6, life = 20 }, { kind = "health", max = 1 } } }, - belt = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "rock", every = 0.8, max = 8, total = 12, points = { { x = -4, y = 0, z = -60 }, { x = 0, y = 0, z = -60 }, { x = 4, y = 0, z = -60 }, { x = 2, y = 1, z = -60 } } } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ship", x = 0, y = 3, z = 8, lookY = 0, lag = 0.2 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "belt", - entities = { - { type = "ship", id = "ship", x = 0, y = 0, z = 0 }, - { type = "belt", id = "belt", x = 0, y = 0, z = -60 }, - { type = "camera", id = "camera", x = 0, y = 3, z = 8 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "A bolt meets a rock", - on = "collision", a = "bolt", b = "rock", - act = { { "destroy", entity = "self" }, { "damage", entity = "other", amount = 1 } } }, - - { note = "A rock is broken", - on = "death", type = "rock", - act = { { "addScore", amount = 25 }, { "addVar", name = "rocks", amount = 1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " rocks " .. rocks' } } } - } -} diff --git a/testScripts/author/tail.forge b/testScripts/author/tail.forge deleted file mode 100644 index 2b68a03a8..000000000 --- a/testScripts/author/tail.forge +++ /dev/null @@ -1,73 +0,0 @@ --- The long tail, in 2D: a sprite sheet stepped by state, a map of tiles, and MIDI notes as an --- event -- the pieces a rhythm game or a maze game needs. -return { - title = "Author test: the long tail", - size = { w = 720, h = 480 }, - layers = { { kind = "overlay" } }, - vars = { score = 0, notes = 0 }, - - types = { - runner = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" }, - behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5, walkLeft=1-2" }, - { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, - { kind = "mover", speed = 120, clamp = true } } }, - -- The same sheet, drawn facing right only: facing left mirrors it, and rz turns a frame. - mirrored = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" }, - behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5" }, - { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, - { kind = "mover", speed = 120, clamp = true }, - { kind = "sound", hit = "testScripts/crackle.wav", volume = 30 } } }, - floor = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 2 1 2 1; 2 1 2 1 2; 3 4 3 4 3" } }, - spinner = { look = { kind = "sprite", file = "testScripts/checker.png" }, behaviours = { { kind = "spin", rate = 90 } } }, - fire = { look = { kind = "particles", r = 255, g = 140, b = 40, rate = 60, size = 10, speed = 80, life = 0.8 } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "stage", - entities = { - { type = "floor", id = "floor", x = 200, y = 300 }, - { type = "runner", id = "runner", x = 360, y = 300 }, - { type = "mirrored", id = "mirror", x = 360, y = 420 }, - { type = "mirrored", id = "tilted", x = 120, y = 200, rz = 30 }, - { type = "spinner", id = "spinner", x = 560, y = 200, rz = 45 }, - { type = "fire", id = "fire", x = 640, y = 400 }, - { type = "readout", id = "readout", x = 12, y = 12 } - } } - }, - - rules = { - { note = "Moving means walking", - on = "frame", each = "runner", - when = { { "test", expr = "self.dx != 0" } }, - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "Still means idle", - on = "frame", each = "runner", - when = { { "test", expr = "self.dx == 0" } }, - act = { { "setState", entity = "self", state = "idle" } } }, - - { note = "The mirrored one walks the same", - on = "frame", each = "mirrored", - when = { { "test", expr = "self.dx != 0" } }, - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "Middle C is a quiet click as well", - on = "midi", pitch = 60, - act = { { "playSound", file = "testScripts/crackle.wav", volume = 40 } } }, - - { note = "Middle C scores", - on = "midi", pitch = 60, - act = { { "addScore", amount = 10 }, - { "addVar", name = "notes", amount = 1 } } }, - - { note = "Any other note is counted", - on = "midi", - when = { { "test", expr = "event.pitch != 60" } }, - act = { { "addVar", name = "notes", amount = 1 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"frame " .. runner.frame .. " " .. runner.state .. " score " .. score .. " notes " .. notes' } } } - } -} diff --git a/testScripts/author/thirdperson.forge b/testScripts/author/thirdperson.forge deleted file mode 100644 index b296aba44..000000000 --- a/testScripts/author/thirdperson.forge +++ /dev/null @@ -1,78 +0,0 @@ --- Third-person action, from the catalogue: a character the keys move relative to a camera that --- orbits it under the mouse, enemies that walk the mesh after it and bite, and a swing of the --- sword on a key at whatever is in reach. -return { - title = "Author test: third person", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 60 }, { kind = "overlay" } }, - vars = { score = 0, health = 5, swings = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 80, g = 100, b = 80 }, behaviours = { { kind = "solid" } } }, - hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.9, d = 0.6 }, - behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" }, - { kind = "character", speed = 4, jump = 5, radius = 0.3, height = 0.9 }, - { kind = "animator", idle = "Survey", walk = "Walk" } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "orbit", target = "hero", y = 1.2, z = 6, lookY = 0.6 } } }, - enemy = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 }, - vars = { health = 2 }, - behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1 }, - { kind = "walker", speed = 2, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 }, - { kind = "animator", idle = "Survey", walk = "Walk", attack = "Run" }, - { kind = "timer", name = "bite", after = 1.5, start = false } } }, - hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "enemy", every = 1.5, max = 3, total = 4, points = { { x = -8, y = 0, z = -8 }, { x = 8, y = 0, z = -8 } } } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }, - sign = { look = { kind = "text3d", text = "ARENA", height = 0.6 } } - }, - - rooms = { - { name = "field", - navFrom = { "floor" }, - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { type = "hero", id = "hero", x = 0, y = 0.2, z = 4 }, - { type = "camera", id = "camera", x = 0, y = 2, z = 10 }, - { type = "hive", id = "hive", x = 0, y = 0, z = -8 }, - { type = "sign", id = "sign", x = 0, y = 2, z = -8 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "An enemy walks from the moment it is made", - on = "spawn", type = "enemy", - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "Moving means walking", - on = "frame", each = "hero", - when = { { "test", expr = "self.dx != 0 or self.dy != 0" } }, - act = { { "setState", entity = "self", state = "walk" } } }, - - { note = "Still means idle", - on = "frame", each = "hero", - when = { { "test", expr = "self.dx == 0 and self.dy == 0" } }, - act = { { "setState", entity = "self", state = "idle" } } }, - - { note = "A swing of the sword hits whatever is in reach", - on = "pressed", key = "SPACE", each = "enemy", - when = { { "test", expr = 'distance(self, "hero") < 2.2' } }, - act = { { "damage", entity = "self", amount = 1 }, { "addVar", name = "swings", amount = 1 } } }, - - { note = "A kill", - on = "death", type = "enemy", - act = { { "addScore", amount = 100 } } }, - - { note = "It reaches the hero and rears up", - on = "arrived", type = "enemy", - act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, - - { note = "The bite lands unless it was killed", - on = "timer", name = "bite", type = "enemy", - when = { { "inState", entity = "self", state = "attack" } }, - act = { { "addVar", name = "health", amount = -1 }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health' } } } - } -} diff --git a/testScripts/author/towerdefence.forge b/testScripts/author/towerdefence.forge deleted file mode 100644 index 5ac2a7ef1..000000000 --- a/testScripts/author/towerdefence.forge +++ /dev/null @@ -1,70 +0,0 @@ --- Genre two of milestone 4: tower defence. Creeps that walk a lane on the navigation mesh in --- waves, turrets placed with a click on the floor that fire at the nearest creep in range, --- projectiles that collide, gold and lives. Nothing here is shared with the racing game but the --- scene. -return { - title = "Author test: tower defence", - size = { w = 720, h = 480 }, - layers = { { kind = "scene3d", ambient = { r = 80, g = 90, b = 80 }, fov = 55 }, { kind = "overlay" } }, - vars = { score = 0, gold = 120, lives = 5, killed = 0 }, - - types = { - floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 24, r = 80, g = 110, b = 80 }, behaviours = { { kind = "solid" } } }, - creep = { look = { kind = "mesh", shape = "box", w = 0.6, h = 0.8, d = 0.6, r = 120, g = 200, b = 90 }, - vars = { health = 3 }, - behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 }, - { kind = "patrol", points = { { x = -10, y = 0, z = -6 }, { x = 10, y = 0, z = -6 }, { x = 10, y = 0, z = 6 }, { x = -10, y = 0, z = 6 } } }, - { kind = "health", max = 3 } } }, - hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "creep", every = 1.2, max = 8, total = 10, points = { { x = -12, y = 0, z = -6 } } } } }, - turret = { look = { kind = "mesh", shape = "cylinder", w = 0.8, h = 1.2, r = 70, g = 90, b = 220 }, - behaviours = { { kind = "turret", targets = "creep", range = 6, rate = 0.5, spawns = "bolt", speed = 14 } } }, - bolt = { look = { kind = "mesh", shape = "sphere", w = 0.25, r = 255, g = 230, b = 80 }, - behaviours = { { kind = "projectile", life = 2 } } }, - camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0, lookZ = 0 } } }, - readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } } - }, - - rooms = { - { name = "field", - navFrom = { "floor" }, - entities = { - { type = "floor", id = "floor", x = 0, y = -0.2, z = 0 }, - { type = "hive", id = "hive", x = -12, y = 0, z = -6 }, - { type = "camera", id = "camera", x = 0, y = 20, z = 14 }, - { type = "readout", id = "readout", x = 12, y = 12, z = 0 } - } } - }, - - rules = { - { note = "A click on the floor builds a turret, for fifty gold", - on = "pressed", switch = "SWITCH_BUTTON3", - when = { { "test", expr = "gold >= 50" } }, - act = { { "spawnAtPointer", type = "turret", player = 1 }, - { "addVar", name = "gold", amount = -50 } } }, - - { note = "A bolt lands", - on = "collision", a = "bolt", b = "creep", - act = { { "destroy", entity = "self" }, - { "damage", entity = "other", amount = 1 } } }, - - { note = "A creep dies", - on = "death", type = "creep", - act = { { "addVar", name = "gold", amount = 20 }, - { "addScore", amount = 10 }, - { "addVar", name = "killed", amount = 1 } } }, - - { note = "A creep reaches the end of the lane", - on = "patrolEnd", type = "creep", - act = { { "destroy", entity = "self" }, - { "addVar", name = "lives", amount = -1 } } }, - - { note = "Out of lives", - on = "frame", - when = { { "test", expr = "lives <= 0" }, { "once", tag = "over", scope = "game" } }, - act = { { "gameOver" } } }, - - { note = "The readout, every frame", - on = "frame", - act = { { "setText", entity = "readout", text = '"gold " .. gold .. " lives " .. lives .. " killed " .. killed .. " creeps " .. count("creep")' } } } - } -} diff --git a/testScripts/author/vocab.forge b/testScripts/author/vocab.forge deleted file mode 100644 index 92c301272..000000000 --- a/testScripts/author/vocab.forge +++ /dev/null @@ -1,32 +0,0 @@ --- 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 deleted file mode 100644 index fa130be80..000000000 --- a/testScripts/author/vocab.singe +++ /dev/null @@ -1,47 +0,0 @@ --- 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.forge b/testScripts/author/waves.forge deleted file mode 100644 index d125a723e..000000000 --- a/testScripts/author/waves.forge +++ /dev/null @@ -1,37 +0,0 @@ --- Waves and soundDone: a track of spawns by time -- three rocks from two spots in turn, then --- two at a point -- and a bell that says when its clip has ended. -return { - title = "Author test: waves", - players = 1, - layers = { { kind = "world2d" }, { kind = "overlay" } }, - vars = { made = 0, rung = 0 }, - - types = { - rock = { look = { kind = "box", w = 20, h = 20, r = 200, g = 120, b = 60 } }, - spot = { look = { kind = "none" } }, - bell = { look = { kind = "none" }, behaviours = { { kind = "sound", spawn = "testScripts/crackle.wav", volume = 40 } } } - }, - - rooms = { - { name = "field", - entities = { - { type = "spot", id = "left", x = 60, y = 240 }, - { type = "spot", id = "right", x = 660, y = 240 }, - { type = "bell", id = "bell", x = 0, y = 0 } - }, - tracks = { - { name = "waves", key = "time", - spawns = { { at = 0.5, type = "rock", count = 3, every = 0.2, from = "left, right" }, - { at = 1.5, type = "rock", count = 2, x = 360, y = 100 } } } - } } - }, - - rules = { - { note = "Count the rocks as they come", - on = "spawn", type = "rock", - act = { { "addVar", name = "made", amount = 1 } } }, - { note = "The bell's clip ended", - on = "soundDone", type = "bell", - act = { { "addVar", name = "rung", amount = 1 } } } - } -} diff --git a/testScripts/games.dat b/testScripts/games.dat index 405733f18..8eb41af18 100644 --- a/testScripts/games.dat +++ b/testScripts/games.dat @@ -552,4 +552,25 @@ GAMES = { DEVELOPER = "Test", PUBLISHER = "Test", }, + { + TITLE = "Calls: Collision, Disc, Sound, Input, Packing", + SCRIPT = "testScripts/scene101.singe", + VIDEO = "Singe/menuBackground.mkv", + DESCRIPTION = "Engine calls no other scene exercised, each checked against its Reference entry: collideRects, collidePointRect and collidePointPolygon on known shapes; the disc's state through play, pause, discSkipForward, discSkipToFrame and stop, with discChangeSpeed, discSetFPS and discAudio; a sound channel through soundPause and soundResume; keyboardIsDown, keyboardGetModifiers and mouseSetMode; videoPause and videoSetVolume; spriteFlip; and singePack, the packed script read back through the container.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, + { + TITLE = "Calls: Nodes, Physics, Players, Navigation, Ragdolls", + SCRIPT = "testScripts/scene102.singe", + DESCRIPTION = "The 3D calls no other scene exercised, each checked against its Reference entry: nodeSetName found by nodeFind, bodyApplyForce lifting a crate, physicsSetEnabled holding and releasing it, playerSetPosition, emitterDelete, navNearest on and off the floor, navAgentStop, navAgentIsArrived, navAgentDelete, and ragdollDelete freeing a node for a new ragdoll.", + YEAR = 2026, + GENRE = "Test", + PLATFORM = "Singe", + DEVELOPER = "Test", + PUBLISHER = "Test", + }, } diff --git a/testScripts/ports/README b/testScripts/ports/README deleted file mode 100644 index 26a434d1e..000000000 --- a/testScripts/ports/README +++ /dev/null @@ -1,30 +0,0 @@ -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 /ActionMax/frame_.txt and PORT_GAME= - (38AmbushAlley, BlueThunder, Hydrosub2021, PopsGhostly, SonicFury); - the descriptions come from util/forgePortActionMax.py. - - compare.sh 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= and - PORT_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= 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 deleted file mode 100644 index b6e05785e..000000000 --- a/testScripts/ports/actionMaxInputs.singe +++ /dev/null @@ -1,36 +0,0 @@ --- 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 deleted file mode 100644 index 99816b748..000000000 --- a/testScripts/ports/actionMaxOriginal.singe +++ /dev/null @@ -1,52 +0,0 @@ --- 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 /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 deleted file mode 100644 index c23c7c213..000000000 --- a/testScripts/ports/actionMaxPort.singe +++ /dev/null @@ -1,52 +0,0 @@ --- 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 deleted file mode 100644 index aeb12f356..000000000 --- a/testScripts/ports/algDrive.singe +++ /dev/null @@ -1,263 +0,0 @@ --- 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 deleted file mode 100644 index 8c946976d..000000000 --- a/testScripts/ports/algOriginal.singe +++ /dev/null @@ -1,515 +0,0 @@ --- 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>.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 deleted file mode 100644 index 49b5f8e36..000000000 --- a/testScripts/ports/algPort.singe +++ /dev/null @@ -1,503 +0,0 @@ --- 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 deleted file mode 100755 index 03f86c6e1..000000000 --- a/testScripts/ports/compare.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/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 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/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/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/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} - # The Hypseus layout wins when singe/ holds the description, even beside a Script/ of the - # game's own files (the one-player Johnny Rock HD edition keeps both). - hypseusDesc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) - if [[ -z "$hypseusDesc" && -d "$library/$game/Script" ]]; then - # The library's own copy (Script/, Cfg/, Video/ with the framefile beside the video; - # Platoon keeps its Video/ under Script/): the script is the description's namesake, - # the video the framefile in Video/. - desc=$(ls "$library/$game/Script"/*.forge 2>/dev/null | head -1) - export PORT_SCRIPT=Script/$(basename "${desc%.forge}").singe - video=$(ls "$library/$game/Video"/*.txt "$library/$game/Script/Video"/*.txt 2>/dev/null | head -1) - # No framefile: the video itself, when the title keeps one there. - [[ -n "$video" ]] || video=$(ls "$library/$game/Video"/*.m2v "$library/$game/Video"/*.mp4 "$library/$game/Video"/*.mkv "$library/$game/Script/Video"/*.m2v 2>/dev/null | head -1) - else - desc=$(find -L "$library/$game/singe" -mindepth 2 -maxdepth 2 -name "*.forge" 2>/dev/null | head -1) - if [[ -n "$desc" ]]; then - 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/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/forge" "$run/Forge" - ;; - *) - echo "compare.sh: no family called $family" >&2 - exit 2 - ;; -esac -if [[ -z "${video:-}" ]]; then - echo "compare.sh: no video for $family $game in the library; nothing to compare" >&2 - exit 2 -fi -export SINGE_LIBRARY=$library PORT_GAME=$game SDL_VIDEODRIVER=${SDL_VIDEODRIVER:-offscreen} SDL_AUDIO_DRIVER=dummy -trace() { - local name=$1 script=$2 work - work=$(mktemp -d "$run/$name.XXXXXX") - local pass passes=1 - # PORT_TWICE=1 runs each side twice in the same data directory (the original's own files are - # put back only after both), so the second run shows what the first one saved: the - # Persistence row of FORGE.md 14.18. The trace is both runs', the drive told which it is. - [[ "${PORT_TWICE:-}" == "1" ]] && passes=2 - : > "$work/trace" - for (( pass = 1; pass <= passes; pass++ )); do - # An hour a run; a run spinning in Lua never sees the quit SIGTERM asks for, so -k kills it. - ( cd "$run" && PORT_PASS=$pass timeout -k 30 3600 "$binary" -w -p -k --deterministic -d "$work" -v "$video" "$script" ) > "$work/run$pass.log" 2>&1 - (( pass > 1 )) && echo "TRACE run $pass" >> "$work/trace" - grep -a '^TRACE' "$work/run$pass.log" | grep -v 'state 2 from' >> "$work/trace" - done - cp "$work/run1.log" "$work/run.log" - grep -a 'rror\|Author:' "$work/run.log" | head -3 >&2 - # The screenshots a drive took (PORT_SHOTS=1) land in the run's data directory; with - # SINGE_SHOTS set they are gathered there, named for the run. - if [[ -n "${SINGE_SHOTS:-}" ]]; then - mkdir -p "$SINGE_SHOTS" - # The engine saves under the game's own folder in the data directory, wherever that is. - find "$work" -name "*.png" -print0 | while IFS= read -r -d '' png; do - cp "$png" "$SINGE_SHOTS/$name-$(basename "$png")" - done - fi - echo "$work/trace" -} -# The originals autosave into their own Cfg folder as they play, which would give the port's -# snapshot and the next run different saves to read: the folder is put back as it was. -cfgDir="" -# One comparison of a title at a time: two would each keep the original's files, then put them -# back over each other. A second waits for the first. -exec 9> "${TMPDIR:-/tmp}/compare-$family-${game//\//_}.lock" -flock 9 -if [[ -n "${PORT_SCRIPT:-}" ]]; then - cfgDir=$library/$game/$(dirname "$PORT_SCRIPT")/Cfg - [[ -d "$cfgDir" ]] || cfgDir=$library/$game/Cfg - [[ -d "$cfgDir" ]] || cfgDir="" -fi -if [[ -n "$cfgDir" ]]; then - cfgKeep=$(mktemp -d "$run/cfg.XXXXXX") - cp -r "$cfgDir/." "$cfgKeep/" -fi -# A title that keeps its dips and board in a .cfg beside its script (the gun games, the map-mode -# games) has the original write it at every board: kept and put back the same way, whatever the -# family, or a run leaves its scores in the library for the next description to snapshot. -cfgFiles="" -if [[ -n "${PORT_SCRIPT:-}" ]]; then - cfgFiles=$(ls "$library/$game/$(dirname "$PORT_SCRIPT")"/*.cfg 2>/dev/null) - if [[ -n "$cfgFiles" ]]; then - cfgKeepFiles=$(mktemp -d "$run/cfgfiles.XXXXXX") - 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 deleted file mode 100644 index 9cc21b55f..000000000 --- a/testScripts/ports/karisDrive.singe +++ /dev/null @@ -1,776 +0,0 @@ --- 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 -AUTHOR_SEED = KARIS_SEED -- The port's runtime seeds its generator from this, as the original's os.time does. - -local lastLine = nil -local pressed = nil -- The switch held down this frame, released the next. -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 --- PORT_ATTRACT=N lets the attract loop run N frames before the coin and START, so its own screens --- (the rankings, the best percent) are played and traced too. -local ATTRACT = tonumber(os.getenv("PORT_ATTRACT")) or 0 -local MISS_EVERY = tonumber(os.getenv("PORT_MISS_EVERY")) or 7 -local function wrongMove(view) - return (view.move % WRONG_EVERY == 0) and not view.retry -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 --- A map-mode game's pair kinds, by the name of the game's constant for each: what is held with --- what. A game that does not define a constant (one with no diagonals) simply has no such pair. -KARIS_PAIR_KINDS = { { "ACTUP", 5, 1 }, { "ACTDOWN", 5, 2 }, { "ACTLEFT", 5, 3 }, { "ACTRIGHT", 5, 4 }, { "UPLEFT", 1, 3 }, { "UPRIGHT", 1, 4 }, { "DOWNLEFT", 2, 3 }, { "DOWNRIGHT", 2, 4 } } - - --- The pair kinds a game defines, keyed by its own numbers; constants maps a name to the number. -function karisPairs(constants) - local found = {} - - for _, kind in ipairs(KARIS_PAIR_KINDS) do - local number = constants[kind[1]] - - if number ~= nil then - found[number] = { kind[2], kind[3] } - end - end - - return found -end - --- The framework's move numbers as switches; Kimmy's diagonals are two of them. -local SWITCH_OF = { [1] = SWITCH_UP, [2] = SWITCH_DOWN, [3] = SWITCH_LEFT, [4] = SWITCH_RIGHT, [5] = SWITCH_BUTTON1, [6] = SWITCH_BUTTON2, [7] = SWITCH_BUTTON3 } -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 - - --- PORT_SERVICE=1 walks the service screens from the attract loop before the game: SERVICE, then --- the options' first row on to the graphics screen, its rows each changed once (RIGHT) and --- "Default", round to its first row and on to the performance screen, the same there, on to the --- options, the same, and EXIT; a press every WALK_EVERY frames, clear of the screens' quarter --- second between moves. While a screen shows, the trace carries its row and the dips. -local walkService = (os.getenv("PORT_SERVICE") == "1") -local WALK_EVERY = 10 -local WALK = { SWITCH_SERVICE } -local walkAt = nil -local walkEvents = {} - - --- A screen's rows walked from its first: each one's value changed, the second from last --- ("Default") used, and, unless it is the last screen, round to the first row and on. -local function walkScreen(rows, leave) - WALK[#WALK + 1] = SWITCH_DOWN - for _ = 2, rows - 2 do - WALK[#WALK + 1] = SWITCH_RIGHT - WALK[#WALK + 1] = SWITCH_DOWN - end - WALK[#WALK + 1] = SWITCH_RIGHT -- Default - WALK[#WALK + 1] = SWITCH_DOWN - if leave then - WALK[#WALK + 1] = SWITCH_RIGHT -- EXIT - else - WALK[#WALK + 1] = SWITCH_DOWN -- Round to the first row ... - WALK[#WALK + 1] = SWITCH_RIGHT -- ... and on to the next screen. - end -end - - -WALK[#WALK + 1] = SWITCH_RIGHT -- The options' first row, on to the graphics screen. -walkScreen(15, false) -- The graphics screen, on to the performance screen. -walkScreen(10, false) -- The performance screen, on to the options. -walkScreen(15, true) -- The options, and EXIT. -local SERVICE_DIPS = { "dip_GameType", "dip_Diffshow", "dip_PlayStyle", "dip_StartLevel", "dip_StartScene", "dip_Rewind", "dip_CoinsPerCredit", "dip_LivesPerCredit", "dip_LimitContinue", "dip_ShowAction", "dip_Hints", "dip_MashtoRun", - "dip_Extravid", "ShowTitle", "ShowLCD", "ShowLevel", "ShowTop", "ScorePos", "LivPos", "CoinPos", "fontType", "fontSize", "mycolor1", "mycolor2", - "dip_Model", "dip_Res", "dip_Display", "dip_Scanlines", "dip_ScType", "dip_LivType", "dip_FontQ" } - - --- The options screen's row and dips, or the save or exit screen's row and choices, from a reader of --- the loop's variables, while it shows. -function karisServiceState(screen, read) - local parts = {} - - if (screen == 108) or (screen == 112) then - return "row " .. tostring(read("optSel")) .. " Loadsave " .. tostring(read("dip_Loadsave")) .. " Slot " .. tostring(read("dip_Slot")) .. " InPlayExit " .. tostring(read("bInPlayExit")) - end - if (screen ~= 104) and (screen ~= 113) and (screen ~= 114) then - return nil - end - parts[1] = "rows " .. tostring(read("optSel")) .. " " .. tostring(read("optSelG")) .. " " .. tostring(read("optSelP")) - for _, name in ipairs(SERVICE_DIPS) do - parts[#parts + 1] = name:sub(5) .. " " .. tostring(read(name)) - end - - return table.concat(parts, " ") -end - - --- Switches tapped, as a walk's events: each a press, then its release. -local function tapsOf(switches) - local events = {} - - for _, switch in ipairs(switches) do - events[#events + 1] = { switch, true } - events[#events + 1] = { switch, false } - end - - return events -end - - --- PORT_TITLETAPS=1 taps the switches that do something beyond a move while the title plays, --- before any service walk: the original pauses through the title, so none should count; the --- trace carries the credits. -local titleTaps = (os.getenv("PORT_TITLETAPS") == "1") - -if titleTaps then - walkEvents = tapsOf({ SWITCH_COIN1, SWITCH_START1, SWITCH_SERVICE, SWITCH_TEST, SWITCH_CONSOLE, SWITCH_SKILL1 }) -end -if walkService then - table.move(tapsOf(WALK), 1, #WALK * 2, #walkEvents + 1, walkEvents) -end - - --- PORT_PLAYWALK=1 walks the in-play switches, each part once the game has answered so many moves --- and out of a move's window: a quick save to slot 1 (SKILL1); TEST to the save screen, saving --- to slot 2 and back to the game; CONSOLE to the exit screen and BACK; START held with SKILL1, --- loading slot 1; COIN held with START, to the exit screen and BACK. -local PLAY_WALK = (os.getenv("PORT_PLAYWALK") == "1") and { - { after = 2, events = tapsOf({ SWITCH_SKILL1 }) }, - { after = 3, events = tapsOf({ SWITCH_TEST, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT, SWITCH_DOWN, SWITCH_RIGHT }) }, - { after = 5, events = tapsOf({ SWITCH_CONSOLE, SWITCH_DOWN, SWITCH_BUTTON1 }) }, - { after = 6, events = { { SWITCH_START1, true }, { SWITCH_SKILL1, true }, { SWITCH_SKILL1, false }, { SWITCH_START1, false } } }, - { after = 8, events = { { SWITCH_COIN1, true }, { SWITCH_START1, true }, { SWITCH_START1, false }, { SWITCH_COIN1, false }, { SWITCH_DOWN, true }, { SWITCH_DOWN, false }, { SWITCH_BUTTON1, true }, { SWITCH_BUTTON1, false } } } -} or {} -local playPart = 1 - --- The second of PORT_TWICE's runs first loads slot 2 (START held with SKILL2), which the first --- run saved from the save screen: the slots' part of the Persistence row. -if (#PLAY_WALK > 0) and (os.getenv("PORT_PASS") == "2") then - table.insert(PLAY_WALK, 1, { after = 1, events = { { SWITCH_START1, true }, { SWITCH_SKILL2, true }, { SWITCH_SKILL2, false }, { SWITCH_START1, false } } }) -end - - --- The next part of the play walk, when the game has answered enough moves and no window is near. -local function karisPlayWalk(view, moves) - local part = PLAY_WALK[playPart] - - if (#walkEvents > 0) or (part == nil) or (view.screen ~= 103) or (moves < part.after) then - return - end - if view.window and (view.frame >= view.window.from - 30) and (view.frame <= view.window.to + 5) then - return - end - playPart = playPart + 1 - walkEvents = { table.unpack(part.events) } - walkAt = nil -end - - --- One event of the walk each WALK_EVERY frames. True while walking. -local function walk(view) - if #walkEvents == 0 then - return false - end - walkAt = walkAt or view.frames - if view.frames - walkAt < WALK_EVERY then - return true - end - walkAt = view.frames - local event = table.remove(walkEvents, 1) - - karisPress(event[1], event[2]) - - return true -end - - --- PORT_HUD=1 adds what the HUD shows to each trace line, a letter a flag the loop sets, so an --- original and its port are compared on what a player sees as well as on the game's state. -local HUD_FLAGS = { - { "S", "bShowScore" }, { "L", "bShowLives" }, { "V", "bShowLvl" }, { "T", "bShowTop" }, - { "C", "bShowCredits" }, { "A", "bShowAction" }, { "K", "bShowSkip" }, { "G", "bShowGet" }, - { "H", "bShowChoices" }, { "D", "bShowLCD" }, { "P", "bPause" }, { "1", "bShowDisk1" }, - { "2", "bShowDisk2" }, { "3", "bShowDisk3" }, { "4", "bShowDiskA" } -} -local traceHud = (os.getenv("PORT_HUD") == "1") - - --- The HUD flags as letters, from a reader of the loop's variables (the original's globals, or --- the port's q). -function karisHudFlags(read) - local letters = {} - - if not traceHud then - return nil - end - for _, flag in ipairs(HUD_FLAGS) do - if read(flag[2]) then - letters[#letters + 1] = flag[1] - end - end - - return table.concat(letters) -end - - --- PORT_SOUND=1 traces each sound played or stopped, by its file's name, and the disc's audio --- turned off or on, with the disc frame: the Sound row of FORGE.md 14.18. The engine's own calls --- are wrapped before the game loads, so the original's sounds and the port's (which Forge plays --- through the same calls) are caught the same way. -if os.getenv("PORT_SOUND") == "1" then - local names = {} - local realSoundLoad = soundLoad - local realSoundPlay = soundPlay - - soundLoad = function(file, ...) - local handle = realSoundLoad(file, ...) - - if handle ~= nil then - names[handle] = tostring(file):match("([^/\\]+)$") - end - - return handle - end - soundPlay = function(handle, ...) - debugPrint(string.format("TRACE sound %s disc %d", tostring(names[handle]), discGetFrame())) - - return realSoundPlay(handle, ...) - end - -- A sound stopped, and the disc's own audio turned off or on, change what is heard too. - local realSoundStop = soundStop - local realDiscAudio = discAudio - - soundStop = function(handle, ...) - debugPrint(string.format("TRACE soundStop %s disc %d", tostring(names[handle]), discGetFrame())) - - return realSoundStop(handle, ...) - end - discAudio = function(channel, enabled, ...) - debugPrint(string.format("TRACE discAudio %s %s disc %d", tostring(channel), tostring(enabled), discGetFrame())) - - return realDiscAudio(channel, enabled, ...) - end -end - - --- PORT_SHOTS=N takes up to N screenshots, numbered in order: one at each change the trace --- prints, and one a frame into each move's window (the drive answers within a few). The traces --- of an original and its port agree, so the same number is the same moment in both, and the --- pictures can be diffed. -local SHOT_INTO_WINDOW = 1 --- A second run of PORT_TWICE names its pictures apart from the first's. -local SHOT_PREFIX = ((os.getenv("PORT_PASS") or "1") == "1") and "shot-" or ("shot-run" .. os.getenv("PORT_PASS") .. "-") -local shotsLeft = tonumber(os.getenv("PORT_SHOTS") or "0") or 0 -local shotCount = 0 - - -function karisShot(why) - if shotsLeft > 0 then - shotsLeft = shotsLeft - 1 - shotCount = shotCount + 1 - singeScreenshot(string.format("%s%04d-%s", SHOT_PREFIX, shotCount, why)) - end -end - - -function karisTraceSeek(frame) - seeks = seeks + 1 - debugPrint(string.format("TRACE seek %d", frame)) -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 view.hud then - line = line .. " hud " .. view.hud - end - if view.service then - line = line .. " " .. view.service - end - if titleTaps then - line = line .. " credits " .. tostring(view.credits) - end - - if line ~= lastLine then - lastLine = line - debugPrint(line .. " disc " .. tostring(view.frame)) - karisShot("state") - end - -- A few frames into a move's window, where its prompt shows. - if view.window and (view.frame == view.window.from + SHOT_INTO_WINDOW) then - karisShot("window") - end - -- The options screen's walk, on the attract loop before anything else, and the play walk's - -- parts. - local moves = 0 - - for _ in pairs(answered) do - moves = moves + 1 - end - karisPlayWalk(view, moves) - if ((playPart > 1) or (view.screen == 0) or (view.screen == 104) or (view.screen == 113) or (view.screen == 114)) and walk(view) then - return - end - -- A coin on the attract loop when the game is not free play, then START once it has begun - -- playing. - if (view.screen == 0) and not started and (view.frame >= 150) and (view.frames >= ATTRACT) and (not view.freeplay) and (view.credits == 0) and not coined then - coined = true - pressed = SWITCH_COIN1 - karisPress(pressed, true) - return - end - if (view.screen == 0) and not started and (view.frame >= 160) and (view.frames >= ATTRACT) 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 deleted file mode 100644 index f7cf8373f..000000000 --- a/testScripts/ports/karisOriginal.singe +++ /dev/null @@ -1,107 +0,0 @@ --- 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 = karisPairs(_G) - end - -- A choice: the framework's at branch10, the later map-mode copies' at branch02. - if ((lvlState == branch10) or (rdg and (lvlState == branch02))) and choice then - 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, - hud = karisHudFlags(function(name) return _G[name] end), service = karisServiceState(currentLevel, function(name) return _G[name] end) } -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 deleted file mode 100644 index 7201c225e..000000000 --- a/testScripts/ports/karisPort.singe +++ /dev/null @@ -1,106 +0,0 @@ --- 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 - -- The style the qte behaviour plays, as the flags the view reports. - local held = (q.qteStyle == "held") - local checkpoints = (q.qteStyle == "checkpoints") - local segmented = (q.qteStyle == "segments") or checkpoints or (q.qteStyle == "trophies") - local m = q.move[q.currentMove] - local window = nil - local chosen = nil - - if checkpoints then - if (q.currentLevel == 103) and m and (m[2] ~= nil) then - window = { from = m[2], to = m[3], kind = m[7], death = 0, alt = 0, alt2 = 0 } - end - 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 segmented and q.ACTUP then - pairs = karisPairs(q) - end - if ((q.lvlState == 19) or (segmented and (q.lvlState == 11))) and q.choice then - chosen = {} - for index, c in ipairs(q.choice) do - chosen[index] = { right = c[2] } - end - end - if checkpoints 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 held and (screen == q.levelNG) then - screen = nil - end - -- The map-mode lineage never numbered its finish screen either. - if segmented and (screen == QTE_LEVEL_FINISH) then - screen = nil - end - - return { screen = screen, step = q.lvlState, level = q.iLevel or q.iCurPos or q.iStageIndex, scene = q.iScene or q.iSegPointer, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives, window = window, choices = chosen, order = q.optorder, choice = q.iChoice, - kimmy = held, rdg = segmented, timegal = checkpoints, multi = held and q.multi[q.currentMove] or nil, path = held and q.path[q.currentMove] or nil, timed = held and q.timed or nil, - freeplay = (q.dip_CoinsPerCredit == q.DOPT_FREEPLAY), credits = q.iCredits, zone = zone, mouse = mouse, pairs = pairs, mashKind = segmented and q.MASH or nil, - hud = karisHudFlags(function(name) return q[name] end), service = karisServiceState(screen, function(name) return q[name] end) } -end - - -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 deleted file mode 100755 index 6f7cbd84b..000000000 --- a/testScripts/ports/launch.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/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/ledger.md b/testScripts/ports/ledger.md deleted file mode 100644 index 61fe662e6..000000000 --- a/testScripts/ports/ledger.md +++ /dev/null @@ -1,242 +0,0 @@ -# The ledger (FORGE.md section 17) - -Generated by `util/forgeLedger.py` on 2026-09-28 from the converter's report for each -description it made; do not edit by hand. **forge** is a field of Forge's own model, -**framework** something Forge plays under the framework's names or numbers, **lua** -something the game's own Lua still does. Rows are sorted by status, then by how many -titles use them: the top of the table is what most blocks titles from being Forge's own. -A hook from the add-ons file most titles ship unchanged (19 of them) is the framework's -stock one, and counts as framework. The globals are every global the game's script left -that nothing names: most are the framework's own state and picture numbers, not data. - -76 titles reported; 0 failed to report; 5 descriptions made by another tool (not covered). - -## Capabilities - -| Status | Capability | Titles | Some of them | Details | -|---|---|---|---|---| -| lua | script | 49 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | AlteredCarbon.singe; Arcade_Xperience_Vol1.singe; Arcade_Xperience_Vol2.singe; Arcade_Xperience_Vol3.singe; Asterix.singe; Astroboy.singe; BD13.singe; Chantze.singe; ... | -| lua | vocabulary | 27 | crimepatrol, drugwars, drugwars, crimepatrol, timetraveler, lastbountyhunter, ... | Vocabulary.singe | -| lua | levelFunctions | 11 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | Puss_in_Boots.singe; Sdq.singe; conan_singe_2.singe; daitarn_singe_2.singe; dlair.singe; dragon_singe_2.singe; freedomfighter.singe; hq-timegal.singe; ... | -| lua | hook.startConf | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | -| lua | hook.swapLevel | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | -| lua | hook.doLevelSelect | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | -| lua | hook.moveCursor | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | -| lua | hook.specialScore | 3 | DL2e, DLe, BD13 | Script/addons.singe | -| lua | hook.swapDeath | 3 | SAe, BD13, hayate_1080 | Script/addons.singe | -| lua | hook.AllowLvlEnd | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.AllowLvlQuartz | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.LvlMapComplete | 2 | f13, SuckerPunch | Script/addons.singe | -| lua | hook.ShowComplete | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.TestDips | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.TestState | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.loadSavePlus | 2 | DL2e, BD13 | Script/addons.singe | -| lua | hook.mapConf | 2 | Chantze, Triad | Script/addons.singe | -| lua | hook.writeSavePlus | 2 | DL2e, BD13 | Script/addons.singe | -| framework | setting.* | 75 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | -| framework | global.* | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| framework | judge.press.direction | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | DOWN; LEFT; RIGHT; UP | -| framework | judge.press.button | 35 | AlteredCarbon, Asterix, Cliff, DL2e, DLe, Fire_and_Ice, ... | BUTTON1; BUTTON2; BUTTON3 | -| framework | judge.skip | 30 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, Fire_and_Ice, ... | SKIP | -| framework | judge.mash.one | 25 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | MASH; MASHMAX; MASHMIN | -| framework | hook.LevelMapComplete | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | -| framework | hook.doLevelSelect | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | -| framework | hook.moveCursor | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | -| framework | hook.swapScene | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, f13, ... | empty; stock add-ons | -| framework | judge.chord.buttonDirection | 20 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | ACTDOWN; ACTLEFT; ACTRIGHT; ACTUP | -| framework | option.style | 18 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | checkpoints; held; segments; trophies | -| framework | judge.hold.direction | 16 | AlteredCarbon, Fire_and_Ice_v2, f13, LINEA, mononoke, oeil-pour-oeil, ... | HOLDDOWN; HOLDLEFT; HOLDRIGHT; HOLDUP | -| framework | judge.mash.run | 15 | AlteredCarbon, Fire_and_Ice_v2, f13, mononoke, SuckerPunch, Titan_AE, ... | RUN; RUNMAX; RUNMIN | -| framework | judge.press.diagonal | 14 | Fire_and_Ice_v2, LINEA, mononoke, oeil-pour-oeil, SuckerPunch, the-elder-scrolls, ... | DOWNLEFT; DOWNRIGHT; UPLEFT; UPRIGHT | -| framework | judge.hold.button | 12 | AlteredCarbon, LINEA, oeil-pour-oeil, the-elder-scrolls, Tron, Arcade_Xperience_Vol2, ... | HOLDBUT | -| framework | judge.jump | 9 | DL2e, DLe, Hayate, SAe, BD13, Esh_Aurunmilla, ... | TOLEVEL; TOSCENE; WAY; WAYOUT | -| framework | judge.path | 9 | DL2e, DLe, Hayate, SAe, BD13, StarshipTroopers, ... | PATH | -| framework | persist.dieAndRetryRecords | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | | -| framework | judge.circle | 5 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Danmachi, mazinga | LOOP; LOOPLEFT; LOOPRIGHT | -| framework | judge.double | 5 | AlteredCarbon, Fire_and_Ice_v2, SuckerPunch, Tron, mazinga | DOUBLE | -| framework | judge.letGo | 5 | Fire_and_Ice_v2, mononoke, Tron, Danmachi, Esh_Aurunmilla | LETGO | -| framework | judge.mash.two | 5 | LINEA, Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy | MASH2MAX; MASH2MIN | -| framework | judge.count | 3 | LINEA, Danmachi, mazinga | MULTI | -| framework | judge.timed | 3 | DLe, SAe, badlands_lite | TIMED | -| framework | hook.swapDeath | 2 | Esh_Aurunmilla, Sintel | empty | -| framework | judge.choose | 2 | Timegal, FumaConspiracy | CHOOSE | -| framework | judge.unnamed | 2 | LINEA, Arcade_Xperience_Vol2 | kind 156 (UL); kind 157 (UR); kind 158 (DL); kind 159 (DR); kind 160 (LU); kind 162 (LD); kind 167 (LDB); kind 181 (DRU); ... | -| framework | hook.loadSavePlus | 1 | Sintel | empty | -| framework | hook.specialScore | 1 | Sintel | empty | -| framework | hook.startConf | 1 | Sintel | empty | -| framework | hook.swapLevel | 1 | Sintel | empty | -| framework | hook.writeSavePlus | 1 | Sintel | empty | -| framework | judge.anything | 1 | BD13 | ANYTHING | -| framework | judge.hold | 1 | mazinga | HOLD | -| framework | judge.sequence | 1 | LINEA | DU; LR; RL; UD | -| framework | judge.yesNo | 1 | SAe | YESNO | -| framework | order.trophies | 1 | Sdq | | -| framework | scoring.percents | 1 | Sdq | | -| framework | tweak.introHold | 1 | freedomfighter | | -| framework | tweak.rightSound | 1 | freedomfighter | | -| forge | persist.boards | 76 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | -| forge | deaths.clips | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | deaths.randomCount | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | lifeBar.missCost | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | lifeBar.regainAfter | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | lifeBar.size | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | order.levels | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | order.play | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | order.tiers | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | persist.saves | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.attract | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.clear | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.continue | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.enterName | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.extraDeath | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.gameIntro | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.gameOver | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.gameOverAlt | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.getReady | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.newHighScore | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | screen.title | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | secret.allowed | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.controls | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.options | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.save | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.special | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.trophy | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.victory | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | video.fps | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | video.relativeFrames | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.hints | 37 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.scene | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | still.secret | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | order.mapStart | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.deathPenalty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.extraLifeEvery | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.game | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.level | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.move | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.noDeathBonus | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.perDifficulty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | scoring.secret | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | windowDelay.extreme | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | windowDelay.hard | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | windowDelay.normal | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | -| forge | family.qte.windows | 31 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | 3.31c; 3.32b; Mazescater 1.00 | -| forge | still.easy | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | -| forge | still.normal | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | -| forge | still.extreme | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | -| forge | still.hard | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | -| forge | family.alg | 25 | crimepatrol, drugwars, drugwars, crimepatrol, lastbountyhunter, lastbountyhunter, ... | | -| forge | option.gameOverAfterContinue | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | -| forge | option.secretInputs | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | -| forge | family.qte.segments | 9 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | 1.1; 2.2 | -| forge | family.qte.held | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | 4.0 | -| forge | scene.swaps | 6 | Chantze, Triad, DL2e, SuckerPunch, Tron, BD13 | read from swapScene | -| forge | screen.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | -| forge | still.newGame | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | -| forge | still.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | -| forge | family.qte.checkpoints | 1 | hq-timegal | | -| forge | family.qte.trophies | 1 | Sdq | 2.21 | -| forge | family.timetraveler | 1 | timetraveler | | -| forge | family.videoland | 1 | Rollercoaster | | -| forge | option.alternateMashRate | 1 | LINEA | | -| forge | option.doubleKind | 1 | LINEA | | -| forge | option.hintSeconds | 1 | LINEA | | -| forge | option.mashAnySwitch | 1 | LINEA | | -| forge | still.difficult | 1 | Titan_AE | | -| forge | still.rankingsAlt | 1 | BD13 | | - -## Settings under the framework's names (69) - -By how many titles use each: dip_LivesPerCredit 75, dip_Difficulty 74, dip_CoinsPerCredit 58, dip_LimitContinue 50, dip_ShowAction 49, dip_GameType 48, dip_StartLevel 48, dip_Rewind 47, dip_Diffshow 39, dip_Extravid 39, dip_Hints 39, dip_Res 39, dip_StartScene 39, CoinPos 38, LivPos 38, ScorePos 38, ShowLCD 38, ShowLevel 38, ShowTitle 38, ShowTop 38, dip_Display 38, dip_FontQ 38, dip_MashtoRun 38, dip_Model 38, dip_PlayStyle 38, dip_ScType 38, fontSize 38, fontType 38, mycolor1 38, mycolor2 38, dip_HoldtoLoop 36, dip_LivType 36, dip_Scanlines 34, dip_Undertaker 25, dip_Crosshair 24, dip_ReloadTrigger 19, dip_Showdown 17, dip_Lang 12, dip_AllowContinue 11, dip_Practice 11, dip_Debug 10, dip_HUD 10, dip_Kidmode 10, dip_Movie 10, dip_StartAtPart 10, dip_StartSegment 9, dip_CoinsPerContinue 8, dip_MashRes 7, dip_Next 7, dip_OVLS 7, dip_Tilt 7, mycolor3 7, dip_MaxBullets 5, dip_PoolIndex 5, dip_MustBeatLevel 2, dip_Bilinear 1, dip_Chroma 1, dip_IntroType 1, dip_PlayEveryLevel 1, dip_RevCubeMax 1, dip_ShowHellgate 1, dip_ShowWizard 1, dip_SortStyle 1, dip_Special 1, dip_showAction 1, t1 1, t2 1, t3 1, t4 1. - -## Globals nothing names yet (455) - -By how many titles use each: ARROWSELL 38, ARROWSELR 38, BARFULL 38, BARNONE 38, BONUS 38, BUTTONS 38, CREDIT 38, D1 38, D2 38, D3 38, DA 38, DEATH 38, FREEPLAY 38, ForceRewind 38, GETREADY 38, Group1 38, Group2 38, Group3 38, Group4 38, Hit 38, INSERTCOIN 38, LEVEL 38, LIVES 38, LvlMap 38, MINUS 38, MOVE 38, MovieFPS 38, NOMOVES 38, OKMAP 38, PERFECT 38, PW0 38, PW1 38, PW2 38, PW3 38, PW4 38, PW5 38, PW6 38, PW7 38, PWFULL 38, SCAN 38, SCENES 38, SCORE 38, SECRET 38, SELMAP 38, SKIPINTRO 38, SPECIALS 38, STICK 38, TB 38, TG 38, TIME 38, TN 38, TOPSCORE 38, TOTAL 38, TP 38, TS 38, Tlimit 38, altCfg 38, altState 38, altState2 38, b1PEnd 38, b1PStart 38, b2PEnd 38, b2PStart 38, bAct 38, bAllowSave 38, bCalc 38, bCheckForCredits 38, bCheckMove 38, bExtendedPlay 38, bFlip 38, bGOAlt 38, bGR 38, bIgnoreJoy 38, bInPlayExit 38, bLCDLineWait 38, bLevelComplete 38, bOneDiff 38, bPath 38, bPause 38, bPlayPrompt 38, bRes 38, bResetContinue 38, bSave 38, bShowAction 38, bShowCredits 38, bShowDisk 38, bShowGet 38, bShowLCD 38, bShowLives 38, bShowLvl 38, bShowScore 38, bShowSkip 38, bShowTop 38, bShuffleOrder 38, bSkipIntroClip 38, bSwap 38, bTestHold 38, bTestMash 38, bTestRunL 38, bTestRunR 38, bTime 38, curPath 38, currentFrame 38, currentLevel 38, currentMove 38, diffState 38, dip_Loadsave 38, dip_Slot 38, finalstage 38, flow_GameInit 38, flow_GameRunning 38, flow_VLDPStart 38, fontSizeNew 38, gameflow 38, i1PScore 38, i2P 38, i2PScore 38, iBonus 38, iChoice 38, iCoins 38, iContinues 38, iCredits 38, iDeathCount 38, iExtraLife 38, iLCDline 38, iLCDpointer 38, iLCDwait 38, iLCDx 38, iLastFiller 38, iLenHold 38, iLevel 38, iLevelEnd 38, iLevelStart 38, iLifeBar 38, iLiveSave 38, iLives 38, iMash 38, iMovie 38, iPath 38, iPathA 38, iPathAend 38, iPathAjmp 38, iPathB 38, iPauseFrame 38, iPenal 38, iRightMv 38, iRoll 38, iScDeath 38, iScPlayed 38, iScene 38, iScore 38, iScoreSave 38, iScoreTemp 38, iStageIndex 38, iTempLevel 38, iTop 38, iTotDeath 38, iWrongMv 38, lastHold 38, lenCounter 38, lfs 38, lvlState 38, mashCounter 38, numChoice 38, numTrophy 38, p1BUTTON1 38, p1BUTTON2 38, p1BUTTON3 38, p1COIN1 38, p1COIN2 38, p1CONSOLE 38, p1DOWN 38, p1LEFT 38, p1PAUSE 38, p1RIGHT 38, p1SERVICE 38, p1START1 38, p1START2 38, p1UP 38, posGauge 38, sLCD 38, scene 38, segID 38, sndclear 38, sndcoin 38, sndcredit 38, snddeath 38, sndright 38, sndroll 38, sndvictory 38, sndwrong 38, stage 38, t1 38, t1l 38, t2 38, t2l 38, t3 38, t3l 38, t4 38, t4l 38, thisMove 38, ycoinpos 38, ydskpos 38, ylivpos 38, ylvlpos 38, yscopos 38, offsetDeath 37, offsetMenus 37, offsetMovieEnd 37, AllowMap 35, AllowRandom 35, IngameDiffchoice 35, LvlTrophy3 35, LvlTrophy4 35, miscB 34, miscG 34, miscR 34, offsetMap 34, offsetMapend 34, AllowTier 33, HOLD 31, level2P 31, level2PEnd 31, bScan 30, bShowCubes 20, iMulti 19, LivesPrev 18, ScorePrev 18, LangOpt 14, BASEOVERLAY 12, DOUBLE 12, OVERLAY_FULL 12, OVERLAY_HALF 12, OVERLAY_OVERSIZE 12, iLoopPrev 12, iLoopStep 12, PlayerNum 11, SBCOH 11, SBCOW 11, dip_MinimalOverlay 11, OVERLAY_DEFAULT 9, dip_Lang 8, AllowDie 7, AllowKid 7, AllowLifeBar 7, AllowNormal 7, AllowPercent 7, AllowSurvivor 7, B1 7, B2 7, B3 7, D 7, DL 7, DR 7, HDOWN 7, HLEFT 7, HRIGHT 7, HUP 7, KIMMY 7, L 7, MASH3 7, NB1 7, NB2 7, NB3 7, NDL 7, NDOWN 7, NDR 7, NEMPTY 7, NHDOWN 7, NHLEFT 7, NHOLD 7, NHRIGHT 7, NHUP 7, NLEFT 7, NM 7, NRIGHT 7, NSDOWN 7, NSLEFT 7, NSRIGHT 7, NSUP 7, NUL 7, NUMX 7, NUP 7, NUR 7, PAUSED 7, R 7, SKINDIR 7, TILT 7, U 7, UL 7, UR 7, WAIT 7, WARNING 7, acombo 7, bLvlJump 7, bNoScreen 7, bScnJump 7, bShowFrame 7, bShowNext 7, bShowTilt 7, bShowWarnTilt 7, bTestRunD 7, bTestRunU 7, bUnlockSel 7, dip_LiveType 7, gcombo 7, iCountCombo 7, iTilt 7, iTiltMv 7, iTimer 7, iTrigTilt 7, levelDR 7, mcombo 7, multi 7, n 7, pathLook 7, xCredit 7, xFreePlay 7, xTop 7, MazeGame 6, bAddons 3, devMode 2, dip_Bilinear 2, Allow2Player 1, ArcadeMode 1, BUTTONMASHB2 1, BUTTONMASHB3 1, BUTTONMASHDOWN 1, BUTTONMASHLEFT 1, BUTTONMASHRIGHT 1, BUTTONMASHUP 1, CPHeight 1, CPHeightLo 1, CPwidth 1, CPwidthLo 1, CreditPannel 1, CreditPannelLo 1, LEDBarw 1, LEDBarwLo 1, LEDPannel 1, LPwidth 1, LPwidthLo 1, LivesPannel 1, LivesPannelLo 1, SSDheight 1, SSDheightLo 1, SSDwidth 1, SSDwidthLo 1, ScorePannel 1, ScorePannelLo 1, SpriteMenu 1, a 1, alCfg 1, bBilinear 1, bBranchDeath 1, bLang 1, bShowTitle 1, disable2P 1, disable2PLo 1, disable2Pw 1, disable2PwLo 1, exitmenu 1, exitmenuLo 1, exitmenuh 1, exitmenuhLo 1, exitmenuw 1, exitmenuwLo 1, gap 1, h_OverlayTweak 1, hyplogo 1, hyplogoLo 1, hyplogow 1, hyplogowLo 1, insertCoinb 1, insertCoinbLo 1, insertCoinw 1, insertCoinwLo 1, ledFull 1, ledFullLo 1, ledNone 1, ledNoneLo 1, patch_mazinga 1, press1p 1, press1pLo 1, press1pw 1, press1pwLo 1, sndchange 1, sndenter 1, sndprompt 1, sndselect 1, sndtest 1, sprSSD 1, sprSSDLo 1, w_OverlayTweak 1, waitLook 1, xLEDCredits 1, xLEDCreditsLo 1, xLEDLives 1, xLEDLivesLo 1, xLEDScore 1, xLEDScoreLo 1, xattract 1, xattractLo 1, xattractb 1, xattractbLo 1, xattractw 1, xattractwLo 1, xback 1, xbackLo 1, xbackb 1, xbackbLo 1, xbackw 1, xbackwLo 1, xcontinue 1, xcontinueLo 1, xcontinueb 1, xcontinuebLo 1, xcontinuew 1, xcontinuewLo 1, xnewgame 1, xnewgameLo 1, xnewgameb 1, xnewgamebLo 1, xnewgamew 1, xnewgamewLo 1, xoptions 1, xoptionsLo 1, xoptionsb 1, xoptionsbLo 1, xoptionsw 1, xoptionswLo 1, xpaused 1, xpausedLo 1, xpausedh 1, xpausedhLo 1, xpausedw 1, xpausedwLo 1, xquit 1, xquitLo 1, xquitb 1, xquitbLo 1, xquitw 1, xquitwLo 1, xreturn 1, xreturnLo 1, xreturnb 1, xreturnbLo 1, xreturnw 1, xreturnwLo 1, yLEDCredits 1, yLEDCreditsLo 1, yLEDLives 1, yLEDLivesLo 1, yLEDScore 1, yLEDScoreLo 1. - -## Titles - -Runs without its Lua: `standalone.txt`, last proved 2026-09-24. - -| Title | Family | forge | framework | lua | Runs without its Lua | -|---|---|---|---|---|---| -| Rollercoaster/Rollercoaster | videoland | 2 | 0 | 1 | not on the list | -| hypseus/typing-md2/singe/typing-md2/typing-md2 | alg | 2 | 4 | 1 | not on the list | -| CrimePatrol2DrugWarsHD/Script/drugwars | alg | 2 | 6 | 1 | not on the list | -| CrimePatrolHD/Script/crimepatrol | alg | 2 | 6 | 1 | not on the list | -| CrimePatrol/Script/crimepatrol | alg | 2 | 7 | 1 | not on the list | -| SpacePirates/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | -| SpacePiratesHD/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | -| hypseus/crimepatrol-hd/singe/crimepatrol-hd/crimepatrol-hd | alg | 2 | 7 | 1 | not on the list | -| hypseus/drugwars-hd/singe/drugwars-hd/drugwars-hd | alg | 2 | 7 | 1 | not on the list | -| hypseus/lbh-hd/singe/lbh-hd/lbh-hd | alg | 2 | 7 | 1 | not on the list | -| hypseus/spacepirates-hd/singe/spacepirates-hd/spacepirates-hd | alg | 2 | 7 | 1 | not on the list | -| CrimePatrol2DrugWars/Script/drugwars | alg | 2 | 8 | 1 | not on the list | -| LastBountyHunter/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | -| LastBountyHunterHD/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | -| Platoon/Script/platoon | alg | 2 | 8 | 1 | not on the list | -| hypseus/maddog2-hd/singe/maddog2-hd/maddog2-hd | alg | 2 | 8 | 1 | not on the list | -| MadDogMcCree/Script/maddog | alg | 2 | 9 | 1 | not on the list | -| MadDogMcCree2/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | -| MadDogMcCree2HD/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | -| MadDogMcCreeHD/Script/maddog | alg | 2 | 9 | 1 | not on the list | -| WhoShotJohnnyRock/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | -| WhoShotJohnnyRockHD/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | -| hypseus/WSJR_HD/WSJR_HD | alg | 2 | 9 | 1 | not on the list | -| hypseus/johnnyrocknoir/johnnyrocknoir | alg | 2 | 9 | 1 | not on the list | -| hypseus/maddog-hd/singe/maddog-hd/maddog-hd | alg | 2 | 9 | 1 | not on the list | -| hypseus/wsjr-hd/singe/wsjr-hd/wsjr-hd | alg | 2 | 10 | 1 | not on the list | -| HologramTimeTraveler/Script/timetraveler | timetraveler | 2 | 11 | 1 | not on the list | -| NinjaHayateHD/Hayate | qte.windows | 52 | 266 | 1 | yes | -| TimeGal/Timegal | qte.windows | 52 | 268 | 1 | yes | -| CliffHanger/Cliff | qte.windows | 52 | 269 | 1 | yes | -| Asterix/Asterix | qte.windows | 48 | 273 | 1 | yes | -| FireAndIce/Fire_and_Ice | qte.windows | 52 | 273 | 1 | yes | -| TheElderScrolls/the-elder-scrolls | qte.windows | 48 | 274 | 1 | yes | -| TitanAE/Titan_AE | qte.windows | 52 | 278 | 1 | yes | -| hypseus/badlands_lite/singe/badlands_lite/badlands_lite | qte.windows | 54 | 278 | 1 | yes | -| AlteredCarbon/AlteredCarbon | qte.windows | 52 | 281 | 1 | yes | -| OeilPourOeil/oeil-pour-oeil | qte.windows | 48 | 281 | 1 | yes | -| hypseus/Survival/singe/Survival/Survival | qte.windows | 54 | 281 | 1 | yes | -| FireAndIce/Fire_and_Ice_v2 | qte.windows | 52 | 284 | 1 | yes | -| Tron/Tron | qte.windows | 54 | 284 | 1 | yes | -| hypseus/Freddy/singe/Freddy/Freddy | qte.windows | 54 | 286 | 1 | not on the list | -| hypseus/StarshipTroopers/singe/StarshipTroopers/StarshipTroopers | qte.windows | 54 | 286 | 1 | not on the list | -| Mononoke/mononoke | qte.windows | 52 | 287 | 1 | not on the list | -| hypseus/Sugar_Rush/singe/Sugar_Rush/Sugar_Rush | qte.windows | 54 | 287 | 1 | not on the list | -| hypseus/Arcade_Xperience_Vol1/singe/Arcade_Xperience_Vol1/Arcade_Xperience_Vol1 | qte.windows | 54 | 301 | 1 | yes | -| hypseus/Arcade_Xperience_Vol2/singe/Arcade_Xperience_Vol2/Arcade_Xperience_Vol2 | qte.windows | 54 | 302 | 1 | not on the list | -| hypseus/Astroboy/singe/Astroboy/Astroboy | qte.windows | 54 | 302 | 1 | not on the list | -| hypseus/Arcade_Xperience_Vol3/singe/Arcade_Xperience_Vol3/Arcade_Xperience_Vol3 | qte.windows | 54 | 304 | 1 | not on the list | -| hypseus/Danmachi/singe/Danmachi/Danmachi | qte.windows | 54 | 306 | 1 | not on the list | -| Linea/LINEA | qte.windows | 56 | 331 | 1 | yes | -| hypseus/sintel/singe/Sintel/Sintel | qte.held | 38 | 346 | 1 | not on the list | -| hypseus/FBC/singe/FBC/FBC | qte.held | 54 | 351 | 1 | not on the list | -| hypseus/FumaConspiracy/singe/FumaConspiracy/FumaConspiracy | qte.held | 52 | 351 | 1 | not on the list | -| hypseus/Sonic_the_Hedgehog_1996/singe/Sonic_the_Hedgehog_1996/Sonic_the_Hedgehog_1996 | qte.held | 52 | 354 | 1 | yes | -| hypseus/mazinga/singe/mazinga/mazinga | qte.held | 55 | 364 | 1 | yes | -| hypseus/hq-timegal/hq-timegal | qte.checkpoints | 2 | 10 | 2 | not on the list | -| hypseus/jeeg/singe/jeeg/jeeg | qte.segments | 2 | 13 | 2 | not on the list | -| hypseus/starblazers/starblazers | qte.segments | 2 | 13 | 2 | not on the list | -| Conan/Script/conan_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | -| Daitarn3/Script/daitarn_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | -| DragonTrainer/Script/dragon_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | -| DragonsLairTvShow/Script/dlair | qte.segments | 2 | 14 | 2 | not on the list | -| PussInBoots/Script/Puss_in_Boots | qte.segments | 2 | 14 | 2 | not on the list | -| SamuraiJack/Script/jack_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | -| hypseus/freedomfighter/freedomfighter | qte.segments | 2 | 15 | 2 | not on the list | -| SuperDonQuixote/Script/Sdq | qte.trophies | 2 | 24 | 2 | not on the list | -| hypseus/Esh_Aurunmilla/singe/Esh_Aurunmilla/Esh_Aurunmilla | qte.windows | 50 | 283 | 3 | not on the list | -| DragonsLairEnhanced/DLe | qte.windows | 52 | 267 | 4 | not on the list | -| SpaceAceEnhanced/SAe | qte.windows | 52 | 268 | 4 | not on the list | -| FridayThe13th/f13 | qte.windows | 52 | 271 | 4 | not on the list | -| SuckerPunch/SuckerPunch | qte.windows | 54 | 282 | 4 | yes | -| hypseus/hayate_1080/singe/hayate_1080/hayate_1080 | qte.held | 39 | 455 | 4 | not on the list | -| DragonsLair2Enhanced/DL2e | qte.windows | 53 | 266 | 6 | yes | -| hypseus/BD13/singe/BD13/BD13 | qte.held | 41 | 347 | 7 | not on the list | -| ChantzesStone/Chantze | qte.windows | 53 | 268 | 9 | yes | -| ChantzesStone/Triad | qte.windows | 53 | 268 | 9 | yes | - -## Made by another tool - -ActionMax/38AmbushAlley.forge, ActionMax/BlueThunder.forge, ActionMax/Hydrosub2021.forge, ActionMax/PopsGhostly.forge, ActionMax/SonicFury.forge. diff --git a/testScripts/ports/shotDiff.py b/testScripts/ports/shotDiff.py deleted file mode 100755 index 92e586e8a..000000000 --- a/testScripts/ports/shotDiff.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 -# The Picture row's check (FORGE.md 14.18): the screenshots an original and its port took at the -# same moments (compare.sh with PORT_SHOTS=N and SINGE_SHOTS=<dir>), paired by number, and how much -# of each picture differs. A pixel differs when any channel is more than TOLERANCE apart, since -# the two runs decode the same video but may round a scaled frame differently. -# -# testScripts/ports/shotDiff.py <shots dir> [<out dir>] -# -# prints one line a pair and a summary; with <out dir>, writes each pair side by side with the -# differing pixels marked in the third panel, for a person to look at. -import os -import re -import sys - -import numpy -from PIL import Image - -TOLERANCE = 24 # Per channel, out of 255: video rounding, not a missing prompt -MARK = (255, 0, 255) -PAIR = re.compile(r"^(original|port)-shot-((?:run\d+-)?\d+)-(\w+)\.png$") - - -def main(): - shots = sys.argv[1] if len(sys.argv) > 1 else sys.exit("usage: shotDiff.py <shots dir> [<out dir>]") - out = sys.argv[2] if len(sys.argv) > 2 else None - found = {} - - for name in os.listdir(shots): - match = PAIR.match(name) - if match: - found.setdefault((match.group(2), match.group(3)), {})[match.group(1)] = os.path.join(shots, name) - if out: - os.makedirs(out, exist_ok=True) - worst = 0.0 - for (number, why), pair in sorted(found.items()): - if "original" not in pair or "port" not in pair: - print("%s %-6s only the %s took it" % (number, why, "original" if "original" in pair else "port")) - continue - a = numpy.asarray(Image.open(pair["original"]).convert("RGB"), dtype=numpy.int16) - b = numpy.asarray(Image.open(pair["port"]).convert("RGB"), dtype=numpy.int16) - if a.shape != b.shape: - print("%s %-6s sizes differ: %s and %s" % (number, why, a.shape, b.shape)) - continue - differs = (numpy.abs(a - b) > TOLERANCE).any(axis=2) - percent = 100.0 * differs.mean() - worst = max(worst, percent) - print("%s %-6s %6.2f%% of the picture differs" % (number, why, percent)) - if out: - marked = a.copy() - marked[differs] = MARK - side = numpy.concatenate([a, b, marked], axis=1).astype(numpy.uint8) - Image.fromarray(side).save(os.path.join(out, "%s-%s.png" % (number, why))) - print("%d pairs; the worst differs in %.2f%% of its picture" % (len(found), worst)) - - -if __name__ == "__main__": - main() diff --git a/testScripts/ports/shots.sh b/testScripts/ports/shots.sh deleted file mode 100755 index 7bfadc2e3..000000000 --- a/testScripts/ports/shots.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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/standalone.txt b/testScripts/ports/standalone.txt deleted file mode 100644 index 7331b23dd..000000000 --- a/testScripts/ports/standalone.txt +++ /dev/null @@ -1,22 +0,0 @@ -# Titles whose Forge port plays from its description alone, proved by -# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua -# out of these ports. Regenerate the list; do not edit it by hand. -AlteredCarbon -Arcade_Xperience_Vol1 -Asterix -ChantzesStone -CliffHanger -DragonsLair2Enhanced -FireAndIce -Linea -NinjaHayateHD -OeilPourOeil -Sonic_the_Hedgehog_1996 -SuckerPunch -Survival -TheElderScrolls -TimeGal -TitanAE -Tron -badlands_lite -mazinga diff --git a/testScripts/ports/sweep.sh b/testScripts/ports/sweep.sh deleted file mode 100755 index 41e72d78a..000000000 --- a/testScripts/ports/sweep.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/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}" -} -# compare.sh's family for a description, from its kind: the drivers differ by family, and a -# description run under another family's driver produces a trace of one line. -familyOf() { - local desc=$1 - - if grep -a -q 'style = "segments"\|style = "checkpoints"\|style = "trophies"' "$desc"; then - echo rdg - elif grep -a -q 'kind = "alg"' "$desc"; then - echo alg - elif grep -a -q 'kind = "timetraveler"' "$desc"; then - echo timetraveler - elif grep -a -q 'kind = "videoland"' "$desc"; then - echo videoland - else - echo "$2" - fi -} -# One description of the library: the families that take a game by its folder get the folder, -# the rest the script beside the description. -libraryPair() { - local desc=$1 script=$2 lib=$3 folder=$4 - local family - - family=$(familyOf "$desc" karis) - case "$family" in - alg|timetraveler|videoland) - pair "$family" "$folder" "$lib" - ;; - rdg) - # A segmented style in the library's own layout: the rdg family knows only the - # Hypseus one, and the karis driver plays every style. - pair karis "$script" "$lib" - ;; - *) - pair "$family" "$script" "$lib" - ;; - esac -} -# 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 - libraryPair "$desc" "$folder/$(basename "${desc%.forge}").singe" "$library/ported" "$folder" & -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 - libraryPair "$desc" "$folder/Script/$(basename "${desc%.forge}").singe" "$lib" "$folder" & - 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=$(familyOf "$desc" hypseus) - throttle - pair "$family" "$title" "$library/ported/hypseus" & -done -wait diff --git a/testScripts/ports/timetravelerDrive.singe b/testScripts/ports/timetravelerDrive.singe deleted file mode 100644 index b07b3a2bc..000000000 --- a/testScripts/ports/timetravelerDrive.singe +++ /dev/null @@ -1,200 +0,0 @@ --- 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 deleted file mode 100644 index 69112de0a..000000000 --- a/testScripts/ports/timetravelerOriginal.singe +++ /dev/null @@ -1,66 +0,0 @@ --- 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 deleted file mode 100644 index c715bedec..000000000 --- a/testScripts/ports/timetravelerPort.singe +++ /dev/null @@ -1,63 +0,0 @@ --- 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 deleted file mode 100644 index 72d08cfab..000000000 --- a/testScripts/ports/videolandDrive.singe +++ /dev/null @@ -1,120 +0,0 @@ --- 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 deleted file mode 100644 index dfeabe30d..000000000 --- a/testScripts/ports/videolandOriginal.singe +++ /dev/null @@ -1,58 +0,0 @@ --- 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 deleted file mode 100644 index 62f90dac4..000000000 --- a/testScripts/ports/videolandPort.singe +++ /dev/null @@ -1,57 +0,0 @@ --- 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/scene100.singe b/testScripts/scene100.singe deleted file mode 100644 index e4bdedbe9..000000000 --- a/testScripts/scene100.singe +++ /dev/null @@ -1,89 +0,0 @@ --- Forge: the picture at a point, and the light sensor that judges a shot by it. pictureAt is the --- condition a rule asks -- bright, dark, or neither -- and lightSensor is the behaviour built on --- the same read, the ActionMax way of judging a shot over video. Both are the framework's own --- words, so this scene is what covers them from the repository alone: the description compiles, --- the emitted Lua calls the runtime, and the answers are the documented ones. Run plain, where --- no video is playing and every frame must read dark; run with -v, where the picture is a moving --- one and bright frames must appear. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -local built = authorBuild("testScripts/author/picture.forge", singeGetDataPath() .. "picture.singe") -local file = built and io.open(singeGetDataPath() .. "picture.singe", "r") -local text = file and file:read("a") or "" - -if file then - file:close() -end -if not built then - debugPrint("PICTURE FAIL the description did not compile") - singeQuit() - return -end -if text:find("authorPictureIs", 1, true) == nil then - debugPrint("PICTURE FAIL the built game does not call authorPictureIs") -end -if AUTHOR.behaviours.lightSensor == nil then - debugPrint("PICTURE FAIL the framework has no lightSensor behaviour") -end -dofile(singeGetDataPath() .. "picture.singe") - -local gameUpdate = onOverlayUpdate -local frames = 0 -local FRAMES = 120 - - --- The read on its own, beside the condition's: the runtime answers one of three words, and a --- point outside the picture is still answered rather than failing. -local function checkRead() - local here = authorPictureState(200, 120) - local far = authorPictureState(-50, -50) - - if (here ~= "dark") and (here ~= "bright") and (here ~= "unknown") then - debugPrint("PICTURE FAIL authorPictureState answered '" .. tostring(here) .. "'") - end - if far == nil then - debugPrint("PICTURE FAIL a point off the picture answered nothing") - end -end - - -function onOverlayUpdate() - local result = gameUpdate and gameUpdate() or OVERLAY_UPDATED - local dark = AUTHOR_VARS.dark or 0 - local bright = AUTHOR_VARS.bright or 0 - local other = AUTHOR_VARS.neither or 0 - - frames = frames + 1 - if frames == 1 then - checkRead() - end - overlayPrint(1, 1, string.format("pictureAt at 200,120: dark %d bright %d neither %d of %d frames", dark, bright, other, frames)) - overlayPrint(1, 2, "lightSensor and pictureAt are the framework's own words now") - if frames == 60 then - singeScreenshot() - elseif frames == FRAMES then - debugPrint(string.format("PICTURE RESULT dark %d, bright %d, neither %d, of %d frames", dark, bright, other, frames)) - if (dark + bright + other) ~= frames then - debugPrint("PICTURE FAIL the three answers do not add up to the frames drawn") - end - -- No video: the count of frames on the disc is zero, which is how a script tells. - if discGetFrameCount() == 0 then - if dark ~= frames then - debugPrint("PICTURE FAIL with no video playing every frame should read dark") - end - elseif (bright + other) == 0 then - -- Over a picture the read must see something other than the zeroes a missing video - -- gives. Which of bright and neither it sees is the video's business: the menu's - -- backdrop is mid-tone, so it reads neither and never bright. - debugPrint("PICTURE FAIL over a moving picture every frame still read dark") - end - singeQuit() - end - - return result -end diff --git a/testScripts/scene101.singe b/testScripts/scene101.singe new file mode 100644 index 000000000..0a28aafb9 --- /dev/null +++ b/testScripts/scene101.singe @@ -0,0 +1,132 @@ +-- Engine calls no other scene exercised, checked against what the Reference says of them: the +-- collision helpers on known shapes (a concave polygon among them), the disc's transport state +-- through play, pause, a skip forward, a skip to a frame, and stop, the speed and frame rate calls +-- that do nothing, one soundtrack channel off and on, a sound channel paused and resumed, the +-- keyboard and mouse queries with nothing held, a loaded video paused, a sprite mirrored, and a +-- game packed from a script. Each check prints "CALLS ok" or "CALLS FAIL"; the screenshot shows +-- the arrows plain and mirrored over the disc. Run with -v Singe/menuBackground.mkv. +dofile("Singe/Framework.singe") + +local lfs = require("lfs") +local font = fontLoad("Singe/FreeSansBold.ttf", 14) +local arrows = spriteLoad("testScripts/arrows.png") +local mirrored = spriteLoad("testScripts/arrows.png") +local crackle = soundLoad("testScripts/crackle.wav") +local clip = videoLoad("Singe/menuBackground.mkv") +local frames = 0 +local failures = 0 +local channel = nil +local held = 0 +local clipAt = 0 +local LAST = 60 +local SCRIPT = "function onOverlayUpdate()\n\treturn OVERLAY_UPDATED\nend\n" + + +local function check(name, ok, detail) + if ok then + debugPrint("CALLS ok " .. name) + else + failures = failures + 1 + debugPrint("CALLS FAIL " .. name .. (detail and (" (" .. detail .. ")") or "")) + end +end + + +-- A game of one script and its games.dat, written into the data directory and packed from here. +local function packOne() + local data = singeGetDataPath() + local folder = data .. "packMe" + local game = data .. "packMe.game" + local file = nil + + lfs.mkdir(folder) + file = assert(io.open(folder .. "/packMe.singe", "w")) + file:write(SCRIPT) + file:close() + file = assert(io.open(folder .. "/games.dat", "w")) + file:write("GAMES = { { TITLE = \"Pack Me\", SCRIPT = \"packMe/packMe.singe\" } }\n") + file:close() + os.remove(game) + check("singePack answers true", singePack(folder, game) == true) + -- A .game is read through as a folder, so the packed script is opened inside it and must read + -- back as written. + file = io.open(game .. "/packMe/packMe.singe", "r") + check("singePack holds the script", (file ~= nil) and (file:read("a") == SCRIPT)) + if file then + file:close() + end +end + + +fontSelect(font) +overlaySetResolution(720, 480) +spriteFlip(mirrored, true, false) + +check("collideRects overlapping", collideRects(0, 0, 10, 10, 5, 5, 10, 10)) +check("collideRects apart", not collideRects(0, 0, 10, 10, 20, 20, 5, 5)) +check("collidePointRect on the edge", collidePointRect(0, 5, 0, 0, 10, 10)) +check("collidePointRect outside", not collidePointRect(11, 5, 0, 0, 10, 10)) +-- An L: the notch at the top right is outside it. +check("collidePointPolygon inside the L", collidePointPolygon(2, 8, { 0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10 })) +check("collidePointPolygon in the notch", not collidePointPolygon(8, 8, { 0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10 })) +check("collidePointPolygon two corners hold nothing", not collidePointPolygon(1, 0, { 0, 0, 10, 0 })) +check("keyboardIsDown with nothing held", keyboardIsDown(SCANCODE.A.value) == false) +check("keyboardGetModifiers with nothing held", (keyboardGetModifiers() & MODIFIER.SHIFT.value) == 0) +mouseSetMode(MOUSE_MANY) +mouseSetMode(MOUSE_SINGLE) +check("mouseSetMode both ways", true) + + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + spriteDraw(arrows, 40, 60) + spriteDraw(mirrored, 40, 140) + fontPrint(8, 6, string.format("frame %d disc %d failures %d", frames, discGetFrame(), failures)) + if frames == 2 then + discPlay() + videoPlay(clip) + channel = soundPlay(crackle) + elseif frames == 4 then + check("soundIsPlaying while it plays", soundIsPlaying(channel)) + check("soundPause says it was playing", soundPause(channel)) + check("soundIsPlaying false while paused", not soundIsPlaying(channel)) + elseif frames == 6 then + check("soundResume says it was paused", soundResume(channel)) + check("soundIsPlaying again", soundIsPlaying(channel)) + elseif frames == 10 then + check("discGetState playing", discGetState() == DISC_PLAYING) + discPause() + videoPause(clip) + clipAt = videoGetFrame(clip) + videoSetVolume(clip, 50, 50) + videoSetVolume(64) + elseif frames == 14 then + check("discGetState paused", discGetState() == DISC_PAUSED) + check("videoPause holds the frame", videoGetFrame(clip) == clipAt, videoGetFrame(clip) .. " against " .. clipAt) + held = discGetFrame() + discSkipForward(50) + elseif frames == 18 then + check("discSkipForward moves 50 frames", discGetFrame() == held + 50, discGetFrame() .. " against " .. (held + 50)) + check("discSkipForward keeps the pause", discGetState() == DISC_PAUSED) + discSkipToFrame(300) + elseif frames == 22 then + check("discSkipToFrame plays", discGetState() == DISC_PLAYING) + check("discSkipToFrame lands there", (discGetFrame() >= 300) and (discGetFrame() < 330), tostring(discGetFrame())) + discChangeSpeed(2) + discSetFPS(30) + discAudio(1, false) + discAudio(1, true) + check("discChangeSpeed, discSetFPS and discAudio take their arguments", true) + singeScreenshot("calls") + elseif frames == 26 then + discStop() + elseif frames == 28 then + check("discGetState stopped", discGetState() == DISC_STOPPED) + packOne() + elseif frames == LAST then + debugPrint(string.format("CALLS RESULT failures=%d", failures)) + singeQuit() + end + return OVERLAY_UPDATED +end diff --git a/testScripts/scene102.singe b/testScripts/scene102.singe new file mode 100644 index 000000000..45291ae6d --- /dev/null +++ b/testScripts/scene102.singe @@ -0,0 +1,153 @@ +-- The 3D calls no other scene exercised, checked against what the Reference says of them: a node +-- found by the name nodeSetName gave it, a crate lifted by bodyApplyForce, the simulation held +-- still by physicsSetEnabled and let go again, a player put somewhere by playerSetPosition, an +-- emitter deleted, navNearest snapping a point onto the floor and finding +-- nothing far off, an agent that navAgentStop halts short of its target and that arrives when sent +-- on (navAgentIsArrived), navAgentDelete, and a ragdoll that ragdollDelete frees for a new one. +-- Each check prints "SCENE3D ok" or "SCENE3D FAIL". No disc: run with -C 720x480. +dofile("Singe/Framework.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 14) +local stone = materialNew() +local wood = materialNew() +local camera = nodeNew() +local sun = lightNew(LIGHT_DIRECTIONAL) +local floor = nodeNew() +local crate = nodeNew() +local player = nodeNew() +local walker = nodeNew() +local fox = modelInstance(modelLoad("testScripts/Models/Fox.glb")) +local nav = nil +local agent = nil +local frames = 0 +local failures = 0 +local stage = "lift" +local mark = 0 +local heldY = 0 +local arrived = 0 +local LAST = 900 + + +local function check(name, ok, detail) + if ok then + debugPrint("SCENE3D ok " .. name) + else + failures = failures + 1 + debugPrint("SCENE3D FAIL " .. name .. (detail and (" (" .. detail .. ")") or "")) + end +end + + +function onNavArrived(which) + if which == agent then + arrived = arrived + 1 + end +end + + +fontSelect(font) +overlaySetResolution(720, 480) +sceneEnable(true) +cameraSet(camera) +nodeSetPosition(camera, 0, 9, 14) +nodeLookAt(camera, 0, 0, 0) +nodeLookAt(sun, -1, -2, -1) +materialSetColor(stone, 120, 120, 130) +materialSetColor(wood, 170, 120, 70) +nodeSetMesh(floor, meshBox(20, 0.2, 20), stone) +nodeSetPosition(floor, 0, -0.1, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 20, 0.2, 20) +nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) +nodeSetPosition(crate, -3, 0.3, 3) +bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) +bodySetMass(crate, 1) -- So 40 newtons outweighs gravity +nodeSetName(crate, "theCrate") +check("nodeFind finds what nodeSetName named", nodeFind("theCrate") == crate) +nodeSetMesh(player, meshCylinder(0.3, 1.0, 12), wood) +nodeSetPosition(player, 0, 0.5, 0) +playerNew(player, 0.3, 1.0) +nodeSetMesh(walker, meshBox(0.4, 1.0, 0.4), wood) +nodeSetPosition(walker, -6, 0, -4) +nodeSetPosition(fox, 6, 0, -4) +nodeSetScale(fox, 0.02) + +nav = navNew(0.3, 1.0, 45, 0.3) +navAddNode(nav, floor) +navBuild(nav) +do + local x, y, z = navNearest(nav, 1, 1.5, 1) + + check("navNearest snaps onto the floor", (x ~= nil) and (math.abs(x - 1) < 0.3) and (math.abs(z - 1) < 0.3) and (math.abs(y) < 0.3), string.format("%s %s %s", tostring(x), tostring(y), tostring(z))) + check("navNearest finds nothing far off", navNearest(nav, 100, 0, 100) == nil) +end +agent = navAgentNew(nav, walker, 0.3, 1.0, 2.0) +navAgentMoveTo(agent, 6, 0, 4) + +-- A dead handle ends the script rather than raising an error a pcall could catch, so these are +-- proved by what follows them: a second ragdollNew on a node that still had one would end the run. +emitterDelete(emitterNew()) +check("emitterDelete takes the emitter", true) +ragdollNew(fox) +ragdollDelete(fox) +ragdollNew(fox) +check("ragdollDelete frees the node for a new ragdoll", true) +ragdollDelete(fox) + + +function onOverlayUpdate() + local cx, cy, cz = nodeGetWorldPosition(crate) + local wx, wy, wz = nodeGetWorldPosition(walker) + + frames = frames + 1 + overlayClear() + fontPrint(8, 6, string.format("frame %d %s crate y %.2f walker %.1f %.1f failures %d", frames, stage, cy, wx, wz, failures)) + -- The crate is pushed up for a third of a second, then the simulation is held and let go. + if stage == "lift" then + bodyApplyForce(crate, 0, 40, 0) + if frames == 15 then + stage = "rising" + end + elseif stage == "rising" and frames == 18 then + check("bodyApplyForce lifts the crate", cy > 0.6, string.format("y %.2f", cy)) + physicsSetEnabled(false) + stage = "held" + mark = frames + elseif stage == "held" then + if frames == mark + 2 then + heldY = cy + elseif frames == mark + 12 then + check("physicsSetEnabled(false) holds it still", math.abs(cy - heldY) < 0.001, string.format("%.3f then %.3f", heldY, cy)) + physicsSetEnabled(true) + stage = "resumed" + mark = frames + end + elseif stage == "resumed" and frames == mark + 12 then + check("physicsSetEnabled(true) lets it go", math.abs(cy - heldY) > 0.01, string.format("%.3f then %.3f", heldY, cy)) + playerSetPosition(player, 5, 0.6, 5) + stage = "teleported" + mark = frames + elseif stage == "teleported" and frames == mark + 3 then + local px, _, pz = nodeGetWorldPosition(player) + + check("playerSetPosition puts the player there", (math.abs(px - 5) < 0.1) and (math.abs(pz - 5) < 0.1), string.format("%.2f %.2f", px, pz)) + navAgentStop(agent) + stage = "stopped" + mark = frames + elseif stage == "stopped" and frames == mark + 60 then + check("navAgentStop halts it short", not navAgentIsArrived(agent) and (arrived == 0) and (math.abs(wx - 6) > 1), string.format("at %.1f %.1f", wx, wz)) + navAgentMoveTo(agent, 6, 0, 4) + stage = "sent" + elseif stage == "sent" and arrived > 0 then + check("navAgentIsArrived once it is there", navAgentIsArrived(agent)) + navAgentDelete(agent) + check("navAgentDelete leaves the walker where it was", (math.abs(wx - 6) < 1) and (math.abs(wz - 4) < 1), string.format("at %.1f %.1f", wx, wz)) + stage = "done" + end + if (stage == "done") or (frames == LAST) then + check("everything ran", stage == "done", stage) + debugPrint(string.format("SCENE3D RESULT failures=%d frames=%d", failures, frames)) + singeScreenshot("scene3d") + singeQuit() + end + return OVERLAY_UPDATED +end diff --git a/testScripts/scene52.singe b/testScripts/scene52.singe deleted file mode 100644 index 4ad493c09..000000000 --- a/testScripts/scene52.singe +++ /dev/null @@ -1,102 +0,0 @@ --- Forge (FORGE.md): compiles all four sample descriptions and plays the platformer. --- --- The descriptions share nothing -- physics with a character, disc frames and time windows, a --- shoot-em-up of spawned types, a gun over video -- so compiling all of them here is the test --- that the core has no genre baked into it. --- Nothing in this scene knows what a platformer is; it drives keys and looks at the result. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) -local built = {} -local out = singeGetDataPath() - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do - local game = authorLoad("testScripts/author/" .. name .. ".forge") - local problems = authorCheck(game) - - 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) - end -end - --- Both must at least load as Lua; a compiler that emits something unparseable is the failure this --- catches earliest, and it costs nothing to check both even though only one is played. -for name, path in pairs(built) do - local chunk, problem = loadfile(path) - - if chunk == nil then - debugPrint("AUTHOR FAIL " .. name .. " does not parse: " .. tostring(problem)) - else - debugPrint("AUTHOR parsed " .. name) - end -end - --- Now run the platformer, as the engine would if it had been launched directly. -dofile(built.platformer) - -local gameUpdate = onOverlayUpdate -local frames = 0 - --- Driving by what the game is doing, not by a clock or a frame count. --- --- Both of those were tried and both are wrong. A frame count is wrong because the authored game --- moves in seconds. A clock is wrong because physics steps at most MAX_STEPS_PER_FRAME (4, so --- 66 ms) per frame and deliberately falls behind wall clock on a machine that cannot keep up -- --- which the sanitizer build, with a disc to decode, does. A test that watches the hero instead --- gives the same answer at any frame rate, and exercises onGround from this side as well. -local JUMP_AT = 340 -- Where the hero leaves the ground to reach the ledge. -local GIVE_UP = 20.0 -- Seconds. Generous: this is a stuck detector, not a schedule. - -local held = false -local jumped = false -local won = 0 -local shots = 0 - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local hx, hy = authorPosition(hero) - - frames = frames + 1 - if not held then - authorKeyDown(0, SCANCODE.RIGHT.value) - held = true - end - - -- Jump once, when the hero is under way and has ground beneath it. - if (not jumped) and (hx >= JUMP_AT) and playerIsOnGround(hero.node) then - authorKeyDown(0, SCANCODE.SPACE.value) - jumped = true - elseif jumped then - authorKeyUp(0, SCANCODE.SPACE.value) - end - - local r = gameUpdate() - - -- The engine's own text over the authored game, so a screenshot says what it is looking at. - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_VARS.score)) - - -- Four shots at the moments that matter, found by watching rather than by timing: the start, - -- the run, the jump, and the frame after the prize was taken. - if (shots == 0) or (shots == 1 and hx > 250) or (shots == 2 and jumped and not playerIsOnGround(hero.node)) or (shots == 3 and won > 0 and t > won + 0.3) then - shots = shots + 1 - singeScreenshot() - end - if AUTHOR_VARS.won and won == 0 then - won = t - end - if (won > 0 and t > won + 0.6) or (t > GIVE_UP) then - debugPrint("AUTHOR RESULT score=" .. AUTHOR_VARS.score .. " flagWon=" .. tostring(AUTHOR_VARS.won) .. " after=" .. string.format("%.1fs", t)) - singeQuit() - end - - return r -end diff --git a/testScripts/scene53.singe b/testScripts/scene53.singe deleted file mode 100644 index af9c7ef7a..000000000 --- a/testScripts/scene53.singe +++ /dev/null @@ -1,51 +0,0 @@ --- Forge (FORGE.md), genre two of the first slice: the QTE description, played over the disc. --- --- Scene 52 plays the platformer. This one shares no vocabulary with it beyond the three nouns -- --- no physics, no character, no gravity, and every rule is about disc frames -- which is what makes --- the pair a test of the core rather than of either genre. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/qte.forge", singeGetDataPath() .. "qte.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local answered = false -local shots = 0 - - -function onOverlayUpdate() - local disc = discGetFrame() - - frames = frames + 1 - - -- Answer inside the window, the way a player would: hold the button for a few frames. - if (disc >= 215) and (disc < 225) and not answered then - authorSwitchDown(SWITCH_BUTTON1) - answered = true - end - if answered and (disc >= 230) then - authorSwitchUp(SWITCH_BUTTON1) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, "authored QTE, disc frame " .. disc) - - -- On the disc's clock, not the frame count: the window is what is being shown. - if (shots == 0 and disc >= 205) or (shots == 1 and disc >= 230) or (shots == 2 and disc >= 280) then - shots = shots + 1 - singeScreenshot() - end - if disc >= 310 or frames > 1200 then - debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_VARS.score .. " hit=" .. tostring(AUTHOR_VARS.hit)) - singeQuit() - end - - return r -end diff --git a/testScripts/scene54.singe b/testScripts/scene54.singe deleted file mode 100644 index 436be9061..000000000 --- a/testScripts/scene54.singe +++ /dev/null @@ -1,168 +0,0 @@ --- Forge (FORGE.md): opened on a real description, driven, saved, and the result compiled and --- played. --- --- Driven by calling the editor's own functions rather than by a pointer, the way the menu's --- sanitizer scene drives the menu: what is being tested is that moving something in the editor --- reaches the description, survives a save, and changes the game that comes out. The pointer path --- itself was settled by the spike in FORGE.md section 1 and needs a real X server to exercise. --- As a library: the tail of Forge.singe that launches it is skipped, because this scene --- drives it rather than being it. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 16) -local out = singeGetDataPath() -local work = out .. "edited.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -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.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("FORGE FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 -local pointer = { x = 430, y = 300 } -local before = nil - - --- Each stage is one thing the editor must be able to do, checked as it happens. -local function step() - if stage == 1 then - -- Pick the prize up off the ledge, the way a press on the canvas would. - local index = forgePick(520, 300) - - if index == nil then - debugPrint("FORGE FAIL nothing under the prize's own position") - else - before = forgeRoom().entities[index].x - debugPrint("FORGE picked " .. forgeRoom().entities[index].id .. " at x=" .. before) - forgePress(520, 300) - end - - elseif stage == 2 then - -- Drag it left and down, off the ledge and onto the ground. - forgeDrag(300, 300) - pointer.x = 300 - - elseif stage == 3 then - forgeRelease() - local moved = forgeRoom().entities[forgePick(300, 300)] - - debugPrint("FORGE moved to x=" .. moved.x .. " dirty=" .. tostring(FORGE.dirty)) - - elseif stage == 4 then - forgeSave() - -- Reload from disc and check the move is in the file, not just in memory. - local reloaded = authorLoad(work) - local found = nil - - for _, e in ipairs(reloaded.rooms[1].entities) do - if e.id == "prize" then - found = e.x - end - end - debugPrint("FORGE saved and reloaded, prize x=" .. tostring(found) .. " (was " .. tostring(before) .. ")") - if found == before then - debugPrint("FORGE FAIL the move did not survive the save") - end - - elseif stage == 5 then - -- And the edit has to reach the game, not just the file. - local built = forgeBuild(out .. "edited.singe") - local text = assert(io.open(built, "r")):read("a") - - if text:find('id = "prize", type = "prize", x = 300') then - debugPrint("FORGE the compiled game carries the new position") - else - debugPrint("FORGE FAIL the compiled game does not carry the new position") - end - - elseif stage == 6 then - -- 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.forge") - local trip = out .. "trip.forge" - - authorSave(original, trip) - local again = authorLoad(trip) - - if authorCompile(original) == authorCompile(again) then - debugPrint("FORGE round trip identical") - else - debugPrint("FORGE FAIL round trip changed the game") - end - - elseif stage == 7 then - -- The hero lives at x=120, underneath the chrome. A press there must not reach it. - forgeSelect(nil) - forgePress(120, 380) - if FORGE.selected == nil then - debugPrint("FORGE the panel correctly swallows a press beneath it") - else - debugPrint("FORGE FAIL a press under the panel reached the canvas") - end - - elseif stage == 8 then - -- Slide the chrome out of the way by pressing its tab, through forgePress, which is - -- the path a real pointer takes. Grabbing the panel through a GUI handler would not be: - -- the spike found a document can go without pointer input entirely, so the tab is drawn on - -- the canvas and this test goes through the same door a player's mouse does. - local tx, ty, tw, th = forgeTab() - - forgePress(tx + tw / 2, ty + th / 2) - if FORGE.panelDrag == nil then - debugPrint("FORGE FAIL pressing the tab did not start a panel drag") - end - forgeDrag(440 + tw / 2, 200) - forgeRelease() - pointer.x = 440 - debugPrint("FORGE panel moved to x=" .. FORGE.panelX) - - elseif stage == 9 then - -- And now the same press reaches the hero, which is the whole point of a movable panel. - forgePress(120, 380) - if FORGE.selected ~= nil and forgeRoom().entities[FORGE.selected].id == "hero" then - debugPrint("FORGE the hero is reachable once the panel has moved") - else - debugPrint("FORGE FAIL the hero is still unreachable") - end - forgeRelease() - debugPrint("FORGE RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 10 then - stage = stage + 1 - step() - end - - forgeDraw(pointer.x, pointer.y) - colorForeground(255, 255, 255, 255) - fontPrint(200, 455, "Forge, stage " .. stage) - - if frames == 10 or frames == 54 or frames == 88 or frames == 118 then - singeScreenshot() - end - if frames > 130 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene55.singe b/testScripts/scene55.singe deleted file mode 100644 index 6ea271ced..000000000 --- a/testScripts/scene55.singe +++ /dev/null @@ -1,178 +0,0 @@ --- The event sheet (FORGE.md): rules edited, not just entities moved. --- --- Driven by keys, which is how the editor is meant to be used and how the bundled menu's document --- has always been driven. What is asserted is that an edit made through the rule editor reaches --- the compiled game -- adding a condition has to change what the game actually does. --- As a library: the tail of Forge.singe that launches it is skipped, because this scene --- drives it rather than being it. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "rules.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/platformer.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("RULES FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 -local before = "" - - --- The engine hands onKeyPressed two integers, so the test does too. Passing the SCANCODE table --- instead made every comparison in forgeKey false and the test still passed, which is worse than --- failing: the keyboard did nothing for a real user and nothing said so. -local function key(code, character) - forgeKey(character or 0, code.value) -end - - -local function compiled() - FORGE.game.source = work - - return authorCompile(FORGE.game) -end - - -local function step() - if stage == 1 then - key(SCANCODE.TAB) -- entities to types, - key(SCANCODE.TAB) -- types to rules. - debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules) - - elseif stage == 2 then - -- Walk to the second rule the way the arrow keys do. - key(SCANCODE.DOWN) - key(SCANCODE.DOWN) - key(SCANCODE.DOWN) - debugPrint("RULES at rule " .. FORGE.rule .. " part " .. FORGE.part) - - elseif stage == 3 then - before = compiled() - -- A new rule, then a condition and an action on it, all from the manifest. - forgeRuleNew("jump with the up arrow too") - forgeRuleAdd("when", "keyHeld") - forgePartSet("key", "UP") - forgeRuleAdd("when", "onGround") - forgePartSet("entity", "hero") - forgeRuleAdd("act", "jump") - forgePartSet("entity", "hero") - debugPrint("RULES built a rule with " .. #forgeParts(FORGE.game.rules[FORGE.rule]) .. " parts") - - elseif stage == 4 then - local after = compiled() - - if after == before then - debugPrint("RULES FAIL the new rule did not reach the compiled game") - elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%(authorEntity%("hero"%)%)') and after:find('authorJump%(authorEntity%("hero"%)%)') then - debugPrint("RULES the compiled game carries the new rule") - else - debugPrint("RULES FAIL the compiled game is missing part of the new rule") - end - - elseif stage == 5 then - -- And deleting a part has to take it back out again. - FORGE.part = 1 - forgePartDelete() - local after = compiled() - - if after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') then - debugPrint("RULES FAIL the deleted condition is still compiled") - else - debugPrint("RULES the deleted condition is gone from the compiled game") - end - - elseif stage == 6 then - -- Type a value the way a person does: ENTER opens the first parameter, characters go in, - -- ENTER commits. Rule 1 is "Run left", whose only condition is keyHeld with key = LEFT. - FORGE.rule = 1 - FORGE.part = 1 - key(SCANCODE.RETURN) - for c in string.gmatch("RIGHT", ".") do - key(SCANCODE.A, string.byte(c)) - end - key(SCANCODE.RETURN) - local typed = FORGE.game.rules[1].when[1] - - if typed.key == "RIGHT" then - debugPrint("RULES typed a value in: key = " .. tostring(typed.key)) - else - debugPrint("RULES FAIL typing did not reach the rule: key = " .. tostring(typed.key)) - end - - elseif stage == 7 then - -- A number has to come back a number: "100" and 100 compile to different source, so a - -- retyped value that turned into a string would stop the description round-tripping. - -- Rule 4 is "Take the prize", on a collision; its first part is addScore, whose value is - -- amount. - FORGE.rule = 4 - FORGE.part = 1 - key(SCANCODE.RETURN) - for c in string.gmatch("250", ".") do - key(SCANCODE.A, string.byte(c)) - end - key(SCANCODE.RETURN) - local amount = FORGE.game.rules[4].act[1].amount - - if amount == 250 and type(amount) == "number" then - debugPrint("RULES a typed number is a number: " .. tostring(amount)) - else - debugPrint("RULES FAIL amount is " .. type(amount) .. " " .. tostring(amount)) - end - - -- And ESC has to put back what was there: the second part is destroy, on other. - FORGE.part = 2 - key(SCANCODE.RETURN) - key(SCANCODE.A, string.byte("Z")) - key(SCANCODE.ESCAPE) - if FORGE.game.rules[4].act[2].entity == "other" then - debugPrint("RULES escape put the value back") - else - debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].act[2].entity)) - end - - elseif stage == 8 then - key(SCANCODE.S) - local reloaded = authorLoad(work) - local last = reloaded.rules[#reloaded.rules] - - debugPrint("RULES saved; last rule is now '" .. tostring(last.note) .. "' with " .. #(last.when or {}) .. " conditions") - debugPrint("RULES RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 9 then - stage = stage + 1 - step() - end - - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 455, "event sheet, stage " .. stage) - - if frames == 16 or frames == 44 or frames == 62 or frames == 96 then - singeScreenshot() - end - if frames > 150 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene56.singe b/testScripts/scene56.singe deleted file mode 100644 index f530bc595..000000000 --- a/testScripts/scene56.singe +++ /dev/null @@ -1,116 +0,0 @@ --- A Forge release is standalone (FORGE.md). --- --- Building it is half the test; the half that matters is that the result runs somewhere Forge has --- never been. The runtime a compiled game loads is Singe/Author.singe, which whatever Singe the --- player has will have extracted -- but a game should not change behaviour because the engine under --- it moved on, so a release carries its own copy and loads that instead. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -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.forge", "r")) -local copy = assert(io.open(work, "w")) - --- With credits, so the release can be checked for them: the fields go to games.dat, the notes to --- CREDITS.txt, and the licence file travels with the game. -copy:write((source:read("a"):gsub("(\n\ttitle%s*=[^\n]*\n)", "%1\tcredits = { developer = \"Test Developer\", year = 1983, notes = { \"Original game: Test Author\", \"Converted by: Test Porter\" }, licenses = { \"LICENSE.txt\" } },\n", 1))) -source:close() -copy:close() -copy = assert(io.open(out .. "LICENSE.txt", "w")) -copy:write("A licence the release must carry.\n") -copy:close() - -if not forgeBegin(work) then - debugPrint("RELEASE FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 - - -local function step() - if stage == 1 then - local folder = forgeExport(dest, "Platformer") - - debugPrint("RELEASE built into " .. tostring(folder)) - - elseif stage == 2 then - -- Everything a standalone game needs has to be there. - for _, name in ipairs({ "Platformer.singe", "Author.singe", "games.dat", "Platformer.forge" }) do - local f = io.open(dest .. "/" .. name, "r") - - if f == nil then - debugPrint("RELEASE FAIL missing " .. name) - else - f:close() - end - end - debugPrint("RELEASE all four files present") - - elseif stage == 3 then - -- And it must load its own runtime, not the engine's. - local text = assert(io.open(dest .. "/Platformer.singe", "r")):read("a") - - if text:find('dofile%(here %.%. "Author%.singe"%)') and text:find('debug%.getinfo') then - debugPrint("RELEASE the game finds its own directory and loads the runtime there") - elseif text:find('Singe/Author%.singe') then - debugPrint("RELEASE FAIL the game still loads the engine's runtime, which no longer ships") - else - debugPrint("RELEASE FAIL the game does not load a runtime at all") - end - - elseif stage == 4 then - -- The original author's credits: in games.dat for the menu, the notes in CREDITS.txt, and - -- the licence beside the game. - local dat = assert(io.open(dest .. "/games.dat", "r")):read("a") - local credits = io.open(dest .. "/CREDITS.txt", "r") - local licence = io.open(dest .. "/LICENSE.txt", "r") - local notes = credits and credits:read("a") or "" - - if credits then - credits:close() - end - if licence then - licence:close() - end - if dat:find('DEVELOPER = "Test Developer"', 1, true) and dat:find("YEAR = 1983", 1, true) and notes:find("Original game: Test Author", 1, true) and (licence ~= nil) then - debugPrint("RELEASE the credits travel: games.dat, CREDITS.txt, and the licence") - else - debugPrint("RELEASE FAIL credits missing from the release") - end - - elseif stage == 5 then - -- Whether it truly stands alone cannot be answered from in here: DIR is the directory of - -- the script the engine was *launched* with, so a game reached by dofile would look for the - -- runtime beside this scene instead of beside itself. A released game is the main script, - -- which is a different run of the engine. harness/verify.sh launches it with - -- Singe/Author.singe moved aside and checks it still runs. - debugPrint("RELEASE RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 6 then - stage = stage + 1 - step() - end - if frames == 20 or frames == 70 then - singeScreenshot() - end - if frames > 90 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene57.singe b/testScripts/scene57.singe deleted file mode 100644 index a823ee891..000000000 --- a/testScripts/scene57.singe +++ /dev/null @@ -1,244 +0,0 @@ --- Forge as a game (FORGE.md): the chooser, the engine callbacks, and the editing that --- needs no pointer -- entities added, renamed and deleted, a rule built from the pickers, undo. --- --- Unlike scene54 to 56 this does not set FORGE_LIBRARY: Forge installs its own callbacks, and --- the scene drives them the way the engine would, two integers to onKeyPressed. What is --- asserted is that every key reaches the description and that a rename reaches the rules. -dofile("Forge/Forge.singe") - -local forgeUpdate = onOverlayUpdate -local frames = 0 -local stage = 0 -local opened = nil - - -local function key(code, character) - onKeyPressed(character or 0, code.value) -end - - -local function typeIn(text) - for c in string.gmatch(text, ".") do - key(SCANCODE.A, string.byte(c)) - end -end - - -local function step() - if stage == 1 then - -- The chooser is up and offers a new game at least. - if FORGE.game ~= nil then - debugPrint("GAME FAIL the editor is open before anything was chosen") - end - debugPrint("GAME the chooser offers " .. #forgeFiles() .. " entries") - -- The last entry is always a new game. - for _ = 1, 20 do - key(SCANCODE.DOWN) - end - key(SCANCODE.RETURN) - if FORGE.game == nil then - debugPrint("GAME FAIL ENTER on the chooser opened nothing") - else - opened = FORGE.path - debugPrint("GAME opened " .. opened .. " with " .. #forgeRoom().entities .. " entities") - end - - elseif stage == 2 then - -- Add an entity, name it, and make it a hero by typing the fields ENTER walks. - local count = #forgeRoom().entities - - key(SCANCODE.A) - if #forgeRoom().entities ~= count + 1 then - debugPrint("GAME FAIL A did not add an entity") - end - key(SCANCODE.RETURN) -- id - typeIn("coin") - key(SCANCODE.RETURN) -- type - typeIn("hero") - key(SCANCODE.RETURN) -- x - typeIn("300") - key(SCANCODE.RETURN) -- y - typeIn("200") - key(SCANCODE.RETURN) -- z - key(SCANCODE.ESCAPE) -- untouched - local coin = forgeRoom().entities[#forgeRoom().entities] - - if coin.id == "coin" and coin.type == "hero" and coin.x == 300 and coin.y == 200 then - debugPrint("GAME typed an entity in: " .. coin.id .. " the " .. coin.type .. " at " .. coin.x .. "," .. coin.y) - else - debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " the " .. tostring(coin.type) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y)) - end - - elseif stage == 3 then - -- Renaming the hero has to reach every rule that names it. - key(SCANCODE.UP) - key(SCANCODE.UP) - key(SCANCODE.UP) - key(SCANCODE.UP) - key(SCANCODE.DOWN) -- ground, then hero - key(SCANCODE.RETURN) - typeIn("player") - key(SCANCODE.ESCAPE) -- ESC puts it back: the hero keeps its name - if forgeRoom().entities[FORGE.selected].id ~= "hero" then - debugPrint("GAME FAIL escape did not put the name back") - end - key(SCANCODE.RETURN) - typeIn("player") - key(SCANCODE.RETURN) -- commits, moves on to x - key(SCANCODE.ESCAPE) - local named = 0 - - for _, rule in ipairs(FORGE.game.rules) do - for _, item in ipairs(rule.act) do - if item.entity == "player" then - named = named + 1 - end - end - end - if forgeRoom().entities[FORGE.selected].id == "player" and named == 3 then - debugPrint("GAME renamed the hero and " .. named .. " actions followed") - else - debugPrint("GAME FAIL rename: id " .. tostring(forgeRoom().entities[FORGE.selected].id) .. ", " .. named .. " actions renamed") - end - - elseif stage == 4 then - -- A rule from the pickers: N, E picks an event, C a condition, T an action. - local rules = #FORGE.game.rules - - key(SCANCODE.TAB) -- types, - key(SCANCODE.TAB) -- rules. - key(SCANCODE.N) - key(SCANCODE.E) - key(SCANCODE.DOWN) - key(SCANCODE.RETURN) - key(SCANCODE.C) - if FORGE.picker == nil then - debugPrint("GAME FAIL C opened no picker") - end - key(SCANCODE.DOWN) - key(SCANCODE.RETURN) - key(SCANCODE.T) - key(SCANCODE.RETURN) - local rule = FORGE.game.rules[#FORGE.game.rules] - - if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 and rule.on ~= "frame" then - debugPrint("GAME built a rule from the pickers: on " .. rule.on .. " when " .. rule.when[1][1] .. " then " .. rule.act[1][1]) - else - debugPrint("GAME FAIL the picked rule has " .. #(rule.when or {}) .. " conditions and " .. #(rule.act or {}) .. " actions") - end - - elseif stage == 5 then - -- Undo takes the action back, then the condition, the event, then the rule; redo brings - -- the rule back, and a change by hand ends the redo history. - local rules = #FORGE.game.rules - - key(SCANCODE.U) - key(SCANCODE.U) - key(SCANCODE.U) - key(SCANCODE.U) - if #FORGE.game.rules == rules - 1 then - debugPrint("GAME three undos took the rule back out") - else - debugPrint("GAME FAIL after three undos there are " .. #FORGE.game.rules .. " rules, not " .. (rules - 1)) - end - key(SCANCODE.R) - if (#FORGE.game.rules == rules) and (#FORGE.game.rules[rules].when == 0) then - debugPrint("GAME redo brought the empty rule back") - else - debugPrint("GAME FAIL redo left " .. #FORGE.game.rules .. " rules") - end - key(SCANCODE.U) - key(SCANCODE.N) - key(SCANCODE.R) - if #FORGE.redo == 0 and #FORGE.game.rules == rules then - debugPrint("GAME a new change emptied the redo history") - else - debugPrint("GAME FAIL redo history survived a change: " .. #FORGE.redo .. " kept") - end - key(SCANCODE.U) - - elseif stage == 6 then - -- Duplicate and delete in the entity list. - key(SCANCODE.TAB) -- tracks, - key(SCANCODE.TAB) -- entities. - local count = #forgeRoom().entities - - key(SCANCODE.D) - key(SCANCODE.DELETE) - if #forgeRoom().entities == count then - debugPrint("GAME duplicate then delete leaves " .. count .. " entities") - else - debugPrint("GAME FAIL duplicate then delete leaves " .. #forgeRoom().entities) - end - - elseif stage == 7 then - -- ESC on unsaved work warns, S saves, ESC then leaves; the chooser lists the file. - key(SCANCODE.ESCAPE) - if FORGE.game == nil then - debugPrint("GAME FAIL one ESC closed unsaved work") - end - key(SCANCODE.S) - key(SCANCODE.ESCAPE) - if FORGE.game ~= nil then - debugPrint("GAME FAIL ESC did not close a saved description") - end - local listed = false - - for _, entry in ipairs(forgeFiles()) do - if entry.path == opened then - listed = true - end - end - debugPrint("GAME closed; the chooser lists what was saved: " .. tostring(listed)) - - elseif stage == 8 then - -- Open it again from the chooser -- it is listed first -- and the rename is in the file. - for _ = 1, 20 do - key(SCANCODE.UP) - end - key(SCANCODE.RETURN) - if FORGE.game == nil then - debugPrint("GAME FAIL could not reopen the saved description") - else - local found = false - - for _, entity in ipairs(forgeRoom().entities) do - if entity.id == "player" then - found = true - end - end - debugPrint("GAME reopened " .. FORGE.path .. "; the rename was saved: " .. tostring(found)) - end - - elseif stage == 9 then - -- B builds beside the runtime, which is what makes the result playable. - key(SCANCODE.B) - local runtime = io.open(singeGetDataPath() .. "Author.singe", "r") - - if runtime == nil then - debugPrint("GAME FAIL build left no runtime beside the preview") - else - runtime:close() - debugPrint("GAME the build put the runtime beside the preview") - end - debugPrint("GAME RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 10 then - stage = stage + 1 - step() - end - forgeUpdate() - if frames == 6 or frames == 40 or frames == 60 or frames == 100 then - singeScreenshot() - end - if frames > 130 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene58.singe b/testScripts/scene58.singe deleted file mode 100644 index 1f14e8bc1..000000000 --- a/testScripts/scene58.singe +++ /dev/null @@ -1,71 +0,0 @@ --- Forge milestone 1, genre one: the shoot-em-up. Kinds spawned and destroyed by the dozen, --- collision events between types, health and death, a spawner, a HUD document bound to vars, a --- sequence (say waits), a flash, and a game over -- driven by what the game is doing. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -dofile(authorBuild("testScripts/author/shmup.forge", singeGetDataPath() .. "shmup.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local maxRaiders = 0 -local maxShots = 0 -local reported = false -local GIVE_UP = 30 - - -function onOverlayUpdate() - local t = authorTime() - local ship = authorEntity("ship") - - frames = frames + 1 - -- Hold fire from the start and sweep left and right under the spawn points. - if frames == 2 then - authorKeyDown(0, SCANCODE.SPACE.value) - end - if ship then - local x = authorX(ship) - - if (x > 500) and authorKeyHeld(SCANCODE.RIGHT.value) then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif (x < 120) and authorKeyHeld(SCANCODE.LEFT.value) then - authorKeyUp(0, SCANCODE.LEFT.value) - authorKeyDown(0, SCANCODE.RIGHT.value) - elseif not (authorKeyHeld(SCANCODE.LEFT.value) or authorKeyHeld(SCANCODE.RIGHT.value)) then - authorKeyDown(0, SCANCODE.RIGHT.value) - end - end - - local r = gameUpdate() - - maxRaiders = math.max(maxRaiders, authorCount("raider")) - maxShots = math.max(maxShots, authorCount("shot")) - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored shmup, %.1fs, raiders %d, shots %d, score %d, lives %d", t, authorCount("raider"), authorCount("shot"), AUTHOR_VARS.score, AUTHOR_VARS.lives)) - - if (shots == 0 and t > 1.5) or (shots == 1 and t > 4) or (shots == 2 and AUTHOR_VARS.cleared) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.cleared and t > 1 and not reported) or (t > GIVE_UP) then - reported = true - debugPrint(string.format("SHMUP RESULT score=%d lives=%d cleared=%s maxRaiders=%d maxShots=%d after=%.1fs", - AUTHOR_VARS.score, AUTHOR_VARS.lives, tostring(AUTHOR_VARS.cleared), maxRaiders, maxShots, t)) - if maxRaiders < 3 then - debugPrint("SHMUP FAIL the spawner never had three raiders up at once") - end - if AUTHOR_VARS.score <= 0 then - debugPrint("SHMUP FAIL nothing was ever hit") - end - singeQuit() - end - - return r -end diff --git a/testScripts/scene59.singe b/testScripts/scene59.singe deleted file mode 100644 index 8b87931ee..000000000 --- a/testScripts/scene59.singe +++ /dev/null @@ -1,93 +0,0 @@ --- Forge milestone 1, genre two: a light gun over video. Hitboxes keyed to disc frames and --- interpolated between them, a gun that raises hit and miss, ammo that runs out, a reload by a --- shot off the picture, a prompt at a frame, and a penalty for the hostage. The pointer is --- injected through authorSetPointer, the door a test uses instead of a mouse. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/gunvideo.forge", singeGetDataPath() .. "gunvideo.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local stage = 0 -local hits = 0 -local misses = 0 -local hostage = 0 - -local realEvent = authorEvent -function authorEvent(name, event) - if name == "hit" then - if event.self.type == "bandit" then hits = hits + 1 elseif event.self.type == "hostage" then hostage = hostage + 1 end - elseif name == "miss" then - misses = misses + 1 - end - return realEvent(name, event) -end - - -local function shoot(x, y, on) - authorSetPointer(1, x, y, on) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) -end - - -function onOverlayUpdate() - local disc = discGetFrame() - local box = authorTrackBox("bandit") - - frames = frames + 1 - -- Each shot waits for the disc to reach a frame, so the sequence is the same at any speed. - if (stage == 0) and (disc >= 130) and box then - -- Frame 130 of 120..220: the bandit box has moved a tenth of the way from x=100 to x=400. - debugPrint(string.format("GUN at frame %d the bandit box is at x=%.0f (interpolated from 100 toward 400)", disc, box.x)) - shoot(box.x + 10, box.y + 10, true) -- hit - stage = 1 - elseif (stage == 1) and (disc >= 150) then - shoot(20, 20, true) -- miss on the picture - shoot(box.x + 10, box.y + 10, true) -- hit (the bandit is dead; no target, so a miss) - stage = 2 - elseif (stage == 2) and (disc >= 170) then - -- Out of ammo now (3 shots taken): a shot on the picture does nothing, off it reloads. - local ammo = authorEntity("gun1").vars.ammo - - shoot(580, 260, true) -- hostage, but the gun is empty - debugPrint("GUN ammo before reload " .. ammo .. ", hostage hits so far " .. hostage) - shoot(-10, -10, false) -- reload - debugPrint("GUN ammo after reload " .. authorEntity("gun1").vars.ammo) - shoot(580, 260, true) -- hostage - stage = 3 - elseif (stage == 3) and (disc >= 320) then - stage = 4 - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, "authored gun game, disc frame " .. disc) - if box then - colorForeground(255, 80, 80, 255) - overlayBox(box.x, box.y, box.x + box.w, box.y + box.h) - end - if (shots == 0 and disc >= 125) or (shots == 1 and disc >= 160) or (shots == 2 and disc >= 200) then - shots = shots + 1 - singeScreenshot() - end - if stage == 4 or frames > 1500 then - debugPrint(string.format("GUN RESULT score=%d hits=%d misses=%d hostage=%d ammo=%d", AUTHOR_VARS.score, hits, misses, hostage, authorEntity("gun1").vars.ammo)) - if hits ~= 1 or hostage ~= 1 or misses ~= 2 then - debugPrint("GUN FAIL expected 1 hit, 2 misses, 1 hostage") - end - if authorTrackBoxAt({ boxes = { { at = 0, x = 0, y = 0, w = 10, h = 10 }, { at = 10, x = 100, y = 0, w = 10, h = 10 } } }, 5).x ~= 50 then - debugPrint("GUN FAIL interpolation") - end - singeQuit() - end - - return r -end diff --git a/testScripts/scene60.singe b/testScripts/scene60.singe deleted file mode 100644 index 2cbbe5e0a..000000000 --- a/testScripts/scene60.singe +++ /dev/null @@ -1,148 +0,0 @@ --- The timeline (FORGE.md milestone 1): hitbox tracks keyed to disc frames, edited in Forge. The --- cursor is stepped, keys are made at it, one is picked up off the canvas and dragged, one is --- deleted, and the compiled game carries what was drawn. The description names its video, so the --- editor has a frame to show under the cursor. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "timeline.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/gunvideo.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("TIMELINE FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 - - -local function key(code, character) - forgeKey(character or 0, code.value) -end - - -local function step() - local track = forgeRoom().tracks[1] - - if stage == 1 then - debugPrint("TIMELINE video loaded: " .. tostring(FORGE.video ~= nil) .. "; track '" .. track.name .. "' has " .. #track.boxes .. " keys") - key(SCANCODE.TAB) - key(SCANCODE.TAB) - key(SCANCODE.TAB) - if FORGE.mode ~= "tracks" then - debugPrint("TIMELINE FAIL three tabs did not reach the tracks: " .. FORGE.mode) - end - - elseif stage == 2 then - -- Step and leap the cursor to frame 170, halfway between the bandit's two keys. - for _ = 1, 17 do - key(SCANCODE.RIGHTBRACKET) - end - local box = forgeCursorBox() - - debugPrint(string.format("TIMELINE cursor at %d, the bandit box is at x=%.0f (halfway from 100 to 400)", FORGE.cursor, box.x)) - if math.abs(box.x - 250) > 1 then - debugPrint("TIMELINE FAIL interpolation at the cursor") - end - - elseif stage == 3 then - -- A key at the cursor takes the interpolated box, and the list stays sorted. - key(SCANCODE.K) - if #track.boxes == 3 and track.boxes[2].at == 170 and track.boxes[2].x == 250 then - debugPrint("TIMELINE a key at 170 went in between the two, at x=250") - else - debugPrint("TIMELINE FAIL after K the track has " .. #track.boxes .. " keys; second at " .. tostring(track.boxes[2] and track.boxes[2].at)) - end - - elseif stage == 4 then - -- Pick the box up off the canvas and drag it, the way a pointer would. - local box = forgeCursorBox() - - forgePress(box.x + 5, box.y + 5) - forgeDrag(box.x + 65, box.y + 25) - forgeRelease() - if track.boxes[2].x == 310 and track.boxes[2].y == 220 then - debugPrint("TIMELINE dragged the key to " .. track.boxes[2].x .. "," .. track.boxes[2].y) - else - debugPrint("TIMELINE FAIL the drag left the key at " .. tostring(track.boxes[2].x) .. "," .. tostring(track.boxes[2].y)) - end - - elseif stage == 5 then - -- Type the key's height, then step back a frame: the box between keys follows the new one. - key(SCANCODE.RETURN) -- at - key(SCANCODE.RETURN) -- x - key(SCANCODE.RETURN) -- y - key(SCANCODE.RETURN) -- w - key(SCANCODE.RETURN) -- h - for c in string.gmatch("40", ".") do - key(SCANCODE.A, string.byte(c)) - end - key(SCANCODE.RETURN) - key(SCANCODE.ESCAPE) - key(SCANCODE.COMMA) - local box = forgeCursorBox() - - debugPrint(string.format("TIMELINE key height typed to %d; at frame %d the box is %.0f tall", track.boxes[2].h, FORGE.cursor, box.h)) - - elseif stage == 6 then - -- Undo the typing and the drag, then delete the key. Undo puts a copy of the description - -- in place, so the track is looked up again afterwards. - key(SCANCODE.U) - key(SCANCODE.U) - track = forgeRoom().tracks[1] - if track.boxes[2].x ~= 250 then - debugPrint("TIMELINE FAIL undo did not bring the key back to x=250: " .. tostring(track.boxes[2].x)) - end - FORGE.key = 2 - key(SCANCODE.DELETE) - debugPrint("TIMELINE after two undos and a delete the track has " .. #track.boxes .. " keys") - - elseif stage == 7 then - -- And a new key in a new track reaches the compiled game. - key(SCANCODE.N) - key(SCANCODE.K) - key(SCANCODE.S) - local built = forgeBuild(out .. "timeline.singe") - local text = assert(io.open(built, "r")):read("a") - - if text:find('name = "track3"') and text:find("at = " .. FORGE.cursor) then - debugPrint("TIMELINE the compiled game carries the new track and its key") - else - debugPrint("TIMELINE FAIL the new track is missing from the compiled game") - end - debugPrint("TIMELINE RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 8 then - stage = stage + 1 - step() - end - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 8, "timeline, stage " .. stage) - if frames == 44 or frames == 72 or frames == 100 then - singeScreenshot() - end - if frames > 130 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene61.singe b/testScripts/scene61.singe deleted file mode 100644 index 698ec8b3f..000000000 --- a/testScripts/scene61.singe +++ /dev/null @@ -1,105 +0,0 @@ --- Forge milestone 2, genre one: the rail shooter. A camera on a rail that stops at encounters, --- zombies that spawn there and walk to it, a gun whose ray says which part it hit, ragdolls, and --- the rail moving on when the stop is clear. The pointer is aimed at each zombie's head through --- sceneProject, which is what a light gun would do with a person behind it. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/railshooter.forge", singeGetDataPath() .. "railshooter.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local hits = 0 -local heads = 0 -local kills = 0 -local stopped = 0 -local ended = false -local lastShot = 0 -local GIVE_UP = 60 - -local realEvent = authorEvent -function authorEvent(name, event) - if name == "hit" then - hits = hits + 1 - if event.part == "head" then - heads = heads + 1 - end - elseif name == "death" then - kills = kills + 1 - elseif name == "stopped" then - stopped = stopped + 1 - elseif name == "railEnd" then - ended = true - end - return realEvent(name, event) -end - - -local function fire(x, y, on) - authorSetPointer(1, x, y, on) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) -end - - -function onOverlayUpdate() - local t = authorTime() - local gun = authorEntity("gun1") - - frames = frames + 1 - -- Aim at the nearest living zombie's head and fire, no faster than a person would; reload - -- off the picture when empty. - if gun and (t - lastShot > 0.25) then - local nearest, best = nil, math.huge - - for _, zombie in ipairs(authorEach("zombie")) do - if zombie.vars.state ~= "dead" then - local far = authorDistance(zombie, authorEntity("camera")) - - if far < best then - nearest, best = zombie, far - end - end - end - if gun.vars.ammo <= 0 then - fire(-10, -10, false) - lastShot = t - elseif nearest then - local head = nodeFind("b_Head_05", nearest.model) - local hx, hy, hz = nodeGetWorldPosition(head) - local sx, sy, _, inFront = sceneProject(hx, hy, hz) - - if inFront then - fire(sx, sy, true) - lastShot = t - end - end - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored rail shooter, %.1fs, zombies %d, kills %d, heads %d, stops %d", t, authorCount("zombie"), kills, heads, stopped)) - if (shots == 0 and t > 5) or (shots == 1 and kills > 0) or (shots == 2 and stopped == 2) or (shots == 3 and ended) then - shots = shots + 1 - singeScreenshot() - end - if (ended and t > 1) or (t > GIVE_UP) then - debugPrint(string.format("RAIL RESULT score=%d health=%d bites=%d stops=%d kills=%d hits=%d heads=%d ended=%s after=%.1fs", - AUTHOR_VARS.score, AUTHOR_VARS.health, AUTHOR_VARS.bites, stopped, kills, hits, heads, tostring(ended), t)) - if stopped < 2 then debugPrint("RAIL FAIL the rail did not reach both stops") end - if kills < 7 then debugPrint("RAIL FAIL not every zombie was killed") end - if heads == 0 then debugPrint("RAIL FAIL no shot was reported on the head") end - if not ended then debugPrint("RAIL FAIL the rail never ended") end - -- The waves track keyed by the stops: two puffs at the hall, one at the yard. - if authorCount("puff") ~= 3 then debugPrint("RAIL FAIL the stops' waves made " .. authorCount("puff") .. " puffs, not 3") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene62.singe b/testScripts/scene62.singe deleted file mode 100644 index fe9b9b46a..000000000 --- a/testScripts/scene62.singe +++ /dev/null @@ -1,64 +0,0 @@ --- Forge milestone 2, genre two: the 3D platformer. A character on the controller moving --- relative to a following camera, a jump onto a ledge, and a prize that is a trigger. Driven by --- what the hero is doing, as scene52 drives the 2D one. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/platformer3d.forge", singeGetDataPath() .. "platformer3d.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local jumped = false -local won = 0 -local GIVE_UP = 25 - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local hx, hy, hz = authorPosition(hero) - - frames = frames + 1 - if frames == 2 then - authorKeyDown(0, SCANCODE.RIGHT.value) - end - -- Jump once the ledge is near and there is ground underfoot. - if (not jumped) and (hx > 3.2) and authorOnGround(hero) then - authorKeyDown(0, SCANCODE.SPACE.value) - jumped = true - elseif jumped then - authorKeyUp(0, SCANCODE.SPACE.value) - end - -- Past the prize, turn back so the ledge is not overshot for good. - if hx > 7.5 then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif hx < 5 and jumped and AUTHOR_VARS.won == nil then - authorKeyUp(0, SCANCODE.LEFT.value) - authorKeyDown(0, SCANCODE.RIGHT.value) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored 3D platformer, %.1fs, hero %.1f %.1f %.1f, state %s, score %d", t, hx, hy, hz, hero.vars.state, AUTHOR_VARS.score)) - if (shots == 0 and t > 0.5) or (shots == 1 and jumped and not authorOnGround(hero)) or (shots == 2 and won > 0) then - shots = shots + 1 - singeScreenshot() - end - if AUTHOR_VARS.won and won == 0 then - won = t - end - if (won > 0 and t > won + 0.5) or (t > GIVE_UP) then - debugPrint(string.format("PLATFORM3D RESULT score=%d won=%s hero=%.1f,%.1f after=%.1fs", AUTHOR_VARS.score, tostring(AUTHOR_VARS.won), hx, hy, t)) - if not AUTHOR_VARS.won then debugPrint("PLATFORM3D FAIL the prize was never taken") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene63.singe b/testScripts/scene63.singe deleted file mode 100644 index 9aff325d1..000000000 --- a/testScripts/scene63.singe +++ /dev/null @@ -1,139 +0,0 @@ --- The 3D viewport (FORGE.md milestone 2): a room with a scene3d layer is shown as the scene, --- built from the types' looks under an orbiting editor camera. Entities are picked and dragged --- by where they project, the camera orbits by key, and a rail point is put where the camera --- stands. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "rail.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/railshooter.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("VIEW FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 -local wall = nil - - -local function key(code, character) - forgeKey(character or 0, code.value) -end - - -local function step() - local room = forgeRoom() - - if stage == 1 then - local built = 0 - - for _ in pairs(FORGE.scene and FORGE.scene.nodes or {}) do - built = built + 1 - end - debugPrint("VIEW the preview holds " .. built .. " of " .. #room.entities .. " entities") - if built ~= #room.entities then - debugPrint("VIEW FAIL the preview is incomplete") - end - - elseif stage == 2 then - -- Pick the left wall by where it projects. - for index, entry in ipairs(room.entities) do - if entry.id == "wallL" then - wall = index - end - end - local entry = room.entities[wall] - local sx, sy, _, inFront = sceneProject(entry.x, entry.y, entry.z) - - forgePress(sx, sy) - if FORGE.selected == wall then - debugPrint("VIEW picked " .. entry.id .. " at its projection " .. math.floor(sx) .. "," .. math.floor(sy)) - else - debugPrint("VIEW FAIL the press at the wall selected " .. tostring(FORGE.selected) .. " (in front " .. tostring(inFront) .. ")") - end - -- Drag it a little to the right on screen and let go: it moves across the floor. - forgeDrag(sx + 40, sy) - forgeRelease() - if (entry.y == 1.5) and ((entry.x ~= -6) or (entry.z ~= -12)) then - debugPrint(string.format("VIEW dragged it to %.1f, %.1f, %.1f, staying on its floor", entry.x, entry.y, entry.z)) - else - debugPrint(string.format("VIEW FAIL the drag left it at %.1f, %.1f, %.1f", entry.x, entry.y, entry.z)) - end - - elseif stage == 3 then - local yaw = FORGE.scene.yaw - local far = FORGE.scene.distance - - key(SCANCODE.J) - key(SCANCODE.J) - key(SCANCODE.I) - debugPrint(string.format("VIEW orbit: yaw %d to %d, distance %.1f to %.1f", yaw, FORGE.scene.yaw, far, FORGE.scene.distance)) - - elseif stage == 4 then - -- A rail point where the camera stands, at a typed time. - key(SCANCODE.TAB) - key(SCANCODE.TAB) - key(SCANCODE.TAB) - local track = room.tracks[1] - local count = #track.points - - key(SCANCODE.K) - key(SCANCODE.RETURN) -- at - for c in string.gmatch("20", ".") do - key(SCANCODE.A, string.byte(c)) - end - key(SCANCODE.RETURN) -- commits at, moves on to x - key(SCANCODE.ESCAPE) - track = forgeRoom().tracks[1] - local last = track.points[#track.points] - - if (#track.points == count + 1) and (last.at == 20) and (last.look ~= nil) then - debugPrint(string.format("VIEW a rail point went in at %d: %.1f, %.1f, %.1f looking at %s", last.at, last.x, last.y, last.z, tostring(last.look[1] or last.look.x))) - else - debugPrint("VIEW FAIL the rail has " .. #track.points .. " points; the last is at " .. tostring(last and last.at)) - end - - elseif stage == 5 then - key(SCANCODE.S) - local reloaded = authorLoad(work) - local built = forgeBuild(out .. "rail.singe") - local text = assert(io.open(built, "r")):read("a") - - debugPrint("VIEW saved: the rail has " .. #reloaded.rooms[1].tracks[1].points .. " points on disc; compiled carries at = 20: " .. tostring(text:find("at = 20") ~= nil)) - debugPrint("VIEW RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 6 then - stage = stage + 1 - step() - end - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 8, "3D viewport, stage " .. stage) - if frames == 20 or frames == 50 or frames == 80 then - singeScreenshot() - end - if frames > 100 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene64.singe b/testScripts/scene64.singe deleted file mode 100644 index f28bedef8..000000000 --- a/testScripts/scene64.singe +++ /dev/null @@ -1,145 +0,0 @@ --- Forge milestone 3, genre one: the adventure in a painted room. Walk areas baked to a --- navigation mesh, hotspots, verbs and the sentence line, the parser, an inventory, a dialogue --- whose choice changes the world, a cut-scene into a second room, and a save and load. Driven the --- way a player drives it: a verb, a click on a hotspot, a typed line, a numbered choice. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 16) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/adventure2d.forge", singeGetDataPath() .. "adventure2d.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local stage = 0 -local shots = 0 -local said = {} -local GIVE_UP = 90 - -local realSay = authorSay -function authorSay(text, seconds) - said[#said + 1] = tostring(text) - return realSay(text, seconds) -end - - -local function clickOn(verb, id) - local target = authorEntity(id) - local bx, by, bw, bh = authorBounds(target) - - authorSetVerb(verb) - authorSetPointer(1, bx + bw / 2, by + bh / 2, true) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) -end - - -local function lastSaid() - return said[#said] or "(nothing)" -end - - --- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking -- --- or for a dialogue to offer its choices, before the next. -local function idle() - local hero = authorEntity("hero") - - if authorChoices() ~= nil then - return true - end - - return (not authorBusy()) and ((hero == nil) or not hero.walking) -end - - -local steps = { - function() - debugPrint("ADV the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb)) - clickOn("look", "door") - end, - function() - debugPrint("ADV look at the door said: " .. lastSaid()) - clickOn("take", "key") - end, - function() - debugPrint("ADV took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. math.floor(authorX(authorEntity("hero"))) .. "," .. math.floor(authorY(authorEntity("hero")))) - clickOn("open", "door") - end, - function() - debugPrint("ADV the guard objects: " .. lastSaid()) - clickOn("talk", "guard") - end, - function() - -- The dialogue is up: the first choice, then the one the key allows. - debugPrint("ADV the guard says: " .. lastSaid()) - authorKeyDown(0, SCANCODE.MAIN_1.value) - end, - function() - debugPrint("ADV and then: " .. lastSaid()) - authorKeyDown(0, SCANCODE.MAIN_1.value) - end, - function() - debugPrint("ADV allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid()) - authorSaid("look at the door") - end, - function() - debugPrint("ADV typed 'look at the door': " .. lastSaid()) - authorSaid("frobnicate the key") - end, - function() - debugPrint("ADV typed nonsense: " .. lastSaid()) - clickOn("open", "door") - end, - function() - debugPrint("ADV after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside)) - authorSaveGame(1) - authorRestart() - debugPrint("ADV restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score) - authorLoadGame(1) - debugPrint("ADV loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key"))) - end, - function() - if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then - debugPrint("ADV RESULT done") - else - debugPrint("ADV FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score) - end - singeQuit() - end -} - -local waited = 0 - -function onOverlayUpdate() - local t = authorTime() - - frames = frames + 1 - -- Move on when the game is idle for a moment, or after a while regardless. - if idle() then - waited = waited + 1 - else - waited = 0 - end - if (stage < #steps) and ((waited > 12) or (frames == 20)) then - stage = stage + 1 - waited = 0 - steps[stage]() - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(300, 450, string.format("adventure, stage %d, %.1fs", stage, t)) - if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then - shots = shots + 1 - singeScreenshot() - end - if t > GIVE_UP then - debugPrint("ADV FAIL gave up at stage " .. stage) - singeQuit() - end - - return r -end diff --git a/testScripts/scene65.singe b/testScripts/scene65.singe deleted file mode 100644 index caa486588..000000000 --- a/testScripts/scene65.singe +++ /dev/null @@ -1,147 +0,0 @@ --- Forge milestone 3, genre two: the same adventure in a modelled room from a fixed camera. The Walk areas baked to a --- baked from the floor and a hotspot is what the ray through the pointer meets. Driven as scene64 --- is, with the pointer placed by where each hotspot projects. --- -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 16) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/adventure3d.forge", singeGetDataPath() .. "adventure3d.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local stage = 0 -local shots = 0 -local said = {} -local GIVE_UP = 90 - -local realSay = authorSay -function authorSay(text, seconds) - said[#said + 1] = tostring(text) - return realSay(text, seconds) -end - - -local function clickOn(verb, id) - local target = authorEntity(id) - local x, y, z = authorPosition(target) - local w, h = authorSize(target) - local sx, sy = sceneProject(x, y + h / 2, z) - - authorSetVerb(verb) - authorSetPointer(1, sx, sy, true) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) -end - - -local function lastSaid() - return said[#said] or "(nothing)" -end - - --- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking -- --- or for a dialogue to offer its choices, before the next. -local function idle() - local hero = authorEntity("hero") - - if authorChoices() ~= nil then - return true - end - - return (not authorBusy()) and ((hero == nil) or not hero.walking) -end - - -local steps = { - function() - debugPrint("ADV3 the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb)) - clickOn("look", "door") - end, - function() - debugPrint("ADV3 look at the door said: " .. lastSaid()) - clickOn("take", "key") - end, - function() - debugPrint("ADV3 took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. string.format("%.1f", authorX(authorEntity("hero"))) .. "," .. string.format("%.1f", authorField(authorEntity("hero"), "z"))) - clickOn("open", "door") - end, - function() - debugPrint("ADV3 the guard objects: " .. lastSaid()) - clickOn("talk", "guard") - end, - function() - -- The dialogue is up: the first choice, then the one the key allows. - debugPrint("ADV3 the guard says: " .. lastSaid()) - authorKeyDown(0, SCANCODE.MAIN_1.value) - end, - function() - debugPrint("ADV3 and then: " .. lastSaid()) - authorKeyDown(0, SCANCODE.MAIN_1.value) - end, - function() - debugPrint("ADV3 allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid()) - authorSaid("look at the door") - end, - function() - debugPrint("ADV3 typed 'look at the door': " .. lastSaid()) - authorSaid("frobnicate the key") - end, - function() - debugPrint("ADV3 typed nonsense: " .. lastSaid()) - clickOn("open", "door") - end, - function() - debugPrint("ADV3 after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside)) - authorSaveGame(1) - authorRestart() - debugPrint("ADV3 restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score) - authorLoadGame(1) - debugPrint("ADV3 loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key"))) - end, - function() - if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then - debugPrint("ADV3 RESULT done") - else - debugPrint("ADV3 FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score) - end - singeQuit() - end -} - -local waited = 0 - -function onOverlayUpdate() - local t = authorTime() - - frames = frames + 1 - -- Move on when the game is idle for a moment, or after a while regardless. - if idle() then - waited = waited + 1 - else - waited = 0 - end - if (stage < #steps) and ((waited > 12) or (frames == 20)) then - stage = stage + 1 - waited = 0 - steps[stage]() - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(300, 450, string.format("adventure in 3D, stage %d, %.1fs", stage, t)) - if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then - shots = shots + 1 - singeScreenshot() - end - if t > GIVE_UP then - debugPrint("ADV3 FAIL gave up at stage " .. stage) - singeQuit() - end - - return r -end diff --git a/testScripts/scene66.singe b/testScripts/scene66.singe deleted file mode 100644 index f89d6fdab..000000000 --- a/testScripts/scene66.singe +++ /dev/null @@ -1,55 +0,0 @@ --- Forge milestone 4, genre one: racing. The car is driven by held keys, the rival drives the --- track of points on its own, a checkpoint is a trigger the car enters, and a lap is two of them. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/racing.forge", singeGetDataPath() .. "racing.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local topSpeed = 0 -local rivalStart = nil -local GIVE_UP = 40 - - -function onOverlayUpdate() - local t = authorTime() - local car = authorEntity("car") - local rival = authorEntity("rival") - local cx, cy, cz = authorPosition(car) - local rx, ry, rz = authorPosition(rival) - - frames = frames + 1 - rivalStart = rivalStart or rz - if frames == 2 then - authorKeyDown(0, SCANCODE.UP.value) - end - topSpeed = math.max(topSpeed, car.vars.speed or 0) - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored racing, %.1fs, car z %.1f speed %.1f, rival z %.1f, gate %d, laps %d", t, cz, car.vars.speed or 0, rz, AUTHOR_VARS.next, AUTHOR_VARS.laps)) - if (shots == 0 and t > 1) or (shots == 1 and AUTHOR_VARS.next == 2) or (shots == 2 and AUTHOR_VARS.laps > 0) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.laps > 0 and t > 1) or (t > GIVE_UP) then - debugPrint(string.format("RACE RESULT laps=%d next=%d topSpeed=%.1f carZ=%.1f rivalMoved=%.1f after=%.1fs", AUTHOR_VARS.laps, AUTHOR_VARS.next, topSpeed, cz, rivalStart - rz, t)) - if topSpeed < 3 then debugPrint("RACE FAIL the car never got going") end - if AUTHOR_VARS.laps < 1 then debugPrint("RACE FAIL no lap was completed") end - if rivalStart - rz < 5 then debugPrint("RACE FAIL the rival did not drive") end - local hz = authorField(authorEntity("hover"), "z") - - debugPrint(string.format("RACE the hovercraft under thrust is at z %.1f (from 10); the hinged bar is at y %.1f", hz, authorField(authorEntity("bar"), "y"))) - if hz > 8 then debugPrint("RACE FAIL the thrust did not move the hovercraft") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene67.singe b/testScripts/scene67.singe deleted file mode 100644 index 29c650cc5..000000000 --- a/testScripts/scene67.singe +++ /dev/null @@ -1,64 +0,0 @@ --- Forge milestone 4, genre two: tower defence. Creeps walk a lane on the navigation mesh in --- waves, turrets are placed by clicks on the floor -- the pointer put where a lane point projects --- -- and fire projectiles that collide with them. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/towerdefence.forge", singeGetDataPath() .. "towerdefence.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local placed = 0 -local maxCreeps = 0 -local GIVE_UP = 45 - - -local function place(x, y, z) - local sx, sy, _, inFront = sceneProject(x, y, z) - - if inFront then - authorSetPointer(1, sx, sy, true) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - placed = placed + 1 - end -end - - -function onOverlayUpdate() - local t = authorTime() - - frames = frames + 1 - -- Two turrets beside the lane, once the scene has drawn so projection works. - if frames == 10 then - place(0, 0, -3.5) - elseif frames == 12 then - place(10, 0, 0) - end - maxCreeps = math.max(maxCreeps, authorCount("creep")) - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored tower defence, %.1fs, creeps %d, turrets %d, killed %d, gold %d, lives %d", t, authorCount("creep"), authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives)) - if (shots == 0 and t > 3) or (shots == 1 and AUTHOR_VARS.killed > 0) or (shots == 2 and t > 20) then - shots = shots + 1 - singeScreenshot() - end - local hive = authorEntity("hive") - - if (t > 8 and hive and hive.vars.made >= 10 and authorCount("creep") == 0) or (t > GIVE_UP) then - debugPrint(string.format("TD RESULT turrets=%d killed=%d gold=%d lives=%d maxCreeps=%d after=%.1fs", authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives, maxCreeps, t)) - if authorCount("turret") < 2 then debugPrint("TD FAIL the clicks did not place two turrets") end - if AUTHOR_VARS.killed == 0 then debugPrint("TD FAIL no creep was killed") end - if maxCreeps < 3 then debugPrint("TD FAIL the waves never had three creeps walking") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene68.singe b/testScripts/scene68.singe deleted file mode 100644 index 75293f3fd..000000000 --- a/testScripts/scene68.singe +++ /dev/null @@ -1,75 +0,0 @@ --- Forge milestone 5, genre one: branching video. A window on the disc answered in time sends --- the disc to the success clip; one missed sends it to the death clip and takes a life; lives --- run out, the score goes to the board, the results card shows, a coin and start continue. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - --- The master service, stubbed: what is sent is what is checked. -local submitted = {} -function masterSubmitScore(gameId, value, board, meta) - submitted[#submitted + 1] = { value = value, board = board } -end -function masterPump() end - -dofile(authorBuild("testScripts/author/fmv.forge", singeGetDataPath() .. "fmv.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local moved = false -local continued = false -local jumps = {} -local lastFrame = -1 -local before = { taken = 0, missed = 0 } -local GIVE_UP = 1500 - - -function onOverlayUpdate() - local disc = discGetFrame() - - frames = frames + 1 - -- The disc jumping backward or far forward is a branch being taken. - if (lastFrame >= 0) and ((disc < lastFrame) or (disc > lastFrame + 20)) then - jumps[#jumps + 1] = lastFrame .. ">" .. disc - end - lastFrame = disc - -- Answer the first window, in time; miss the second on purpose. - if (disc >= 130) and (disc < 150) and not moved and (AUTHOR_VARS.taken == 0) then - authorKeyDown(0, SCANCODE.RIGHT.value) - authorKeyUp(0, SCANCODE.RIGHT.value) - moved = true - end - -- Once it is over, a coin and a start continue. What the run counted is kept first, since - -- the restart begins the vars again. - if AUTHOR_VARS.lives <= 0 and not continued and disc >= 385 then - before = { taken = AUTHOR_VARS.taken, missed = AUTHOR_VARS.missed } - authorSwitchDown(SWITCH_COIN1) - authorSwitchUp(SWITCH_COIN1) - authorSwitchDown(SWITCH_START1) - authorSwitchUp(SWITCH_START1) - continued = true - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored branching video, frame %d, taken %d, missed %d, lives %d", disc, AUTHOR_VARS.taken, AUTHOR_VARS.missed, AUTHOR_VARS.lives)) - if (shots == 0 and disc >= 125) or (shots == 1 and AUTHOR_VARS.taken > 0) or (shots == 2 and AUTHOR_VARS.lives <= 0) then - shots = shots + 1 - singeScreenshot() - end - if (continued and frames > 60) or (frames > GIVE_UP) then - debugPrint(string.format("FMV RESULT taken=%d missed=%d lives=%d credits=%d submitted=%d jumps=%s", before.taken, before.missed, AUTHOR_VARS.lives, AUTHOR_VARS.credits, #submitted, table.concat(jumps, " "))) - if before.taken ~= 1 then debugPrint("FMV FAIL the first move was not taken") end - if before.missed ~= 3 then debugPrint("FMV FAIL three misses should have spent the lives") end - if #submitted ~= 1 then debugPrint("FMV FAIL the score was not sent once") end - if not continued or AUTHOR_VARS.lives ~= 3 then debugPrint("FMV FAIL the continue did not restart with three lives") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene69.singe b/testScripts/scene69.singe deleted file mode 100644 index 1119df30b..000000000 --- a/testScripts/scene69.singe +++ /dev/null @@ -1,179 +0,0 @@ --- The editor's milestone 6 (FORGE.md): a picker narrowed by typing, a file field offering the --- game's files, walk areas and hotspot outlines drawn corner by corner, the dialogue outline, and --- building with the current room first. Driven by keys and canvas presses, as the others are. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "adv.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/adventure2d.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("EDIT FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 - - -local function key(code, character) - forgeKey(character or 0, code.value) -end - - -local function typeIn(text) - for c in string.gmatch(text, ".") do - key(SCANCODE.A, string.byte(c)) - end -end - - -local function step() - if stage == 1 then - -- A rule's condition picked by typing part of its name. - key(SCANCODE.TAB) - key(SCANCODE.TAB) - key(SCANCODE.N) - key(SCANCODE.C) - typeIn("keyH") - local shown = forgePickItems() - - key(SCANCODE.RETURN) - local rule = FORGE.game.rules[#FORGE.game.rules] - - if #shown == 1 and rule.when[1][1] == "keyHeld" then - debugPrint("EDIT typing keyH left one condition and ENTER took it: " .. rule.when[1][1]) - else - debugPrint("EDIT FAIL the narrowed picker showed " .. #shown .. " and took " .. tostring(rule.when[1] and rule.when[1][1])) - end - - elseif stage == 2 then - -- A file field offers the files under the game's directory. - key(SCANCODE.TAB) -- tracks - key(SCANCODE.TAB) -- dialogues - key(SCANCODE.TAB) -- entities - key(SCANCODE.TAB) -- types - FORGE.typeName = "hero" - forgeTypeSet(FORGE.game.types.hero, "look", "sprite") - FORGE.lastField = "look.faces" -- the field before look.file - key(SCANCODE.RETURN) -- look.file: the picker - if FORGE.picker == nil then - debugPrint("EDIT FAIL a file field did not open the picker") - end - typeIn("testScripts/strip") - key(SCANCODE.RETURN) - debugPrint("EDIT the hero's sprite file is now " .. tostring(FORGE.game.types.hero.look.file)) - if FORGE.game.types.hero.look.file ~= "testScripts/strip.png" then - debugPrint("EDIT FAIL the file picker did not set the file") - end - forgeTypeSet(FORGE.game.types.hero, "look", "box") - - elseif stage == 3 then - -- A walk area drawn corner by corner, then a hotspot's outline. - key(SCANCODE.TAB) -- rules - key(SCANCODE.TAB) -- tracks - key(SCANCODE.TAB) -- dialogues - key(SCANCODE.TAB) -- entities - forgeSelect(nil) - local walks = #(forgeRoom().walk or {}) - - key(SCANCODE.V) - forgePress(300, 320) - forgePress(500, 320) - forgePress(400, 400) - key(SCANCODE.V) - if #forgeRoom().walk == walks + 1 and #forgeRoom().walk[walks + 1] == 6 then - debugPrint("EDIT a walk area of three corners was drawn") - else - debugPrint("EDIT FAIL the room has " .. #(forgeRoom().walk or {}) .. " walk areas") - end - for index, entry in ipairs(forgeRoom().entities) do - if entry.id == "door" then - forgeSelect(index) - end - end - key(SCANCODE.V) - forgePress(570, 180) - forgePress(630, 180) - forgePress(630, 300) - forgePress(570, 300) - key(SCANCODE.RETURN) - local spot = FORGE.game.types.door.behaviours[1] - - if spot.polygon and #spot.polygon == 8 then - debugPrint("EDIT the door's outline has four corners") - else - debugPrint("EDIT FAIL the door's outline is " .. tostring(spot.polygon and #spot.polygon)) - end - - elseif stage == 4 then - -- The dialogue outline: a node added and named, a choice added, an action picked for it. - key(SCANCODE.TAB) -- types - key(SCANCODE.TAB) -- rules - key(SCANCODE.TAB) -- tracks - key(SCANCODE.TAB) -- dialogues - debugPrint("EDIT the outline starts on dialogue '" .. tostring(FORGE.dialogue) .. "' with " .. #forgeDialogueRows() .. " rows") - key(SCANCODE.A) -- a node in the guard's dialogue - key(SCANCODE.RETURN) -- its name - typeIn("bye") - key(SCANCODE.RETURN) -- who - key(SCANCODE.ESCAPE) - key(SCANCODE.A) -- a choice in it - key(SCANCODE.T) - typeIn("addSc") - key(SCANCODE.RETURN) - local node = FORGE.game.dialogues.guard.nodes.bye - - if node and node.choices[1] and node.choices[1].act[1][1] == "addScore" then - debugPrint("EDIT a node 'bye' with a choice that adds to the score went into the guard's dialogue") - else - debugPrint("EDIT FAIL the node is " .. tostring(node) .. "; rows " .. #forgeDialogueRows()) - end - - elseif stage == 5 then - -- Saved, and built with the second room first. - key(SCANCODE.S) - local built = forgeBuild(out .. "adv.singe", 2) - local text = assert(io.open(built, "r")):read("a") - local yard = text:find('name = "yard"', 1, true) - local hall = text:find('name = "hall"', 1, true) - - debugPrint("EDIT built from the yard: yard before hall " .. tostring(yard ~= nil and hall ~= nil and yard < hall) .. "; the dialogue carries bye: " .. tostring(text:find('"bye"', 1, true) ~= nil)) - if not (yard and hall and yard < hall) then - debugPrint("EDIT FAIL the built game does not start in the yard") - end - debugPrint("EDIT RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 6 then - stage = stage + 1 - step() - end - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 8, "editor, stage " .. stage) - if frames == 30 or frames == 58 or frames == 72 then - singeScreenshot() - end - if frames > 100 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene70.singe b/testScripts/scene70.singe deleted file mode 100644 index 703d5d659..000000000 --- a/testScripts/scene70.singe +++ /dev/null @@ -1,82 +0,0 @@ --- The long tail (FORGE.md milestone 7), in 2D: a sprite sheet's frames stepped by state, a --- grid of tiles drawn from a sheet, and MIDI notes reaching the rules as an event. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/tail.forge", singeGetDataPath() .. "tail.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local seen = {} -local leftSeen = {} -local shots = 0 - - -function onOverlayUpdate() - local runner = authorEntity("runner") - local t = authorTime() - - frames = frames + 1 - if frames == 2 then - authorKeyDown(0, SCANCODE.RIGHT.value) - elseif frames == 45 then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif frames == 60 then - authorMidi(0x90, 60, 100) - authorMidi(0x91, 64, 90) - authorMidi(0x80, 60, 0) - end - if frames > 48 then - leftSeen[runner.vars.frame] = true - else - seen[runner.vars.frame] = true - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored long tail, %.1fs, frame %d, %s", t, runner.vars.frame, runner.vars.state)) - if (shots == 0 and frames == 20) or (shots == 1 and frames == 70) then - shots = shots + 1 - singeScreenshot() - end - if frames > 90 then - local distinct = 0 - - for _ in pairs(seen) do - distinct = distinct + 1 - end - local floor = authorEntity("floor") - local w, h = authorSize(floor) - - debugPrint(string.format("TAIL RESULT frames=%d state=%s score=%d notes=%d grid=%dx%d", distinct, runner.vars.state, AUTHOR_VARS.score, AUTHOR_VARS.notes, w, h)) - if distinct < 3 then debugPrint("TAIL FAIL the walk cycle did not step through its frames") end - local leftOnly = true - - for frame in pairs(leftSeen) do - if frame > 2 then - leftOnly = false - end - end - debugPrint("TAIL facing left used only the walkLeft frames: " .. tostring(leftOnly)) - local spinner = authorEntity("spinner") - - debugPrint(string.format("TAIL the spinner began at 45 and is at %.0f degrees; the fire has an emitter: %s", spinner.vars.angle, tostring(authorEntity("fire").emitter ~= nil))) - if spinner.vars.angle < 100 then debugPrint("TAIL FAIL the spinner did not turn") end - local problems = authorCheck({ types = { thing = { look = { kind = "box", widthh = 3 }, behaviours = { { kind = "drift", vxx = 1 } } } }, rooms = { { name = "r", entities = {} } }, rules = { { on = "pressed", keyy = "A" } } }) - - debugPrint("TAIL the checker names " .. #problems .. " misspelt fields: " .. table.concat(problems, " | ")) - if #problems ~= 3 then debugPrint("TAIL FAIL expected three misspellings to be named") end - if not leftOnly then debugPrint("TAIL FAIL facing left drew frames outside walkLeft") end - if AUTHOR_VARS.score ~= 10 or AUTHOR_VARS.notes ~= 2 then debugPrint("TAIL FAIL the notes were not counted: score " .. AUTHOR_VARS.score .. " notes " .. AUTHOR_VARS.notes) end - if w ~= 160 or h ~= 96 then debugPrint("TAIL FAIL the grid is not 5 by 3 tiles of 32") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene71.singe b/testScripts/scene71.singe deleted file mode 100644 index 2273f7c4f..000000000 --- a/testScripts/scene71.singe +++ /dev/null @@ -1,49 +0,0 @@ --- The long tail (FORGE.md milestone 7), in 3D: a body floating in water, a boat under its --- propeller, and a soft body. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/pool.forge", singeGetDataPath() .. "pool.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local boatStart = nil -local lowest = math.huge - - -function onOverlayUpdate() - local t = authorTime() - local ball = authorEntity("ball") - local boat = authorEntity("boat") - local by = authorY(ball) - - frames = frames + 1 - boatStart = boatStart or authorField(boat, "z") - if t > 2 then - lowest = math.min(lowest, by) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored pool, %.1fs, ball y %.2f, boat z %.1f", t, by, authorField(boat, "z"))) - if (shots == 0 and t > 1) or (shots == 1 and t > 4) then - shots = shots + 1 - singeScreenshot() - end - if t > 6 then - local moved = boatStart - authorField(boat, "z") - - debugPrint(string.format("POOL RESULT ballLowest=%.2f ballNow=%.2f boatMoved=%.1f balloon=%s", lowest, by, moved, tostring(authorEntity("balloon") ~= nil))) - if lowest < -1.5 then debugPrint("POOL FAIL the ball sank to the bottom") end - if moved < 2 then debugPrint("POOL FAIL the boat did not move") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene72.singe b/testScripts/scene72.singe deleted file mode 100644 index 81e49f41b..000000000 --- a/testScripts/scene72.singe +++ /dev/null @@ -1,96 +0,0 @@ --- The first-person shooter from FORGE.md's catalogue: a camera on the hero's eyes turned by mouse --- motion, a gun firing from the middle of the picture, keys moving relative to where the camera --- looks, and enemies walking the mesh after the hero. The mouse is injected to turn toward the --- nearest zombie's head before each shot. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/fps.forge", singeGetDataPath() .. "fps.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local hits = 0 -local kills = 0 -local lastShot = 0 -local walked = false -local startZ = nil -local GIVE_UP = 45 -local LOOK_SPEED = 0.2 - -local realEvent = authorEvent -function authorEvent(name, event) - if name == "hit" then hits = hits + 1 elseif name == "death" then kills = kills + 1 end - return realEvent(name, event) -end - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local eyes = authorEntity("eyes") - local hx, hy, hz = authorPosition(hero) - - frames = frames + 1 - startZ = startZ or hz - -- A step forward first, to show the keys move relative to the view. - if frames == 5 then - authorKeyDown(0, SCANCODE.W.value) - elseif frames == 40 then - authorKeyUp(0, SCANCODE.W.value) - walked = true - end - -- Turn toward the nearest zombie's head and fire. - if walked and (t - lastShot > 0.3) then - local nearest, best = nil, math.huge - - for _, zombie in ipairs(authorEach("zombie")) do - if zombie.vars.state ~= "dead" then - local far = authorDistance3D(zombie, hero) - - if far < best then - nearest, best = zombie, far - end - end - end - if nearest then - local head = nodeFind("b_Head_05", nearest.model) - local tx, ty, tz = nodeGetWorldPosition(head) - local ex, ey, ez = authorPosition(eyes) - local wantYaw = math.deg(math.atan(-(tx - ex), -(tz - ez))) - local wantPitch = math.deg(math.atan(ty - ey, math.sqrt((tx - ex) ^ 2 + (tz - ez) ^ 2))) - local dYaw = (eyes.yaw - wantYaw + 540) % 360 - 180 - local dPitch = eyes.pitch - wantPitch - - authorMouseMoved(0, 0, dYaw / LOOK_SPEED, dPitch / LOOK_SPEED) - if math.abs(dYaw) < 2 then - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - lastShot = t - end - end - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored first person, %.1fs, yaw %.0f, hits %d, kills %d, zombies %d", t, eyes.yaw, hits, kills, authorCount("zombie"))) - if (shots == 0 and t > 1) or (shots == 1 and kills > 0) or (shots == 2 and kills >= 4) then - shots = shots + 1 - singeScreenshot() - end - local hive = authorEntity("hive") - - if (hive.vars.made >= 6 and authorCount("zombie") == 0 and t > 5) or (t > GIVE_UP) then - debugPrint(string.format("FPS RESULT kills=%d hits=%d health=%d bites=%d movedForward=%.1f after=%.1fs", kills, hits, AUTHOR_VARS.health, AUTHOR_VARS.bites, startZ - hz, t)) - if kills < 4 then debugPrint("FPS FAIL fewer than four zombies were killed") end - if startZ - hz < 1 then debugPrint("FPS FAIL W did not move the hero forward") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene73.singe b/testScripts/scene73.singe deleted file mode 100644 index af9089a06..000000000 --- a/testScripts/scene73.singe +++ /dev/null @@ -1,53 +0,0 @@ --- Arcade physics (FORGE.md's catalogue): a ball on a 2D body bouncing off static bricks and a --- kinematic paddle the keys move; bricks broken by collision rules. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -dofile(authorBuild("testScripts/author/breakout.forge", singeGetDataPath() .. "breakout.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local paddleStart = nil -local GIVE_UP = 30 - - -function onOverlayUpdate() - local t = authorTime() - local ball = authorEntity("ball") - local paddle = authorEntity("paddle") - local bx, by = authorPosition(ball) - local px = authorX(paddle) - - frames = frames + 1 - paddleStart = paddleStart or px - -- The paddle chases the ball, which is what keeps it in play. - if bx < px - 10 then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif bx > px + 10 then - authorKeyUp(0, SCANCODE.LEFT.value) - authorKeyDown(0, SCANCODE.RIGHT.value) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored breakout, %.1fs, ball %.0f,%.0f, broken %d, lives %d", t, bx, by, AUTHOR_VARS.broken, AUTHOR_VARS.lives)) - if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.broken > 0) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.broken >= 2) or (t > GIVE_UP) then - debugPrint(string.format("BREAKOUT RESULT broken=%d lives=%d paddleMoved=%s after=%.1fs", AUTHOR_VARS.broken, AUTHOR_VARS.lives, tostring(math.abs(px - paddleStart) > 5), t)) - if AUTHOR_VARS.broken < 1 then debugPrint("BREAKOUT FAIL no brick was broken") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene74.singe b/testScripts/scene74.singe deleted file mode 100644 index 1ce3632fc..000000000 --- a/testScripts/scene74.singe +++ /dev/null @@ -1,51 +0,0 @@ --- A maze (FORGE.md's catalogue): a tile map for the walls, corridors that are a walk area, a --- hero sent by a click, dots eaten on touch, and a ghost that follows the hero across the mesh. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -dofile(authorBuild("testScripts/author/maze.forge", singeGetDataPath() .. "maze.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local gapStart = nil -local GIVE_UP = 25 - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local ghost = authorEntity("ghost") - local gap = authorDistance(hero, ghost) - - frames = frames + 1 - gapStart = gapStart or gap - if frames == 10 then - -- A click at the far end of the top corridor. - authorSetPointer(1, 340, 180, true) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored maze, %.1fs, hero %.0f,%.0f, ghost gap %.0f, eaten %d", t, authorX(hero), authorY(hero), gap, AUTHOR_VARS.eaten)) - if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.eaten > 0) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.eaten >= 2 and gap < gapStart - 50) or (t > GIVE_UP) then - debugPrint(string.format("MAZE RESULT eaten=%d gap=%.0f (from %.0f) caught=%s after=%.1fs", AUTHOR_VARS.eaten, gap, gapStart, tostring(AUTHOR_VARS.caught), t)) - if AUTHOR_VARS.eaten < 2 then debugPrint("MAZE FAIL the hero did not eat two dots on its way") end - if gap >= gapStart - 50 then debugPrint("MAZE FAIL the ghost did not close on the hero") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene75.singe b/testScripts/scene75.singe deleted file mode 100644 index 01531aab2..000000000 --- a/testScripts/scene75.singe +++ /dev/null @@ -1,79 +0,0 @@ --- Third-person action (FORGE.md's catalogue): a camera orbiting the hero under injected mouse --- motion, keys moving the hero relative to it, enemies following across the mesh, and a sword --- swung by a key at what is in reach. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/thirdperson.forge", singeGetDataPath() .. "thirdperson.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local kills = 0 -local yawStart = nil -local startX = nil -local lastSwing = 0 -local GIVE_UP = 40 - -local realEvent = authorEvent -function authorEvent(name, event) - if name == "death" then kills = kills + 1 end - return realEvent(name, event) -end - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local camera = authorEntity("camera") - local hx, hy, hz = authorPosition(hero) - - frames = frames + 1 - yawStart = yawStart or camera.yaw - startX = startX or hx - -- Orbit the camera a quarter turn over the first second, then walk forward for two. - if frames >= 5 and frames < 35 then - authorMouseMoved(0, 0, 15, 0) - elseif frames == 35 then - authorKeyDown(0, SCANCODE.W.value) - elseif frames == 155 then - authorKeyUp(0, SCANCODE.W.value) - end - -- Swing at anything close. - if (t - lastSwing > 0.4) then - for _, enemy in ipairs(authorEach("enemy")) do - if (enemy.vars.state ~= "dead") and (authorDistance3D(enemy, hero) < 2.2) then - authorKeyDown(0, SCANCODE.SPACE.value) - authorKeyUp(0, SCANCODE.SPACE.value) - lastSwing = t - break - end - end - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored third person, %.1fs, yaw %.0f, hero %.1f,%.1f, kills %d, swings %d", t, camera.yaw, hx, hz, kills, AUTHOR_VARS.swings)) - if (shots == 0 and frames == 40) or (shots == 1 and kills > 0) then - shots = shots + 1 - singeScreenshot() - end - local hive = authorEntity("hive") - - if (hive.vars.made >= 4 and authorCount("enemy") == 0 and t > 5) or (t > GIVE_UP) then - debugPrint(string.format("TPS RESULT kills=%d swings=%d health=%d yawTurned=%.0f movedX=%.1f after=%.1fs", kills, AUTHOR_VARS.swings, AUTHOR_VARS.health, camera.yaw - yawStart, hx - startX, t)) - if kills < 3 then debugPrint("TPS FAIL fewer than three enemies were cut down") end - if math.abs(camera.yaw - yawStart) < 60 then debugPrint("TPS FAIL the camera did not orbit") end - if math.abs(hx - startX) < 1 then debugPrint("TPS FAIL walking forward after the orbit did not move the hero sideways in the world") end - local sign = authorEntity("sign") - if (sign == nil) or (sign.vars.text ~= "ARENA") or (sign.node == nil) then debugPrint("TPS FAIL the text3d sign is not standing in the field") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene76.singe b/testScripts/scene76.singe deleted file mode 100644 index 94152d9a5..000000000 --- a/testScripts/scene76.singe +++ /dev/null @@ -1,49 +0,0 @@ --- A space shooter (FORGE.md's catalogue): a ship under thrust with no gravity, bolts fired --- forward, rocks coming at it broken by collision rules, and a camera behind. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/space.forge", singeGetDataPath() .. "space.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local startZ = nil -local maxBolts = 0 -local GIVE_UP = 40 - - -function onOverlayUpdate() - local t = authorTime() - local ship = authorEntity("ship") - local sx, sy, sz = authorPosition(ship) - - frames = frames + 1 - startZ = startZ or sz - if frames == 3 then - authorKeyDown(0, SCANCODE.UP.value) - authorKeyDown(0, SCANCODE.SPACE.value) - end - maxBolts = math.max(maxBolts, authorCount("bolt")) - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored space, %.1fs, ship z %.1f, bolts %d, rocks %d, broken %d", t, sz, authorCount("bolt"), authorCount("rock"), AUTHOR_VARS.rocks)) - if (shots == 0 and t > 2) or (shots == 1 and AUTHOR_VARS.rocks > 0) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.rocks >= 3) or (t > GIVE_UP) then - debugPrint(string.format("SPACE RESULT broken=%d movedForward=%.1f maxBolts=%d after=%.1fs", AUTHOR_VARS.rocks, startZ - sz, maxBolts, t)) - if AUTHOR_VARS.rocks < 1 then debugPrint("SPACE FAIL no rock was broken") end - if startZ - sz < 3 then debugPrint("SPACE FAIL the thrust did not move the ship") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene77.singe b/testScripts/scene77.singe deleted file mode 100644 index ff7f40336..000000000 --- a/testScripts/scene77.singe +++ /dev/null @@ -1,52 +0,0 @@ --- A rhythm game (FORGE.md's catalogue): notes that fall to a marker, a press inside the window --- scoring and one outside missing, over a music layer. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -dofile(authorBuild("testScripts/author/rhythm.forge", singeGetDataPath() .. "rhythm.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local pressed = {} -local GIVE_UP = 8 - - -function onOverlayUpdate() - local t = authorTime() - - frames = frames + 1 - -- Press when a note is at the marker, once per note, and once at nothing. - for _, note in ipairs(authorEach("note")) do - if (math.abs(authorY(note) - 400) < 12) and not pressed[note.id] then - pressed[note.id] = true - authorKeyDown(0, SCANCODE.SPACE.value) - authorKeyUp(0, SCANCODE.SPACE.value) - end - end - if frames == 10 then - authorKeyDown(0, SCANCODE.SPACE.value) - authorKeyUp(0, SCANCODE.SPACE.value) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored rhythm, %.1fs, notes %d, hits %d, misses %d, music %s", t, authorCount("note"), AUTHOR_VARS.hits, AUTHOR_VARS.misses, tostring(musicIsPlaying()))) - if (shots == 0 and t > 1.5) or (shots == 1 and AUTHOR_VARS.hits > 0) then - shots = shots + 1 - singeScreenshot() - end - if t > GIVE_UP then - debugPrint(string.format("RHYTHM RESULT hits=%d misses=%d score=%d", AUTHOR_VARS.hits, AUTHOR_VARS.misses, AUTHOR_VARS.score)) - if AUTHOR_VARS.hits < 3 then debugPrint("RHYTHM FAIL fewer than three notes were hit") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene78.singe b/testScripts/scene78.singe deleted file mode 100644 index bb8abbda7..000000000 --- a/testScripts/scene78.singe +++ /dev/null @@ -1,43 +0,0 @@ --- Bowling (FORGE.md's catalogue): a ball shoved down a lane by a press, pins that are bodies --- counted as they fall, and a trigger at the end of the lane. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/bowling.forge", singeGetDataPath() .. "bowling.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local GIVE_UP = 20 - - -function onOverlayUpdate() - local t = authorTime() - local ball = authorEntity("ball") - - frames = frames + 1 - if frames == 5 then - authorKeyDown(0, SCANCODE.SPACE.value) - authorKeyUp(0, SCANCODE.SPACE.value) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored bowling, %.1fs, ball z %.1f, pins down %d", t, authorField(ball, "z"), AUTHOR_VARS.down)) - if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.down > 0) then - shots = shots + 1 - singeScreenshot() - end - if (AUTHOR_VARS.done and t > 3) or (t > GIVE_UP) then - debugPrint(string.format("BOWL RESULT down=%d done=%s ballZ=%.1f after=%.1fs", AUTHOR_VARS.down, tostring(AUTHOR_VARS.done), authorField(ball, "z"), t)) - if AUTHOR_VARS.down < 1 then debugPrint("BOWL FAIL no pin fell") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene79.singe b/testScripts/scene79.singe deleted file mode 100644 index 372578c09..000000000 --- a/testScripts/scene79.singe +++ /dev/null @@ -1,51 +0,0 @@ --- Occluders (FORGE.md): a painted room in 3D with a pillar that is only in the picture. The --- pillar is an invisible box that writes depth alone, so the picture shows through it and the hero --- disappears behind it on the way across. Three shots: before, behind, after. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/painted.forge", singeGetDataPath() .. "painted.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 -local GIVE_UP = 12 - - -function onOverlayUpdate() - local t = authorTime() - local hero = authorEntity("hero") - local hx = authorX(hero) - - frames = frames + 1 - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("authored painted room, %.1fs, hero x %.1f, pillar %s", t, hx, hero.material and "" or "is an occluder")) - if (shots == 0 and hx > -3.5) or (shots == 1 and hx > -0.3) or (shots == 2 and hx > 3) then - shots = shots + 1 - singeScreenshot() - end - if AUTHOR_VARS.across or (t > GIVE_UP) then - local pillar = authorEntity("pillar") - - local _, ry, _ = nodeGetRotation(authorEntity("far").node) - - debugPrint(string.format("PAINTED RESULT across=%s heroX=%.1f occluderCall=%s pillarMaterial=%s markerYaw=%.0f", tostring(AUTHOR_VARS.across), hx, tostring(type(materialSetOccluder) == "function"), tostring(pillar.material ~= nil), ry)) - if math.abs(ry - 90) > 1 then debugPrint("PAINTED FAIL the marker's ry did not reach its node") end - local sx = nodeGetScale(authorEntity("far").node) - local w = authorSize(authorEntity("far")) - - debugPrint(string.format("PAINTED the marker's scale of 2 gives node scale %.1f and a counted width of %.1f", sx, w)) - if math.abs(sx - 2) > 0.01 then debugPrint("PAINTED FAIL the scale did not reach the node") end - if not AUTHOR_VARS.across then debugPrint("PAINTED FAIL the hero did not cross") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene80.singe b/testScripts/scene80.singe deleted file mode 100644 index 8dafff4e4..000000000 --- a/testScripts/scene80.singe +++ /dev/null @@ -1,131 +0,0 @@ --- Gizmos in the 3D viewport (FORGE.md): the selected entity carries three arms along the world --- axes, and dragging an arm's handle moves the entity along that axis alone -- the pointer's --- travel along the arm, as a fraction of the arm, is the distance moved. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "gizmo.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/railshooter.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("GIZMO FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 - - -local function handle(axis) - for _, item in ipairs(forgeGizmoHandles()) do - if item.axis == axis then - return item - end - end - - return nil -end - - -local function step() - local room = forgeRoom() - - if stage == 1 then - for index, entry in ipairs(room.entities) do - if entry.id == "wallL" then - forgeSelect(index) - end - end - local arms = forgeGizmoHandles() - - debugPrint("GIZMO the selected wall shows " .. #arms .. " arms") - if #arms ~= 4 then - debugPrint("GIZMO FAIL expected three axis arms and the turn handle") - end - - elseif stage == 2 then - -- Drag the Y handle the length of its own arm: the wall rises one arm's length (1.5) and - -- nothing else about it changes. - local entry = room.entities[FORGE.selected] - local was = { x = entry.x, y = entry.y, z = entry.z } - local arm = handle("y") - - forgePress(arm.tx, arm.ty) - forgeDrag(arm.tx + (arm.tx - arm.sx), arm.ty + (arm.ty - arm.sy)) - forgeRelease() - debugPrint(string.format("GIZMO the Y arm took the wall from %.1f to %.1f; x %.1f to %.1f, z %.1f to %.1f", was.y, entry.y, was.x, entry.x, was.z, entry.z)) - if math.abs(entry.y - (was.y + 1.5)) > 0.2 or entry.x ~= was.x or entry.z ~= was.z then - debugPrint("GIZMO FAIL the Y drag did not move along Y alone") - end - - elseif stage == 3 then - -- And the X handle, dragged half an arm back. - local entry = room.entities[FORGE.selected] - local was = entry.x - local arm = handle("x") - - forgePress(arm.tx, arm.ty) - forgeDrag(arm.tx - (arm.tx - arm.sx) / 2, arm.ty - (arm.ty - arm.sy) / 2) - forgeRelease() - debugPrint(string.format("GIZMO the X arm took the wall from %.1f to %.1f", was, entry.x)) - if math.abs(entry.x - (was - 0.75)) > 0.2 then - debugPrint("GIZMO FAIL the X drag did not move half an arm back") - end - forgeKey(0, SCANCODE.U.value) - forgeKey(0, SCANCODE.U.value) - -- Undo puts a copy of the description in place, so the room is looked up again. - local back = forgeRoom().entities[FORGE.selected] - - debugPrint(string.format("GIZMO two undos: the wall is back at x %.1f, y %.1f", back.x, back.y)) - if back.x ~= -6 or back.y ~= 1.5 then - debugPrint("GIZMO FAIL undo did not put the wall back") - end - - elseif stage == 4 then - -- The turn handle: dragged a quarter turn round the entity on screen. - local entry = forgeRoom().entities[FORGE.selected] - local arm = handle("ry") - local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy - - forgePress(arm.tx, arm.ty) - forgeDrag(arm.sx - dy, arm.sy + dx) - forgeRelease() - debugPrint(string.format("GIZMO the turn handle set ry to %s", tostring(entry.ry))) - if (entry.ry == nil) or (math.abs(((entry.ry - 270 + 180) % 360) - 180) > 15) then - debugPrint("GIZMO FAIL a quarter turn clockwise on screen should read about 270") - end - debugPrint("GIZMO RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 5 then - stage = stage + 1 - step() - end - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 8, "gizmos, stage " .. stage) - if frames == 20 or frames == 34 then - singeScreenshot() - end - if frames > 80 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene81.singe b/testScripts/scene81.singe deleted file mode 100644 index a6f349a56..000000000 --- a/testScripts/scene81.singe +++ /dev/null @@ -1,73 +0,0 @@ --- Facing and turning sprites in a Forge game: a sheet drawn facing right only is mirrored by --- the engine while its instance faces left (the runner beside it has frames drawn both ways and --- is not), a sheet entity with an rz draws its frame turned, and clips play at a volume of their --- own under the game's. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/tail.forge", singeGetDataPath() .. "facing.singe")) - -local gameUpdate = onOverlayUpdate -local frames = 0 -local shots = 0 - - -function onOverlayUpdate() - local runner = authorEntity("runner") - local mirror = authorEntity("mirror") - local tilted = authorEntity("tilted") - - frames = frames + 1 - if frames == 2 then - authorKeyDown(0, SCANCODE.RIGHT.value) - elseif frames == 30 then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif frames == 40 then - -- The playSound action with a volume, and the sound behaviour's, both under the master. - authorMidi(0x90, 60, 100) - authorBehaviourEvent(mirror, "hit") - local channel = soundPlay(soundLoad("testScripts/crackle.wav"), 0, 20) - - soundSetChannelVolume(channel, 50) - soundSetVolume(40) - debugPrint("FACING a clip on channel " .. channel .. " at its own volume under a master of " .. soundGetVolume()) - end - - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("facing: runner %s, mirror %s%s, tilted %d degrees", tostring(runner.vars.facing), tostring(mirror.vars.facing), authorMirrored(mirror) and " (mirrored)" or "", tilted.vars.angle or 0)) - if (shots == 0 and frames == 20) or (shots == 1 and frames == 55) then - shots = shots + 1 - singeScreenshot() - end - if frames == 20 then - debugPrint(string.format("FACING going right: runner mirrored %s, mirror mirrored %s", tostring(authorMirrored(runner)), tostring(authorMirrored(mirror)))) - if authorMirrored(runner) or authorMirrored(mirror) then - debugPrint("FACING FAIL nothing should be mirrored while facing right") - end - debugPrint(string.format("FACING the tilted sheet has an image of its own: %s, turned %d", tostring(tilted.ownSprite ~= nil), tilted.ownAngle or -1)) - if (tilted.ownSprite == nil) or (tilted.ownAngle ~= 30) then - debugPrint("FACING FAIL the sheet entity with rz 30 should draw a turned frame") - end - elseif frames == 55 then - debugPrint(string.format("FACING going left: runner mirrored %s (drawn %s), mirror mirrored %s", tostring(authorMirrored(runner)), tostring(runner.drawnFacing), tostring(authorMirrored(mirror)))) - if authorMirrored(runner) or not authorMirrored(mirror) then - debugPrint("FACING FAIL the sheet without left frames should be mirrored, the one with them not") - end - if mirror.ownSprite == nil then - debugPrint("FACING FAIL the mirrored instance should draw from an image of its own") - end - debugPrint("FACING RESULT done") - end - if frames > 70 then - singeQuit() - end - - return r -end diff --git a/testScripts/scene82.singe b/testScripts/scene82.singe deleted file mode 100644 index 14acba5d6..000000000 --- a/testScripts/scene82.singe +++ /dev/null @@ -1,134 +0,0 @@ --- The 2D canvas's turn handle and the types panel's thumbnails (FORGE.md): the selected entity --- carries a yellow arm the way its rz points, dragged round it to turn it, as the scene's ry arm --- does; and each type in the panel shows what it looks like beside its name. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) -local out = singeGetDataPath() -local work = out .. "turn.forge" - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -local source = assert(io.open("testScripts/author/tail.forge", "r")) -local copy = assert(io.open(work, "w")) - -copy:write(source:read("a")) -source:close() -copy:close() - -if not forgeBegin(work) then - debugPrint("TURN FAIL could not open " .. work) - singeQuit() -end - -local frames = 0 -local stage = 0 - - -local function selectEntity(id) - for index, entry in ipairs(forgeRoom().entities) do - if entry.id == id then - forgeSelect(index) - end - end -end - - -local function step() - if stage == 1 then - selectEntity("spinner") - local arm = forgeTurnHandle() - - debugPrint(string.format("TURN the spinner, rz 45, shows its handle at %.0f, %.0f from %.0f, %.0f", arm and arm.tx or -1, arm and arm.ty or -1, arm and arm.sx or -1, arm and arm.sy or -1)) - if (arm == nil) or (arm.tx <= arm.sx) or (arm.ty <= arm.sy) then - debugPrint("TURN FAIL a handle at 45 degrees lies down and right of the entity") - end - - elseif stage == 2 then - -- A quarter turn clockwise round the entity: the handle's offset turned 90 degrees. - local entry = forgeRoom().entities[FORGE.selected] - local arm = forgeTurnHandle() - local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy - - forgePress(arm.tx, arm.ty) - forgeDrag(arm.sx - dy, arm.sy + dx) - forgeRelease() - debugPrint(string.format("TURN a quarter turn clockwise took rz from 45 to %s", tostring(entry.rz))) - if (entry.rz == nil) or (math.abs(entry.rz - 135) > 3) then - debugPrint("TURN FAIL expected about 135") - end - - elseif stage == 3 then - -- Undo puts it back, and a sheet entity with an rz draws its first frame turned. - forgeKey(0, SCANCODE.U.value) - local back = forgeRoom().entities[FORGE.selected] - - debugPrint("TURN undone, rz " .. tostring(back.rz)) - if back.rz ~= 45 then - debugPrint("TURN FAIL undo should give 45 back") - end - selectEntity("tilted") - - elseif stage == 4 then - -- The types panel: a thumbnail in front of every name. - -- A click on a row of the panel does what the arrows do, and a click on a field starts - -- typing it, through the dispatcher the rows are wired to. - -- Real clicks, dispatched on the rows as RmlUi would, so the engine's handler path is - -- what is tested and not only the dispatcher behind it. - local function click(id) - rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById(id):DispatchEvent("click", {}) - end - - forgeKey(0, SCANCODE.MAIN_2.value) - click("t2") - local lit = FORGE.typeName - - forgeKey(0, SCANCODE.MAIN_1.value) - click("e2") - local picked = FORGE.selected - click("f3") - local editing = FORGE.editing and FORGE.editing.key - forgeKey(0, SCANCODE.ESCAPE.value) - debugPrint(string.format("TURN clicks: type row 2 lit %s, entity row 2 selected %s, field row 3 typing %s", tostring(lit), tostring(picked), tostring(editing))) - if (picked ~= 2) or (editing ~= "x") then - debugPrint("TURN FAIL a click on a row should select it and a click on a field should start typing it") - end - forgeKey(0, SCANCODE.MAIN_2.value) - while FORGE.mode ~= "types" do - forgeKey(0, SCANCODE.TAB.value) - end - forgeRefresh() - local rml = rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById("list").inner_rml - local images = select(2, rml:gsub("<img", "")) - local swatch = select(2, rml:gsub("swatch", "")) - - debugPrint(string.format("TURN the types panel shows %d sprite thumbnails and %d swatches", images, swatch)) - if (images < 2) or (swatch < 1) then - debugPrint("TURN FAIL the sprite types want images and the fire wants a swatch") - end - debugPrint("TURN RESULT done") - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 14 == 0 and stage < 5 then - stage = stage + 1 - step() - end - forgeDraw(nil, nil) - colorForeground(255, 255, 255, 255) - fontPrint(210, 8, "2D turn handle, stage " .. stage) - if frames == 20 or frames == 62 then - singeScreenshot() - end - if frames > 80 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene83.singe b/testScripts/scene83.singe deleted file mode 100644 index 2bcc15487..000000000 --- a/testScripts/scene83.singe +++ /dev/null @@ -1,99 +0,0 @@ --- 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.forge). -TUTORIAL = { number = 1, name = "01-tenMinutes", tag = "TUTORIAL1" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local built = nil -local game = nil - - -local function step() - if stage == 1 then - -- The chooser, as Forge opens. - FORGE.chooser.manual = forgeChooserManual() - forgeChooserScan() - tutorialShot("chooser") - - elseif stage == 2 then - -- New game is the last entry; ENTER opens it. - FORGE.chooser.at = #FORGE.chooser.files - forgeChooserKey(SCANCODE.RETURN.value) - if FORGE.game == nil then - debugPrint(TUTORIAL.tag .. " FAIL New game did not open") - 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 .. ".forge") - forgeClose() - tutorialBegin(nil) - tutorialShot("entities") - - elseif stage == 3 then - panel("types") - tutorialShot("types") - elseif stage == 4 then - panel("rules") - tutorialShot("rules") - - elseif stage == 5 then - -- Back to the entities, the hero selected by a press on it, and its x typed. - panel("entities") - selectEntity("hero") - form("x", 200) - tutorialShot("moved") - if forgeRoom().entities[FORGE.selected].x ~= 200 then - debugPrint(TUTORIAL.tag .. " FAIL the hero's x did not take") - end - - elseif stage == 6 then - -- The hero's colour and speed, in the types panel. - panel("types") - FORGE.typeName = "hero" - forgeRefresh() - form("look.r", 90) - form("look.b", 240) - form("platformer.speed", 260) - tutorialShot("recoloured") - - elseif stage == 7 then - key(SCANCODE.S) - tutorialShot("saved") - built = tutorialEnd() - -- The built game installs its own callbacks; the scene keeps its frame loop and calls - -- the game's from it. - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.RIGHT.value) - - elseif stage == 9 then - tutorialShot("playing") - elseif stage == 10 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 10 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - if FORGE.game == nil then - forgeChooserDraw() - else - forgeDraw(nil, nil) - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene84.singe b/testScripts/scene84.singe deleted file mode 100644 index 1bf70f7ff..000000000 --- a/testScripts/scene84.singe +++ /dev/null @@ -1,186 +0,0 @@ --- Forge tutorial 2, "A platformer": types from the kit, entities dragged into place, rules on --- the enter event, and a second room reached through a door. Continues from tutorial 1. -TUTORIAL = { number = 2, name = "02-platformer", tag = "TUTORIAL2" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil - -tutorialBegin("01-tenMinutes") - - --- Lights a type in the types panel with the arrow keys, as a reader would. -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - --- A type from the kit: A adds one, and the form names it, gives it a sprite from the kit, and --- makes it a trigger. -local function kitType(name, file, anchor) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", name) - form("look", "sprite") - form("look.file", file) - if anchor then - form("look.anchor", anchor) - end - form("behaviours", "trigger") -end - - --- An entity of the lit type, added and dragged to a spot. -local function place(x, y) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - local entry = forgeRoom().entities[FORGE.selected] - - forgePress(entry.x, entry.y) - forgeDrag(x, y) - forgeRelease() - debugPrint(string.format("%s placed %s at %d, %d (held was %s)", TUTORIAL.tag, entry.id, entry.x, entry.y, tostring(FORGE.selected))) -end - - -local function step() - if stage == 1 then - kitType("coin", "coin.png") - tutorialShot("coinType") - - elseif stage == 2 then - place(300, 392) - key(SCANCODE.D) - forgePress(320, 412) - forgeDrag(420, 392) - forgeRelease() - key(SCANCODE.D) - forgePress(440, 412) - forgeDrag(560, 300) - forgeRelease() - tutorialShot("coins") - - elseif stage == 3 then - kitType("spike", "spike.png", "feet") - place(640, 420) - kitType("door", "door.png", "feet") - place(700, 420) - tutorialShot("placed") - - elseif stage == 4 then - -- The first rule: a coin is taken. - key(SCANCODE.MAIN_3) - key(SCANCODE.N) - form("note", "Take a coin") - key(SCANCODE.E) - pick("enter") - form("a", "coin") - form("b", "hero") - key(SCANCODE.T) - pick("addScore") - form("amount", 10) - key(SCANCODE.T) - pick("destroy") - key(SCANCODE.T) - pick("playSound") - form("file", "coin.wav") - tutorialShot("rule") - - elseif stage == 5 then - key(SCANCODE.N) - form("note", "Spikes hurt") - key(SCANCODE.E) - pick("enter") - form("a", "spike") - form("b", "hero") - key(SCANCODE.T) - pick("moveTo") - form("entity", "hero") - form("x", 200) - form("y", 380) - key(SCANCODE.T) - pick("playSound") - form("file", "hit.wav") - key(SCANCODE.N) - form("note", "Through the door") - key(SCANCODE.E) - pick("enter") - form("a", "door") - form("b", "hero") - key(SCANCODE.T) - pick("goTo") - form("room", "cave") - form("entity", "hero") - form("x", 60) - form("y", 380) - tutorialShot("rules") - - elseif stage == 6 then - -- The second room: ground, a coin, and the readout. - key(SCANCODE.MAIN_1) - key(SCANCODE.N) - form("name", "cave") - key(SCANCODE.MAIN_2) - lightType("ground") - place(360, 440) - key(SCANCODE.MAIN_2) - lightType("readout") - place(12, 12) - key(SCANCODE.MAIN_2) - lightType("coin") - place(400, 392) - tutorialShot("cave") - - elseif stage == 7 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.RIGHT.value) - - elseif stage == 10 then - tutorialShot("playing") - elseif stage == 12 then - local coins = authorCount and authorCount("coin") or -1 - - debugPrint(TUTORIAL.tag .. " after running right the score is " .. tostring(AUTHOR_VARS and AUTHOR_VARS.score) .. " and " .. tostring(coins) .. " coins remain") - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 12 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene85.singe b/testScripts/scene85.singe deleted file mode 100644 index f47825133..000000000 --- a/testScripts/scene85.singe +++ /dev/null @@ -1,130 +0,0 @@ --- Forge tutorial 3, "Sprites and sound": the hero from the kit's sheet, mirrored as it turns, --- coins that spin, a jump with a sound on the press, and music. Continues from tutorial 2. -TUTORIAL = { number = 3, name = "03-sprites", tag = "TUTORIAL3" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil - -tutorialBegin("02-platformer") - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - --- Walks the rules with the arrows to a rule, and to one of its parts. -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function step() - if stage == 1 then - -- The hero from the sheet, with frames stepped by state. - key(SCANCODE.MAIN_2) - lightType("hero") - form("look", "sprite") - form("look.file", "hero.png") - form("look.frames", 8) - form("behaviours", "platformer, frames") - form("frames.fps", 10) - form("frames.states", "idle=1-1, walk=2-5, jump=6-6, fall=7-7") - tutorialShot("heroType") - - elseif stage == 2 then - lightType("coin") - form("behaviours", "trigger, spin") - form("spin.rate", 180) - tutorialShot("coinSpin") - - elseif stage == 3 then - -- The jump rule: a press rather than a hold, and a sound. - key(SCANCODE.MAIN_3) - ruleGo(3, 1) - key(SCANCODE.DELETE) - key(SCANCODE.C) - pick("keyPressed") - form("key", "SPACE") - key(SCANCODE.T) - pick("playSound") - form("file", "jump.wav") - tutorialShot("jumpRule") - - elseif stage == 4 then - key(SCANCODE.N) - form("note", "Music") - key(SCANCODE.E) - pick("roomStart") - key(SCANCODE.T) - pick("playMusic") - form("file", "loop.ogg") - tutorialShot("music") - - elseif stage == 5 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.RIGHT.value) - - elseif stage == 7 then - authorKeyUp(0, SCANCODE.RIGHT.value) - authorKeyDown(0, SCANCODE.LEFT.value) - elseif stage == 8 then - tutorialShot("playing") - local hero = authorEntity("hero") - - debugPrint(string.format("%s the hero faces %s in state %s on frame %s, mirrored %s", TUTORIAL.tag, tostring(hero.vars.facing), tostring(hero.vars.state), tostring(hero.vars.frame), tostring(authorMirrored(hero)))) - if (hero.vars.facing ~= "left") or not authorMirrored(hero) then - debugPrint(TUTORIAL.tag .. " FAIL the hero should face left, mirrored") - end - elseif stage == 9 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 9 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene86.singe b/testScripts/scene86.singe deleted file mode 100644 index dcc7261f6..000000000 --- a/testScripts/scene86.singe +++ /dev/null @@ -1,232 +0,0 @@ --- Forge tutorial 4, "A shoot-em-up": a new game with no gravity, a ship on the keys, shots, --- rocks from spawners, health and death, lives, and game over. Starts from New game. -TUTORIAL = { number = 4, name = "04-shmup", tag = "TUTORIAL4" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function kitType(name, file) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", name) - form("look", "sprite") - form("look.file", file) -end - - -local function place(x, y) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - local entry = forgeRoom().entities[FORGE.selected] - - forgePress(entry.x, entry.y) - forgeDrag(x, y) - forgeRelease() -end - - --- A rule from scratch: N, its note, E and its event, then the filter fields. -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - --- A part: C or T, its name, and its fields. -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - -local function step() - if stage == 1 then - -- Clear the starter: the hero and the ground go, types and all, and so do their rules. - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - -- The game itself: no world, so no gravity; lives to lose. - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "Rocks") - form("vars", "score=0, lives=3") - form("layers", "overlay") - tutorialShot("cleared") - - elseif stage == 2 then - kitType("ship", "ship.png") - form("vars", "health=1") - form("behaviours", "keys, mover, shooter, health, sound") - form("keys.left", "LEFT") - form("keys.right", "RIGHT") - form("keys.up", "UP") - form("keys.down", "DOWN") - form("keys.fire", "SPACE") - form("mover.speed", 260) - form("mover.clamp", "true") - form("shooter.spawns", "shot") - form("shooter.rate", 0.15) - form("shooter.offsetY", -14) - form("health.max", 1) - form("health.keep", "true") - form("sound.hit", "hit.wav") - tutorialShot("ship") - - elseif stage == 3 then - kitType("shot", "shot.png") - form("behaviours", "projectile, sound") - form("projectile.vy", -520) - form("projectile.life", 3) - form("sound.spawn", "shot.wav") - kitType("rock", "rock.png") - form("vars", "health=2") - form("behaviours", "drift, health, sound") - form("drift.vy", 90) - form("health.max", 2) - form("sound.death", "hit.wav") - key(SCANCODE.A) - form("name", "hive") - form("look", "none") - form("behaviours", "spawner") - form("spawner.spawns", "rock") - form("spawner.every", 0.6) - form("spawner.max", 6) - form("spawner.total", 12) - tutorialShot("types") - - elseif stage == 4 then - lightType("ship") - place(360, 420) - key(SCANCODE.MAIN_2) - lightType("hive") - place(360, 20) - key(SCANCODE.D) - forgePress(380, 40) - forgeDrag(560, 20) - forgeRelease() - tutorialShot("placed") - - elseif stage == 5 then - key(SCANCODE.MAIN_3) - rule("A shot lands on a rock", "collision", { { "a", "shot" }, { "b", "rock" } }) - part(SCANCODE.T, "destroy", {}) - part(SCANCODE.T, "damage", { { "entity", "other" }, { "amount", 1 } }) - part(SCANCODE.T, "addScore", { { "amount", 10 } }) - rule("A rock dies", "death", { { "type", "rock" } }) - part(SCANCODE.T, "emit", { { "count", 24 }, { "r", 255 }, { "g", 120 }, { "b", 60 } }) - part(SCANCODE.T, "addScore", { { "amount", 50 } }) - tutorialShot("hitRule") - - elseif stage == 6 then - rule("A rock rams the ship", "collision", { { "a", "rock" }, { "b", "ship" } }) - part(SCANCODE.T, "destroy", {}) - part(SCANCODE.T, "damage", { { "entity", "other" }, { "amount", 1 } }) - rule("The ship is lost", "death", { { "type", "ship" } }) - part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) - clear("entity") - part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 60 }, { "b", 60 }, { "seconds", 0.3 } }) - part(SCANCODE.T, "setVar", { { "name", "health" }, { "value", "1" } }) - part(SCANCODE.T, "moveTo", { { "x", 360 }, { "y", 420 } }) - rule("Out of lives", "frame", {}) - part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) - part(SCANCODE.T, "gameOver", {}) - rule("A rock slips past the bottom", "frame", { { "each", "rock" } }) - part(SCANCODE.C, "test", { { "expr", "self.y > 500" } }) - part(SCANCODE.T, "destroy", {}) - -- The readout, kept from the starter, says the lives too. - ruleGo(1, 1) - form("text", '"score " .. score .. " lives " .. lives') - tutorialShot("rules") - - elseif stage == 7 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.SPACE.value) - - elseif stage == 19 then - tutorialShot("playing") - debugPrint(string.format("%s after firing a while: score %s, lives %s, %d rocks, %d shots", TUTORIAL.tag, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives), authorCount("rock"), authorCount("shot"))) - elseif stage == 20 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 20 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene87.singe b/testScripts/scene87.singe deleted file mode 100644 index 29ef1d274..000000000 --- a/testScripts/scene87.singe +++ /dev/null @@ -1,208 +0,0 @@ --- Forge tutorial 5, "A quick-time event": the kit's clip as the disc, two windows that want a --- key, a hit and a miss for each, lives, and the clip looping. Starts from New game. -TUTORIAL = { number = 5, name = "05-qte", tag = "TUTORIAL5" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil -local pressed = false -local shot = false - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function place(x, y) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - local entry = forgeRoom().entities[FORGE.selected] - - forgePress(entry.x, entry.y) - forgeDrag(x, y) - forgeRelease() -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - --- One window: the prompt while it is open, the hit once, and the miss when it closes unanswered. -local function window(name, from, to, keyName, prompt) - rule("Window " .. name .. " is open", "frame", {}) - part(SCANCODE.C, "discBetween", { { "from", from }, { "to", to } }) - part(SCANCODE.T, "setText", { { "entity", "prompt" }, { "text", '"' .. prompt .. '"' } }) - rule("Window " .. name .. " answered in time", "frame", {}) - part(SCANCODE.C, "discBetween", { { "from", from }, { "to", to } }) - part(SCANCODE.C, "keyPressed", { { "key", keyName } }) - part(SCANCODE.C, "once", { { "tag", name } }) - part(SCANCODE.T, "setVar", { { "name", "hit" .. name }, { "value", "true" } }) - clear("entity") - part(SCANCODE.T, "addScore", { { "amount", 250 } }) - part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '"HIT"' } }) - rule("Window " .. name .. " closed unanswered", "frameReached", { { "frame", to } }) - part(SCANCODE.C, "test", { { "expr", "not hit" .. name } }) - part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) - clear("entity") - part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '"MISS"' } }) - part(SCANCODE.T, "setText", { { "entity", "prompt" }, { "text", '""' } }) -end - - -local function step() - if stage == 1 then - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "Moments") - form("vars", "score=0, lives=3") - form("layers", "disc, overlay") - form("disc.file", "clip.mkv") - tutorialShot("disc") - - elseif stage == 2 then - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", "prompt") - form("look", "text") - form("look.r", 255) - form("look.g", 220) - form("look.b", 80) - place(250, 60) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", "verdict") - form("look", "text") - form("look.r", 160) - form("look.g", 255) - form("look.b", 180) - place(250, 100) - tutorialShot("prompts") - - elseif stage == 3 then - key(SCANCODE.MAIN_3) - window("one", 72, 120, "SPACE", "PRESS!") - tutorialShot("window") - elseif stage == 4 then - window("two", 150, 200, "UP", "UP!") - rule("The clip runs out: round again", "frameReached", { { "frame", 280 } }) - part(SCANCODE.T, "setVar", { { "name", "hitone" }, { "value", "false" } }) - clear("entity") - part(SCANCODE.T, "setVar", { { "name", "hittwo" }, { "value", "false" } }) - clear("entity") - part(SCANCODE.T, "setText", { { "entity", "verdict" }, { "text", '""' } }) - part(SCANCODE.T, "discTo", { { "frame", 1 } }) - rule("Out of lives", "frame", {}) - part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) - part(SCANCODE.T, "gameOver", {}) - ruleGo(1, 1) - form("text", '"score " .. score .. " lives " .. lives') - tutorialShot("rules") - - elseif stage == 5 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 5 then - stage = stage + 1 - step() - end - if game ~= nil then - local frame = discGetFrame() - - if (frame >= 84) and not pressed then - pressed = true - authorKeyDown(0, SCANCODE.SPACE.value) - end - if (frame >= 100) and not shot then - shot = true - tutorialShot("playing") - end - if frame >= 210 then - debugPrint(string.format("%s at frame %d the score is %s with %s lives", TUTORIAL.tag, frame, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives))) - if (AUTHOR_VARS.score ~= 250) or (AUTHOR_VARS.lives ~= 2) then - debugPrint(TUTORIAL.tag .. " FAIL window one should be hit and window two missed") - end - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene88.singe b/testScripts/scene88.singe deleted file mode 100644 index 4dcdb6d28..000000000 --- a/testScripts/scene88.singe +++ /dev/null @@ -1,254 +0,0 @@ --- Forge tutorial 6, "A light gun game": the kit's clip as the disc, a gun on the pointer, hit --- boxes drawn on the timeline where the target and the hand are, ammo and a reload off the --- picture, and a score. Starts from New game. -TUTORIAL = { number = 6, name = "06-gun", tag = "TUTORIAL6" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil -local fired = {} -local shot = false - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function place(x, y) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - local entry = forgeRoom().entities[FORGE.selected] - - forgePress(entry.x, entry.y) - forgeDrag(x, y) - forgeRelease() -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - --- The cursor to a frame, by the leap and step keys, as a reader would. -local function cursorTo(frame) - while FORGE.cursor + 10 <= frame do - key(SCANCODE.RIGHTBRACKET) - end - while FORGE.cursor < frame do - key(SCANCODE.PERIOD) - end - while FORGE.cursor > frame do - key(SCANCODE.COMMA) - end -end - - --- A key on the selected track at a frame, its box typed. -local function keyAt(frame, x, y, w, h) - cursorTo(frame) - key(SCANCODE.K) - form("x", x) - form("y", y) - form("w", w) - form("h", h) -end - - -local function step() - if stage == 1 then - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "Draw") - form("vars", "score=0, misses=0") - form("layers", "disc, overlay") - form("disc.file", "clip.mkv") - - elseif stage == 2 then - -- The gun, and the two things it can hit. - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", "gun") - form("look", "none") - form("vars", "ammo=6") - form("behaviours", "gun") - form("gun.player", 1) - form("gun.ammo", 6) - form("gun.reload", "offscreen") - key(SCANCODE.A) - form("name", "target") - form("look", "none") - form("vars", "health=1") - form("behaviours", "hitbox, health") - form("hitbox.track", "target") - form("health.max", 1) - key(SCANCODE.A) - form("name", "hand") - form("look", "none") - form("behaviours", "hitbox") - form("hitbox.track", "hand") - tutorialShot("types") - - elseif stage == 3 then - lightType("gun") - place(30, 450) - key(SCANCODE.MAIN_2) - lightType("target") - place(450, 210) - key(SCANCODE.MAIN_2) - lightType("hand") - place(600, 210) - - elseif stage == 4 then - -- The tracks: where the target is, frame by frame, and where the hand is. - key(SCANCODE.MAIN_4) - key(SCANCODE.N) - form("name", "target") - keyAt(150, 405, 165, 90, 90) - tutorialShot("firstKey") - - elseif stage == 5 then - keyAt(210, 405, 165, 90, 90) - key(SCANCODE.N) - form("name", "hand") - keyAt(220, 480, 157, 240, 105) - keyAt(260, 480, 157, 240, 105) - cursorTo(180) - tutorialShot("tracks") - - elseif stage == 6 then - key(SCANCODE.MAIN_3) - rule("The target is hit", "hit", { { "type", "target" } }) - part(SCANCODE.T, "addScore", { { "amount", 100 } }) - part(SCANCODE.T, "damage", { { "amount", 1 } }) - part(SCANCODE.T, "playSound", { { "file", "hit.wav" } }) - rule("The hand is hit", "hit", { { "type", "hand" } }) - part(SCANCODE.T, "addScore", { { "amount", -200 } }) - part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 40 }, { "b", 40 }, { "seconds", 0.3 } }) - rule("A shot at nothing", "miss", {}) - part(SCANCODE.C, "test", { { "expr", "not event.offscreen" } }) - part(SCANCODE.T, "addVar", { { "name", "misses" }, { "amount", 1 } }) - clear("entity") - part(SCANCODE.T, "playSound", { { "file", "shot.wav" } }) - rule("Draw!", "frameReached", { { "frame", 150 } }) - part(SCANCODE.T, "say", { { "text", '"DRAW!"' }, { "seconds", 1 } }) - ruleGo(1, 1) - form("text", '"score " .. score .. " ammo " .. gun.ammo .. " misses " .. misses') - tutorialShot("rules") - - elseif stage == 7 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - end -end - - --- A shot from the pointer at a frame of the disc, once. -local function fireAt(frame, x, y, now) - if (now >= frame) and not fired[frame] then - fired[frame] = true - authorSetPointer(1, x, y) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 7 then - stage = stage + 1 - step() - end - if game ~= nil then - local frame = discGetFrame() - - fireAt(170, 450, 210, frame) -- the target - fireAt(190, 100, 100, frame) -- the wall - fireAt(240, 620, 210, frame) -- the hand - if (frame >= 175) and not shot then - shot = true - tutorialShot("playing") - end - if frame >= 270 then - debugPrint(string.format("%s at frame %d: score %s, misses %s, ammo %s", TUTORIAL.tag, frame, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.misses), tostring(authorEntity("gun").vars.ammo))) - if (AUTHOR_VARS.score ~= -100) or (AUTHOR_VARS.misses ~= 1) then - debugPrint(TUTORIAL.tag .. " FAIL a hit on the target, a miss on the wall, and a hit on the hand should score -100 with one miss") - end - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene89.singe b/testScripts/scene89.singe deleted file mode 100644 index 6ccfe9615..000000000 --- a/testScripts/scene89.singe +++ /dev/null @@ -1,353 +0,0 @@ --- Forge tutorial 7, "A point-and-click adventure": a painted yard, a walk area drawn on it, --- a walker sent by clicks, hotspots with verbs, an inventory, a guard with a dialogue, and a --- door to a second painted room. Starts from New game. -TUTORIAL = { number = 7, name = "07-adventure", tag = "TUTORIAL7" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil -local talked = false - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function kitType(name, file, anchor) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", name) - form("look", "sprite") - form("look.file", file) - if anchor then - form("look.anchor", anchor) - end -end - - -local function place(x, y) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - local entry = forgeRoom().entities[FORGE.selected] - - forgePress(entry.x, entry.y) - forgeDrag(x, y) - forgeRelease() -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - --- Up or down the dialogue outline to a row: a dialogue by name, a node by name, or a choice by --- its text, as a reader would with the arrows. -local function dialogueGo(level, name) - local guard = 0 - - local function here() - for _, row in ipairs(forgeDialogueRows()) do - local selected = (row.dialogue == FORGE.dialogue) and (row.node == FORGE.dnode) and ((row.choice or 0) == (FORGE.dchoice or 0)) and ((row.act or 0) == (FORGE.dact or 0)) - - if selected and (row.level == level) then - if (level == "dialogue") and (row.dialogue == name) then - return true - elseif (level == "node") and (row.node == name) then - return true - elseif (level == "choice") and row.label:find(name, 1, true) then - return true - end - end - end - - return false - end - - -- Straight to the top, then down until the row is the one. - while not here() and (guard < 40) do - key(SCANCODE.UP) - guard = guard + 1 - if FORGE.dnode == nil and level == "dialogue" and FORGE.dialogue == name then - break - end - end - guard = 0 - while not here() and (guard < 40) do - key(SCANCODE.DOWN) - guard = guard + 1 - end - if not here() then - debugPrint(string.format("%s FAIL the outline never reached %s %s", TUTORIAL.tag, level, name)) - end -end - - --- A walk area: V, a press on each corner, V again. --- The panel steps aside on its own while the corners are pressed. -local function walkArea(corners) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - key(SCANCODE.V) - for _, corner in ipairs(corners) do - forgePress(corner[1], corner[2]) - end - key(SCANCODE.V) -end - - -local function step() - if stage == 1 then - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "The Yard") - form("vars", "score=0, allowed=false") - form("verbs", "walk, look, take, use, open, talk") - form("layers", "overlay") - form("name", "yard") - form("depthSort", "true") - form("scaleBy", "300:0.6, 460:1.0") - - elseif stage == 2 then - -- The painting, the walk area on its floor, and the walker. - kitType("backdrop", "yard.png") - place(360, 240) - walkArea({ { 60, 320 }, { 660, 320 }, { 700, 460 }, { 40, 460 } }) - forgePanelShow(false) - tutorialShot("walkArea") - forgePanelShow(true) - kitType("hero", "hero.png", "feet") - form("look.frames", 8) - form("behaviours", "walker, frames") - form("walker.speed", 160) - form("frames.fps", 10) - form("frames.states", "idle=1-1, walk=2-5") - place(150, 440) - - elseif stage == 3 then - -- The things a verb can be used on. - kitType("door", "door.png", "feet") - form("behaviours", "hotspot") - form("hotspot.name", "door") - form("hotspot.walkX", 590) - form("hotspot.walkY", 330) - place(590, 300) - kitType("key", "key.png") - form("behaviours", "hotspot") - form("hotspot.name", "key") - form("hotspot.walkX", 200) - form("hotspot.walkY", 400) - place(200, 380) - kitType("guard", "hero.png", "feet") - form("look.frames", 8) - form("look.faces", "left") - form("behaviours", "hotspot") - form("hotspot.name", "guard") - form("hotspot.walkX", 480) - form("hotspot.walkY", 360) - place(520, 340) - tutorialShot("hotspots") - - elseif stage == 4 then - -- The guard's dialogue. - key(SCANCODE.MAIN_5) - key(SCANCODE.A) - form("name", "guard") - key(SCANCODE.DOWN) - form("who", "Guard") - form("text", "Nobody passes.") - key(SCANCODE.A) - form("text", "Why not?") - form("next", "why") - key(SCANCODE.A) - form("text", "Fine.") - dialogueGo("dialogue", "guard") - key(SCANCODE.A) - form("name", "why") - form("who", "Guard") - form("text", "The door is locked, and I have no key.") - key(SCANCODE.A) - form("text", "I have a key.") - form("when", 'has("key")') - form("next", "ok") - key(SCANCODE.T) - pick("setVar") - form("name", "allowed") - form("value", "true") - clear("entity") - dialogueGo("node", "why") - key(SCANCODE.A) - form("text", "I see.") - dialogueGo("dialogue", "guard") - key(SCANCODE.A) - form("name", "ok") - form("who", "Guard") - form("text", "Then go ahead.") - tutorialShot("dialogue") - - elseif stage == 5 then - key(SCANCODE.MAIN_3) - rule("A click on the floor walks there", "pressed", { { "switch", "SWITCH_BUTTON3" }, { "interrupt", "true" } }) - part(SCANCODE.T, "walkToPointer", { { "entity", "hero" }, { "player", 1 } }) - rule("Look at anything", "verb", { { "verb", "look" } }) - part(SCANCODE.T, "say", { { "text", '"It is a " .. event.target .. "."' }, { "seconds", 1 } }) - rule("Take the key", "verb", { { "verb", "take" }, { "target", "key" } }) - part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) - part(SCANCODE.T, "give", { { "item", "key" } }) - part(SCANCODE.T, "addScore", { { "amount", 1 } }) - part(SCANCODE.T, "destroy", {}) - rule("Talk to the guard", "verb", { { "verb", "talk" }, { "target", "guard" } }) - part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) - part(SCANCODE.T, "face", { { "entity", "hero" }, { "target", "self" } }) - part(SCANCODE.T, "talk", { { "dialogue", "guard" } }) - tutorialShot("verbRules") - - elseif stage == 6 then - rule("The door, while the guard objects", "verb", { { "verb", "open" }, { "target", "door" } }) - part(SCANCODE.C, "test", { { "expr", "not allowed" } }) - part(SCANCODE.T, "say", { { "text", '"The guard shakes his head."' }, { "seconds", 1 } }) - rule("The door opens", "verb", { { "verb", "open" }, { "target", "door" }, { "controls", "false" } }) - part(SCANCODE.C, "test", { { "expr", "allowed" } }) - part(SCANCODE.T, "walkToHotspot", { { "entity", "hero" } }) - part(SCANCODE.T, "playSound", { { "file", "door.wav" } }) - part(SCANCODE.T, "fade", { { "seconds", 0.5 }, { "out", "true" } }) - part(SCANCODE.T, "goTo", { { "room", "hall" }, { "entity", "hero" }, { "x", 120 }, { "y", 420 } }) - part(SCANCODE.T, "fade", { { "seconds", 0.5 }, { "out", "false" } }) - part(SCANCODE.T, "addScore", { { "amount", 1 } }) - rule("Anything else", "verb", {}) - part(SCANCODE.T, "say", { { "text", '"That does not work."' }, { "seconds", 1 } }) - rule("The verb keys", "pressed", { { "key", "L" } }) - part(SCANCODE.T, "setVerb", { { "verb", "look" } }) - rule("Take", "pressed", { { "key", "T" } }) - part(SCANCODE.T, "setVerb", { { "verb", "take" } }) - rule("Open", "pressed", { { "key", "O" } }) - part(SCANCODE.T, "setVerb", { { "verb", "open" } }) - rule("Talk", "pressed", { { "key", "K" } }) - part(SCANCODE.T, "setVerb", { { "verb", "talk" } }) - ruleGo(1, 1) - form("text", '(sentence or "") .. " score " .. score') - - elseif stage == 7 then - -- The hall: a second painting, its floor, and the readout. - key(SCANCODE.MAIN_1) - key(SCANCODE.N) - form("name", "hall") - form("depthSort", "true") - key(SCANCODE.MAIN_2) - lightType("backdrop") - place(360, 240) - walkArea({ { 40, 300 }, { 680, 300 }, { 700, 460 }, { 40, 460 } }) - key(SCANCODE.MAIN_1) - selectEntity("backdrop") - form("type", "backdrop") - key(SCANCODE.MAIN_2) - lightType("readout") - place(12, 12) - tutorialShot("hall") - - elseif stage == 8 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - - elseif stage == 10 then - -- Take the key: the take verb on the key hotspot. - authorSetVerb("take") - authorSetPointer(1, 200, 380) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - elseif stage == 16 then - tutorialShot("playing") - local hx, hy = authorPosition(authorEntity("hero")) - - debugPrint(string.format("%s the key is %s, the score %s, the hero at %.0f, %.0f", TUTORIAL.tag, authorHas("key") and "taken" or "still there", tostring(AUTHOR_VARS.score), hx, hy)) - if not authorHas("key") then - debugPrint(TUTORIAL.tag .. " FAIL the take verb on the key should walk there and take it") - end - elseif stage == 17 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 17 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene90.singe b/testScripts/scene90.singe deleted file mode 100644 index 6a59bcfc6..000000000 --- a/testScripts/scene90.singe +++ /dev/null @@ -1,246 +0,0 @@ --- Forge tutorial 8, "Into 3D": a scene3d layer, meshes for floors and a ledge, the kit's fox on --- the character controller with a camera that follows, the 3D viewport, and a prize that is a --- trigger. Starts from New game. -TUTORIAL = { number = 8, name = "08-scene", tag = "TUTORIAL8" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - --- A type with a mesh look: a box or a sphere of a size and a colour. -local function meshType(name, shape, w, h, d, r, g, b) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", name) - form("look", "mesh") - form("look.shape", shape) - form("look.w", w) - if h then - form("look.h", h) - end - if d then - form("look.d", d) - end - form("look.r", r) - form("look.g", g) - form("look.b", b) -end - - --- An entity of the lit type, added and its place typed: in a scene, positions are world units. -local function placeAt(id, x, y, z) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - form("id", id) - form("x", x) - form("y", y) - form("z", z) -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - -local function step() - if stage == 1 then - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "Field") - form("layers", "scene3d, overlay") - form("scene3d.fov", 55) - tutorialShot("scene") - - elseif stage == 2 then - meshType("floor", "box", 30, 0.4, 12, 90, 110, 90) - form("behaviours", "solid") - meshType("ledge", "box", 4, 0.4, 4, 110, 90, 70) - form("behaviours", "solid") - meshType("prize", "sphere", 0.6, nil, nil, 255, 200, 60) - form("behaviours", "trigger") - tutorialShot("meshes") - - elseif stage == 3 then - key(SCANCODE.A) - form("name", "hero") - form("look", "model") - form("look.file", "fox.glb") - form("look.scale", 0.008) - form("look.w", 0.6) - form("look.h", 0.9) - form("look.d", 0.6) - form("behaviours", "keys, character, animator") - form("keys.left", "LEFT") - form("keys.right", "RIGHT") - form("keys.up", "UP") - form("keys.down", "DOWN") - form("character.speed", 4) - form("character.jump", 6.5) - form("character.radius", 0.3) - form("character.height", 0.9) - form("animator.idle", "Survey") - form("animator.walk", "Walk") - key(SCANCODE.A) - form("name", "camera") - form("look", "none") - form("behaviours", "camera") - form("camera.mode", "follow") - form("camera.target", "hero") - form("camera.x", 0) - form("camera.y", 3) - form("camera.z", 7) - form("camera.lookY", 0.6) - form("camera.lag", 0.2) - tutorialShot("hero") - - elseif stage == 4 then - lightType("floor") - placeAt("floor", 0, -0.2, 0) - key(SCANCODE.MAIN_2) - lightType("ledge") - placeAt("ledge", 6, 1.2, 0) - key(SCANCODE.MAIN_2) - lightType("prize") - placeAt("prize", 6, 2, 0) - key(SCANCODE.MAIN_2) - lightType("hero") - placeAt("hero", -4, 0.5, 0) - key(SCANCODE.MAIN_2) - lightType("camera") - placeAt("camera", -4, 3, 7) - -- The viewport orbits what is selected: a few steps round, for the picture. - selectEntity("hero") - key(SCANCODE.L) - key(SCANCODE.L) - key(SCANCODE.Y) - tutorialShot("viewport") - - elseif stage == 5 then - key(SCANCODE.MAIN_3) - rule("Walking is a state the animator plays", "frame", { { "each", "hero" } }) - part(SCANCODE.C, "test", { { "expr", "self.dx != 0 or self.dy != 0" } }) - part(SCANCODE.T, "setState", { { "state", "walk" } }) - rule("Standing still", "frame", { { "each", "hero" } }) - part(SCANCODE.C, "test", { { "expr", "self.dx == 0 and self.dy == 0" } }) - part(SCANCODE.T, "setState", { { "state", "idle" } }) - rule("Jump, with ground underfoot", "frame", {}) - part(SCANCODE.C, "keyPressed", { { "key", "SPACE" } }) - part(SCANCODE.C, "onGround", { { "entity", "hero" } }) - part(SCANCODE.T, "jump", { { "entity", "hero" } }) - rule("The prize is taken", "enter", { { "a", "prize" }, { "b", "hero" } }) - part(SCANCODE.T, "destroy", {}) - part(SCANCODE.T, "addScore", { { "amount", 100 } }) - part(SCANCODE.T, "playSound", { { "file", "coin.wav" } }) - rule("Fallen off the world", "frame", {}) - part(SCANCODE.C, "test", { { "expr", "hero.y < -5" } }) - part(SCANCODE.T, "moveTo", { { "entity", "hero" }, { "x", -4 }, { "y", 0.5 }, { "z", 0 } }) - tutorialShot("rules") - - elseif stage == 6 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.RIGHT.value) - - elseif stage == 14 then - tutorialShot("playing") - local hero = authorEntity("hero") - local x, y, z = authorPosition(hero) - - debugPrint(string.format("%s the fox is at %.1f, %.1f, %.1f in state %s", TUTORIAL.tag, x, y, z, tostring(hero.vars.state))) - if (x < -3) or (hero.vars.state ~= "walk") then - debugPrint(TUTORIAL.tag .. " FAIL holding RIGHT should walk the fox along") - end - elseif stage == 15 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 15 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene91.singe b/testScripts/scene91.singe deleted file mode 100644 index ab9e2f791..000000000 --- a/testScripts/scene91.singe +++ /dev/null @@ -1,308 +0,0 @@ --- Forge tutorial 9, "A rail shooter": a camera on a rail with stops, a painted backdrop and an --- occluder pillar that is only in the picture, zombies that spawn at each stop and walk to the --- camera, a gun on the pointer, ragdolls, and the rail moving on. Starts from New game. -TUTORIAL = { number = 9, name = "09-rail", tag = "TUTORIAL9" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil -local fired = 0 -local shot = false - -tutorialBegin(nil) - - -local function lightType(name) - local names = {} - local from, to = 1, 1 - - for typeName in pairs(FORGE.game.types) do - names[#names + 1] = typeName - end - table.sort(names) - for at, typeName in ipairs(names) do - if typeName == FORGE.typeName then - from = at - end - if typeName == name then - to = at - end - end - for _ = 1, math.abs(to - from) do - key((to > from) and SCANCODE.DOWN or SCANCODE.UP) - end -end - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function meshType(name, w, h, d, r, g, b) - key(SCANCODE.MAIN_2) - key(SCANCODE.A) - form("name", name) - form("look", "mesh") - form("look.shape", "box") - form("look.w", w) - form("look.h", h) - form("look.d", d) - if r then - form("look.r", r) - form("look.g", g) - form("look.b", b) - end -end - - -local function placeAt(id, x, y, z) - key(SCANCODE.MAIN_1) - key(SCANCODE.A) - form("id", id) - form("x", x) - form("y", y) - form("z", z) -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - --- A rail point: K puts one where the editor camera stands, and the form moves it. -local function railPoint(at, x, y, z, lookX, lookY, lookZ, stop) - key(SCANCODE.K) - form("at", at) - form("x", x) - form("y", y) - form("z", z) - form("lookX", lookX) - form("lookY", lookY) - form("lookZ", lookZ) - if stop then - form("stop", stop) - end -end - - -local function step() - if stage == 1 then - key(SCANCODE.MAIN_1) - selectEntity("hero") - key(SCANCODE.DELETE) - selectEntity("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_2) - lightType("hero") - key(SCANCODE.DELETE) - lightType("ground") - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_3) - ruleGo(1, 0) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.DELETE) - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "The Dead Yard") - form("vars", "score=0, health=3") - form("layers", "scene3d, overlay") - form("scene3d.fov", 60) - - elseif stage == 2 then - -- The room: a floor, the yard as a painting at the back, and the pillar in it as an - -- occluder, so a zombie walks behind a pillar that is only paint. - meshType("floor", 40, 0.2, 60, 70, 75, 90) - form("behaviours", "solid") - meshType("painting", 24, 16, 0.2, 255, 255, 255) - form("look.texture", "yard.png") - form("look.unlit", "true") - meshType("pillar", 1.2, 8, 1.2) - form("look.occluder", "true") - tutorialShot("room") - - elseif stage == 3 then - key(SCANCODE.A) - form("name", "camera") - form("look", "none") - form("behaviours", "camera") - form("camera.mode", "rail") - form("camera.track", "rail") - key(SCANCODE.A) - form("name", "gun") - form("look", "none") - form("vars", "ammo=6") - form("behaviours", "gun") - form("gun.player", 1) - form("gun.ammo", 6) - form("gun.reload", "offscreen") - key(SCANCODE.A) - form("name", "zombie") - form("look", "model") - form("look.file", "fox.glb") - form("look.scale", 0.01) - form("look.w", 0.8) - form("look.h", 0.9) - form("look.d", 1.4) - form("vars", "health=2") - form("behaviours", "health, seek, animator, target, timer") - form("health.max", 2) - form("health.ragdoll", "true") - form("health.linger", 2) - form("seek.target", "camera") - form("seek.speed", 1.6) - form("seek.stopAt", 2.2) - form("animator.idle", "Survey") - form("animator.walk", "Walk") - form("animator.attack", "Run") - form("timer.name", "bite") - form("timer.after", 1.5) - form("timer.start", "false") - tutorialShot("zombie") - - elseif stage == 4 then - lightType("floor") - placeAt("floor", 0, -0.1, -20) - key(SCANCODE.MAIN_2) - lightType("painting") - placeAt("painting", 0, 6, -30) - key(SCANCODE.MAIN_2) - lightType("pillar") - placeAt("pillar", 0, 4, -22) - key(SCANCODE.MAIN_2) - lightType("camera") - placeAt("camera", 0, 1.6, 0) - key(SCANCODE.MAIN_2) - lightType("gun") - placeAt("gun", 0, 0, 0) - selectEntity("pillar") - tutorialShot("placed") - - elseif stage == 5 then - -- The rail: a track of points in time, two of them stops. - key(SCANCODE.MAIN_4) - key(SCANCODE.N) - form("name", "rail") - railPoint(0, 0, 1.6, 0, 0, 1.2, -10) - railPoint(4, 0, 1.6, -6, 0, 1.2, -16, "gate") - railPoint(10, 2, 1.6, -14, 2, 1.2, -24, "pillar") - railPoint(14, 2, 1.6, -18, 2, 1.2, -28) - tutorialShot("rail") - - elseif stage == 6 then - key(SCANCODE.MAIN_3) - rule("The gate: two come out of the dark", "stopped", { { "name", "gate" } }) - part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", -2 }, { "y", 0 }, { "z", -14 } }) - part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 2 }, { "y", 0 }, { "z", -15 } }) - rule("The pillar: three more, one from behind it", "stopped", { { "name", "pillar" } }) - part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", -1 }, { "y", 0 }, { "z", -23 } }) - part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 0 }, { "y", 0 }, { "z", -26 } }) - part(SCANCODE.T, "spawn", { { "type", "zombie" }, { "x", 4 }, { "y", 0 }, { "z", -24 } }) - rule("A zombie walks the moment it is made", "spawn", { { "type", "zombie" } }) - part(SCANCODE.T, "setState", { { "state", "walk" } }) - rule("A shot lands", "hit", { { "type", "zombie" } }) - part(SCANCODE.T, "damage", { { "amount", 1 } }) - part(SCANCODE.T, "addScore", { { "amount", 100 } }) - part(SCANCODE.T, "playSound", { { "file", "hit.wav" } }) - tutorialShot("hitRule") - - elseif stage == 7 then - rule("It reaches the camera and rears up", "arrived", { { "type", "zombie" } }) - part(SCANCODE.T, "setState", { { "state", "attack" } }) - part(SCANCODE.T, "timerStart", { { "name", "bite" }, { "after", 1.5 } }) - rule("The bite lands unless it was killed in time", "timer", { { "type", "zombie" }, { "name", "bite" } }) - part(SCANCODE.C, "inState", { { "state", "attack" } }) - part(SCANCODE.T, "addVar", { { "name", "health" }, { "amount", -1 } }) - clear("entity") - part(SCANCODE.T, "flash", { { "r", 255 }, { "g", 40 }, { "b", 40 }, { "seconds", 0.3 } }) - part(SCANCODE.T, "shake", { { "amount", 0.3 }, { "seconds", 0.4 } }) - part(SCANCODE.T, "timerStart", { { "name", "bite" }, { "after", 1.5 } }) - rule("The stop is clear: move on", "frame", {}) - part(SCANCODE.C, "waiting", { { "entity", "camera" } }) - part(SCANCODE.C, "test", { { "expr", 'count("zombie") == 0' } }) - part(SCANCODE.T, "pathNext", { { "entity", "camera" } }) - rule("Out of health", "frame", {}) - part(SCANCODE.C, "test", { { "expr", "health <= 0" } }) - part(SCANCODE.C, "once", { { "tag", "over" }, { "scope", "game" } }) - part(SCANCODE.T, "say", { { "text", '"YOU ARE DEAD"' }, { "seconds", 2 } }) - part(SCANCODE.T, "gameOver", {}) - rule("The end of the rail", "railEnd", {}) - part(SCANCODE.T, "say", { { "text", '"STAGE CLEAR"' }, { "seconds", 2 } }) - ruleGo(1, 1) - form("text", '"score " .. score .. " health " .. health .. " ammo " .. gun.ammo') - tutorialShot("rules") - - elseif stage == 8 then - key(SCANCODE.S) - local built = tutorialEnd() - local mine = onOverlayUpdate - - dofile(built) - game = onOverlayUpdate - onOverlayUpdate = mine - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 8 then - stage = stage + 1 - step() - end - if game ~= nil then - local t = authorTime() - - -- At the gate stop, shoot the first zombie through the middle of the picture. - if (t > 5) and (fired < 2) and (authorCount("zombie") > 0) then - local zombie = authorEntity and nil - local sx, sy = sceneProject(-2, 0.6, -14) - - fired = fired + 1 - authorSetPointer(1, sx, sy) - authorSwitchDown(SWITCH_BUTTON3) - authorSwitchUp(SWITCH_BUTTON3) - end - if (t > 6) and not shot then - shot = true - tutorialShot("playing") - end - if t > 8 then - debugPrint(string.format("%s after 8 seconds: score %s, health %s, %d zombies, camera %s", TUTORIAL.tag, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.health), authorCount("zombie"), authorWaiting(authorEntity("camera")) and "waiting" or "riding")) - if authorCount("zombie") == 0 then - debugPrint(TUTORIAL.tag .. " FAIL the gate stop should have spawned zombies") - end - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene92.singe b/testScripts/scene92.singe deleted file mode 100644 index 8b327c5fe..000000000 --- a/testScripts/scene92.singe +++ /dev/null @@ -1,175 +0,0 @@ --- Forge tutorial 10, "Releasing your game": the platformer with the arcade's plumbing -- lives, --- a coin slot, a continue, the bezel, the score sent to the board -- released to a folder of its --- own with everything it needs. Continues from tutorial 3. -TUTORIAL = { number = 10, name = "10-release", tag = "TUTORIAL10" } -dofile("testScripts/tutorialDrive.singe") - -local frames = 0 -local stage = 0 -local game = nil -local folder = nil - -tutorialBegin("03-sprites") - - -local function ruleGo(index, part) - local guard = 0 - - while ((FORGE.rule ~= index) or (FORGE.part ~= (part or 0))) and (guard < 60) do - local past = (FORGE.rule > index) or ((FORGE.rule == index) and (FORGE.part > (part or 0))) - - key(past and SCANCODE.UP or SCANCODE.DOWN) - guard = guard + 1 - end -end - - -local function rule(note, on, filters) - key(SCANCODE.N) - form("note", note) - key(SCANCODE.E) - pick(on) - for _, pair in ipairs(filters or {}) do - form(pair[1], pair[2]) - end -end - - -local function part(partKey, name, fields) - key(partKey) - pick(name) - for _, pair in ipairs(fields or {}) do - form(pair[1], pair[2]) - end -end - - -local function step() - if stage == 1 then - -- The game's form: a title of its own, lives and credits, and the bezel around the picture. - key(SCANCODE.MAIN_1) - forgeSelect(nil) - form("title", "Coin Run") - form("vars", "score=0, lives=3, credits=0") - form("layers", "world2d, bezel") - tutorialShot("bezel") - - elseif stage == 2 then - -- Spikes cost a life now, and a coin buys a continue. - key(SCANCODE.MAIN_3) - ruleGo(6, 0) - key(SCANCODE.DOWN) - key(SCANCODE.DOWN) - key(SCANCODE.DOWN) - part(SCANCODE.T, "addVar", { { "name", "lives" }, { "amount", -1 } }) - clear("entity") - rule("Out of lives", "frame", {}) - part(SCANCODE.C, "test", { { "expr", "lives <= 0" } }) - part(SCANCODE.C, "once", { { "tag", "over" }, { "scope", "game" } }) - part(SCANCODE.T, "submitScore", { { "board", "default" } }) - part(SCANCODE.T, "say", { { "text", '"GAME OVER"' }, { "seconds", 2 } }) - part(SCANCODE.T, "gameOver", {}) - rule("A coin", "pressed", { { "switch", "SWITCH_COIN1" } }) - part(SCANCODE.T, "credit", {}) - rule("Continue", "pressed", { { "switch", "SWITCH_START1" } }) - part(SCANCODE.C, "test", { { "expr", "credits > 0 and lives <= 0" } }) - part(SCANCODE.T, "addVar", { { "name", "credits" }, { "amount", -1 } }) - clear("entity") - part(SCANCODE.T, "setVar", { { "name", "lives" }, { "value", "3" } }) - clear("entity") - part(SCANCODE.T, "restart", {}) - ruleGo(4, 1) - form("text", '"score " .. score .. " lives " .. lives') - tutorialShot("arcadeRules") - - elseif stage == 3 then - key(SCANCODE.S) - -- X releases: the compiled game, the runtime, the description, a games.dat, and every - -- file the description names, in a folder of the game's own. - key(SCANCODE.X) - folder = forgeReleaseFolder() - tutorialShot("released") - local files = {} - - for name in lfs.dir(folder) do - if name:sub(1, 1) ~= "." then - files[#files + 1] = name - end - end - table.sort(files) - debugPrint(TUTORIAL.tag .. " released to " .. folder .. ": " .. table.concat(files, ", ")) - 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 - end - if lfs.attributes(folder .. "/Forge/kit/hero.png", "mode") ~= "file" then - debugPrint(TUTORIAL.tag .. " FAIL the release should carry the kit files it names") - end - - elseif stage == 4 then - -- Packed, as the tutorial's last step says, when the harness says where the engine is: a - -- scene cannot know its own executable, so SINGE_BIN carries it, and without it the step - -- is reported as not tried rather than failed. - local bin = os.getenv("SINGE_BIN") - - if bin then - 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")) - -- What the packer said is the measure: a .game is a database, and the engine's own - -- file lookups treat one as a game rather than as bytes, so its size is not readable - -- from inside a running script. - local log = io.open(TUTORIAL_OUT .. "pack.log", "r") - local size = 0 - - if log then - for line in log:lines() do - debugPrint(TUTORIAL.tag .. " pack: " .. line) - size = tonumber(line:match("Packed %d+ files %((%d+) bytes%)")) or size - end - log:close() - end - debugPrint(string.format("%s packed the release into %s: %d bytes", TUTORIAL.tag, packed, size)) - if size < 1000 then - debugPrint(TUTORIAL.tag .. " FAIL --pack should have written a .game") - end - else - debugPrint(TUTORIAL.tag .. " --pack not tried: SINGE_BIN is not set") - end - tutorialEnd() - -- The release runs from its own folder, finding its runtime and its files beside itself. - local mine = onOverlayUpdate - - dofile(folder .. "/CoinRun.singe") - game = onOverlayUpdate - onOverlayUpdate = mine - authorKeyDown(0, SCANCODE.RIGHT.value) - - elseif stage == 8 then - tutorialShot("playing") - debugPrint(string.format("%s the release plays from %s: score %s, lives %s, runtime at %s", TUTORIAL.tag, folder, tostring(AUTHOR_VARS.score), tostring(AUTHOR_VARS.lives), tostring(AUTHOR_DIR))) - if AUTHOR_DIR ~= folder .. "/" then - debugPrint(TUTORIAL.tag .. " FAIL the release should know its own folder") - end - elseif stage == 9 then - debugPrint(TUTORIAL.tag .. " RESULT done") - singeQuit() - end -end - - -function onOverlayUpdate() - frames = frames + 1 - if frames % 12 == 0 and stage < 9 then - stage = stage + 1 - step() - end - if game ~= nil then - return game() - end - forgeDraw(nil, nil) - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene93.singe b/testScripts/scene93.singe deleted file mode 100644 index 013a34ca2..000000000 --- a/testScripts/scene93.singe +++ /dev/null @@ -1,51 +0,0 @@ --- Overlay layers round guiDraw: what a script draws before a guiDraw is under the document and --- what it draws after is over it, so a gun's sight or an editor's pointer is never hidden by a --- HUD. Forge's own chrome, an opaque panel down the left, is the document: a red box is drawn --- before it and a yellow cross after it, both across the panel's edge, and the shot shows the box --- cut off by the panel and the cross whole over it. Then a second copy of the panel drawn small --- after the cross, with a blue box drawn after that, three layers deep. -local font = fontLoad("Singe/FreeSansBold.ttf", 16) -local gui = guiNew(720, 480) -local doc = guiLoad(gui, "Forge/Forge.rml") -local gui2 = guiNew(720, 480) -local doc2 = guiLoad(gui2, "Forge/Forge.rml") -local frames = 0 - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - - -function onOverlayUpdate() - frames = frames + 1 - colorBackground(20, 20, 30, 255) - overlayClear() - -- Under the panel: a red box across its right edge (the panel is 190 wide, and its grip - -- and title are the opaque part of an empty one, across the top). - colorForeground(230, 60, 60, 255) - for y = 40, 80 do - overlayLine(100, y, 300, y) - end - guiDraw(gui) - -- Over it: a yellow cross on the panel's edge, and a line of text. - colorForeground(255, 220, 60, 255) - overlayLine(150, 60, 230, 60) - overlayLine(190, 20, 190, 100) - fontPrint(220, 300, "drawn after guiDraw: over the panel") - -- A second panel, drawn small over the cross's layer, and a blue box over its grip. - guiDraw(gui2, 400, 150, 240, 160) - colorForeground(80, 120, 255, 255) - for y = 150, 170 do - overlayLine(380, y, 480, y) - end - if frames == 10 then - singeScreenshot("layers") - debugPrint("LAYERS two panels queued, each with what follows it on a layer of its own; see the shot") - debugPrint("LAYERS RESULT done") - end - if frames > 14 then - singeQuit() - end - - return OVERLAY_UPDATED -end diff --git a/testScripts/scene94.singe b/testScripts/scene94.singe deleted file mode 100644 index 31f13fa76..000000000 --- a/testScripts/scene94.singe +++ /dev/null @@ -1,54 +0,0 @@ --- Forge: a waves track by time, and soundDone. Three rocks come from the left and right spots --- in turn a fifth of a second apart from half a second in, two more at a point from a second and --- a half, and the bell's clip, played as it spawned, reports its end. -dofile("Forge/AuthorCompile.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 18) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) - -dofile(authorBuild("testScripts/author/waves.forge", singeGetDataPath() .. "waves.singe")) - -local gameUpdate = onOverlayUpdate -local shot = false -local checked = false -local began = os.time() -local PATIENCE = 10 -- Real seconds to wait for the bell: the mixer runs on the real clock, whatever the frame clock does. - - -function onOverlayUpdate() - local t = authorTime() - local r = gameUpdate() - - colorForeground(255, 255, 255, 255) - fontPrint(12, 450, string.format("waves %.1fs: %d rocks, made %d, rung %d", t, authorCount("rock"), AUTHOR_VARS.made, AUTHOR_VARS.rung)) - if (t > 1.2) and not shot then - shot = true - singeScreenshot() - end - if (t > 3) and not checked then - local rocks = authorEach("rock") - local xs = {} - - for _, rock in ipairs(rocks) do - local x = authorPosition(rock) - - xs[#xs + 1] = math.floor(x) - end - debugPrint(string.format("WAVES RESULT rocks=%d made=%d rung=%d at=%s after=%.1fs", #rocks, AUTHOR_VARS.made, AUTHOR_VARS.rung, table.concat(xs, ","), t)) - if #rocks ~= 5 then debugPrint("WAVES FAIL five rocks should have come, not " .. #rocks) end - if AUTHOR_VARS.made ~= 5 then debugPrint("WAVES FAIL the spawn rule saw " .. AUTHOR_VARS.made .. " rocks, not 5") end - if (xs[1] ~= 60) or (xs[2] ~= 660) or (xs[3] ~= 60) then debugPrint("WAVES FAIL the first wave did not come from the spots in turn") end - if (xs[4] ~= 360) or (xs[5] ~= 360) then debugPrint("WAVES FAIL the second wave did not come at its point") end - checked = true - end - -- The bell's clip ends on the mixer's own clock, which a deterministic run's frames outpace. - if checked and ((AUTHOR_VARS.rung >= 1) or (os.time() - began >= PATIENCE)) then - debugPrint(string.format("WAVES RESULT rung=%d after %d real seconds", AUTHOR_VARS.rung, os.time() - began)) - if AUTHOR_VARS.rung < 1 then debugPrint("WAVES FAIL soundDone never came for the bell") end - singeQuit() - end - - return r -end diff --git a/testScripts/scene95.singe b/testScripts/scene95.singe deleted file mode 100644 index 6f45f3bf0..000000000 --- a/testScripts/scene95.singe +++ /dev/null @@ -1,56 +0,0 @@ --- Forge's editor makes a waves track by keys: W adds it, K a wave at the cursor, and the wave's --- fields are typed like any other. The built game carries the track. -TUTORIAL = { number = 0, name = "00-waves", tag = "WAVESED" } -dofile("testScripts/tutorialDrive.singe") -tutorialBegin(nil) - -local frames = 0 - -function onOverlayUpdate() - frames = frames + 1 - if frames == 6 then - panel("tracks") - key(SCANCODE.W) - key(SCANCODE.K) - form("type", "hero") - form("count", 3) - form("every", 0.2) - form("from", "hero") - local track = forgeRoom().tracks[1] - local wave = track and track.spawns and track.spawns[1] - - if wave and (wave.type == "hero") and (wave.count == 3) and (wave.every == 0.2) and (wave.from == "hero") and (wave.at == 0) then - debugPrint("WAVESED the keys made a wave: at " .. wave.at .. ", " .. wave.count .. " x " .. wave.type .. " from " .. wave.from .. " every " .. wave.every) - else - debugPrint("WAVESED FAIL the wave is " .. tostring(wave and (tostring(wave.at) .. " " .. tostring(wave.type) .. " " .. tostring(wave.count) .. " " .. tostring(wave.every) .. " " .. tostring(wave.from)))) - end - -- Keyed by a stop instead: the wave's at becomes a name. - key(SCANCODE.UP) - form("key", "stop") - key(SCANCODE.DOWN) - form("at", "hall") - if (track.key == "stop") and (wave.at == "hall") then - debugPrint("WAVESED keyed by stops: the wave waits for " .. wave.at) - else - debugPrint("WAVESED FAIL keying by stops gave key " .. tostring(track.key) .. ", at " .. tostring(wave.at)) - end - singeScreenshot("wavesTrack") - elseif frames == 8 then - forgeSave() - local built = forgeBuild(TUTORIAL_OUT .. "waves.singe") - local file = built and io.open(TUTORIAL_OUT .. "waves.singe", "r") - local text = file and file:read("a") or "" - - if file then - file:close() - end - if text:find("spawns", 1, true) and text:find("hall", 1, true) then - debugPrint("WAVESED the built game carries the waves track") - else - debugPrint("WAVESED FAIL the built game has no waves track") - end - singeQuit() - end - forgeDraw(nil, nil) - return OVERLAY_UPDATED -end diff --git a/testScripts/scene96.singe b/testScripts/scene96.singe deleted file mode 100644 index 07c344318..000000000 --- a/testScripts/scene96.singe +++ /dev/null @@ -1,62 +0,0 @@ --- 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 deleted file mode 100644 index 22539d836..000000000 --- a/testScripts/scene97.singe +++ /dev/null @@ -1,85 +0,0 @@ --- 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 deleted file mode 100644 index dfbe7da9b..000000000 --- a/testScripts/scene98.singe +++ /dev/null @@ -1,56 +0,0 @@ --- 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/tutorialDrive.singe b/testScripts/tutorialDrive.singe deleted file mode 100644 index 63937a0c2..000000000 --- a/testScripts/tutorialDrive.singe +++ /dev/null @@ -1,186 +0,0 @@ --- What the tutorial scenes share: the keys a reader presses, pressed the same way, and the two --- checks every tutorial ends on. A tutorial in docs/Forge.adoc names keys; its scene presses --- them through here, so the text and the test cannot drift apart without the sweep saying so. --- --- A scene sets TUTORIAL (its number and name) before loading this, and calls tutorialBegin to --- open a description, tutorialShot to take a named picture, and tutorialEnd to check the result --- against the shipped description and run the built game. -FORGE_LIBRARY = true -dofile("Forge/Forge.singe") - -local font = fontLoad("Singe/FreeSansBold.ttf", 15) - -fontSelect(font) -fontQuality(FONT_QUALITY_BLENDED) -overlaySetResolution(720, 480) - -TUTORIAL_OUT = singeGetDataPath() - - --- The engine hands onKeyPressed two integers; so does this. -function key(code, character) - forgeKey(character or 0, code.value) -end - - --- Types text as a person would, one character at a time. -function typeText(text) - for at = 1, #text do - forgeKey(string.byte(text, at), 0) - end -end - - --- ENTER until the field is the one being edited. Answers whether it was reached. -local function reach(field) - local guard = 0 - - while (FORGE.lastField ~= field) and (guard < 40) do - key(SCANCODE.RETURN) - guard = guard + 1 - end - if FORGE.lastField ~= field then - debugPrint(string.format("%s FAIL the form never reached %s", TUTORIAL.tag, field)) - return false - end - - return true -end - - --- ESC out of the form: off a list first, then off the field it left being typed. -local function leave() - if FORGE.picker ~= nil then - key(SCANCODE.ESCAPE) - end - if FORGE.editing ~= nil then - key(SCANCODE.ESCAPE) - end -end - - --- Fills in one field of the form ENTER walks: ENTER until the field is the one being edited, --- type the value, ENTER to keep it, ESC to leave the form. Exactly the keys the text names. --- A field with a list narrows it to the value and takes it, which moves on to the next field. -function form(field, value) - if not reach(field) then - return - end - if FORGE.picker ~= nil then - pick(value) - else - typeText(tostring(value)) - key(SCANCODE.RETURN) - end - leave() -end - - --- Empties one field of the form: ENTER until it is the one being edited, DELETE, ESC. -function clear(field) - if not reach(field) then - return - end - if FORGE.picker ~= nil then - pick("(none)") - else - key(SCANCODE.DELETE) - end - leave() -end - - --- Narrows the picker by typing and takes the first match. -function pick(name) - typeText(name) - key(SCANCODE.RETURN) -end - - --- Selects the entity with an id, in the entities panel. -function selectEntity(id) - for index, entry in ipairs(forgeRoom().entities) do - if entry.id == id then - forgeSelect(index) - return - end - end - debugPrint(string.format("%s FAIL no entity called %s", TUTORIAL.tag, id)) -end - - --- Puts the panel on a mode, by TAB as a reader would. -function panel(mode) - local guard = 0 - - while (FORGE.mode ~= mode) and (guard < 6) do - key(SCANCODE.TAB) - guard = guard + 1 - end -end - - --- A picture for the book, drawn now and named. -function tutorialShot(name) - singeScreenshot(string.format("%02d-%s", TUTORIAL.number, name)) -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 .. ".forge" - - if fromShipped then - if not authorCopy("Forge/tutorials/" .. fromShipped .. ".forge", work) then - debugPrint(TUTORIAL.tag .. " FAIL could not copy " .. fromShipped) - singeQuit() - end - else - authorSave(forgeTemplate(), work) - end - if not forgeBegin(work) then - debugPrint(TUTORIAL.tag .. " FAIL could not open " .. work) - singeQuit() - end - - return work -end - - --- The two checks: what the keys made equals the shipped description, compiled; and the game it --- 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 .. ".forge" - local built - - forgeSave() - -- The compiled header names the description's file, which a release has set and a shipped - -- 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.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)) - - if mine == theirs then - debugPrint(TUTORIAL.tag .. " the keys made the shipped description") - else - local out = assert(io.open(TUTORIAL_OUT .. TUTORIAL.name .. ".mine.singe", "w")) - - out:write(mine) - out:close() - out = assert(io.open(TUTORIAL_OUT .. TUTORIAL.name .. ".theirs.singe", "w")) - out:write(theirs) - out:close() - debugPrint(TUTORIAL.tag .. " FAIL the keys made something else; see " .. TUTORIAL_OUT .. TUTORIAL.name .. ".mine.singe") - end - end - built = forgeBuild(TUTORIAL_OUT .. TUTORIAL.name .. ".singe") - forgeClose() - - return built -end diff --git a/util/demoCapture.sh b/util/demoCapture.sh index 0349af77d..6fb605ab1 100755 --- a/util/demoCapture.sh +++ b/util/demoCapture.sh @@ -23,6 +23,9 @@ fi STEP=20 REPO=$(cd "$(dirname "$0")/.." && pwd) BIN=$(ls "$REPO"/.builddir/Singe-v*-Linux-x86_64 | head -1) +# The demo's size, from its games.dat: the canvas the script draws and the window it is shown in. +WIDTH=$(grep -o 'CANVAS_X *= *[0-9]*' "$REPO/demos/$DEMO/games.dat" | grep -o '[0-9]*$') +HEIGHT=$(grep -o 'CANVAS_Y *= *[0-9]*' "$REPO/demos/$DEMO/games.dat" | grep -o '[0-9]*$') SCRATCH=${SCRATCH:-/tmp/demoCapture-$DEMO} rm -rf "$SCRATCH" @@ -33,7 +36,7 @@ DEMO_CAPTURE = { from = $FROM, to = $TO, every = $EVERY } dofile(singeGetScriptPath():match("(.*[/\\\\])") .. "$DEMO.singe") LUA cd "$SCRATCH/run" -SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 7200 "$BIN" -k -s -w -C 640x480 -x 640 -y 480 --deterministic=$STEP -d "$SCRATCH/data" "$DEMO/capture.singe" > "$SCRATCH/run.log" 2>&1 || true +SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 7200 "$BIN" -k -s -C ${WIDTH}x$HEIGHT -x $WIDTH -y $HEIGHT --deterministic=$STEP -d "$SCRATCH/data" "$DEMO/capture.singe" > "$SCRATCH/run.log" 2>&1 || true grep -E "DEMO|rror" "$SCRATCH/run.log" || true find "$SCRATCH/data" -name "frame*.png" -exec mv {} "$SCRATCH/frames/" \; echo "$(ls "$SCRATCH/frames" | wc -l) frames in $SCRATCH/frames" diff --git a/util/demoLogos.sh b/util/demoLogos.sh index b9f472306..b419f8aae 100755 --- a/util/demoLogos.sh +++ b/util/demoLogos.sh @@ -1,19 +1,29 @@ #!/bin/bash -# Renders the Singe and Kangaroo Punch logos from their layered sources in assets/ into demos/art/, -# at the demos' width, for the cards every demo opens and closes with (demos/kit.lua, K.run). The -# command is the one CMakeLists.txt renders the embedded copies with, then scaled down once here so -# the demos draw them without scaling. The closing card leaves out the Kangaroo Punch logo's -# "Proudly Presents..." line, layer 1: nothing follows it. +# Copies the logos every demo opens and closes with from their sources in media/ into demos/art/: +# the Singe dragon, turned to face left, and lettering for the opening card, the lettering turned near-white to stand on +# black, and Kangaroo Punch's logo made for black, uncompressed, for the closing one. The demos draw +# them from the SVG at the screen's resolution (demos/kit.lua, K.vector). set -euo pipefail REPO=$(cd "$(dirname "$0")/.." && pwd) -SIZE=1920x1080 -FIT=640x360 +ART="$REPO/demos/art" +LETTERING="#f2f2f2" -render() { - convert -background "rgba(0,0,0,0)" -size $SIZE xc:none "$1" -flatten -resize $FIT "$2" -} - -mkdir -p "$REPO/demos/art" -render "$REPO/assets/singeLogo.xcf" "$REPO/demos/art/singeLogo.png" -render "$REPO/assets/kangarooPunchLogo.xcf[0,2,3]" "$REPO/demos/art/kangarooPunchLogo.png" +mkdir -p "$ART" +# The dragon turned to face left, as on Singe's own logo: everything in the drawing mirrored about the +# middle of its view box. +python3 - "$REPO/media/Dragon.svg" "$ART/dragon.svg" <<'PY' +import re, sys +svg = open(sys.argv[1]).read() +x, y, w, h = (float(v) for v in re.search(r'viewBox="([^"]+)"', svg).group(1).split()) +opening = re.search(r'<svg[^>]*>', svg) +svg = svg[:opening.end()] + '<g transform="translate(%g 0) scale(-1 1)">' % (2 * x + w) + svg[opening.end():] +svg = svg.replace('</svg>', '</g></svg>') +open(sys.argv[2], "w").write(svg) +PY +sed "s/fill=\"#000\"/fill=\"$LETTERING\"/" "$REPO/media/SingeText.svg" > "$ART/singeText.svg" +if ! grep -q "fill=\"$LETTERING\"" "$ART/singeText.svg"; then + echo "SingeText.svg no longer has fill=\"#000\" to recolour" >&2 + exit 1 +fi +zcat "$REPO/media/Kangaroo Punch Logo with Text on Black.svgz" > "$ART/kangarooPunch.svg" diff --git a/util/demoRecord.sh b/util/demoRecord.sh index 38c6d6a26..2cc7bd4ed 100755 --- a/util/demoRecord.sh +++ b/util/demoRecord.sh @@ -14,11 +14,14 @@ OUT=$(realpath -m "$2") STEP=20 REPO=$(cd "$(dirname "$0")/.." && pwd) BIN=$(ls "$REPO"/.builddir/Singe-v*-Linux-x86_64 | head -1) +# The demo's size, from its games.dat: the canvas the script draws and the window it is shown in. +WIDTH=$(grep -o 'CANVAS_X *= *[0-9]*' "$REPO/demos/$DEMO/games.dat" | grep -o '[0-9]*$') +HEIGHT=$(grep -o 'CANVAS_Y *= *[0-9]*' "$REPO/demos/$DEMO/games.dat" | grep -o '[0-9]*$') SCRATCH=${SCRATCH:-/tmp/demoRecord-$DEMO} rm -rf "$SCRATCH" mkdir -p "$SCRATCH/run" cp -r "$REPO/demos/." "$SCRATCH/run/" cd "$SCRATCH/run" -SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 7200 "$BIN" -k -w -C 640x480 -x 1024 -y 768 --deterministic=$STEP --record="$OUT" -p -d "$SCRATCH/data" "$DEMO/$DEMO.singe" > "$SCRATCH/run.log" 2>&1 || true +SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 7200 "$BIN" -k -C ${WIDTH}x$HEIGHT -x $WIDTH -y $HEIGHT --deterministic=$STEP --record="$OUT" -p -d "$SCRATCH/data" "$DEMO/$DEMO.singe" > "$SCRATCH/run.log" 2>&1 || true grep -E "Recording|Recorded|rror" "$SCRATCH/run.log" || true diff --git a/util/forgeCredits.lua b/util/forgeCredits.lua deleted file mode 100644 index f08ca441c..000000000 --- a/util/forgeCredits.lua +++ /dev/null @@ -1,191 +0,0 @@ --- The original author's credits for a Forge port of an existing game: the fields of the game's own --- games.dat entry (developer, publisher, year, platform, genre, creator, source, description), the --- "KEY: value" lines of its main script's header comment as notes, and the licence files beside --- the script, which a release carries. Nothing is invented: a field the original does not state --- is left out. --- --- lua5.4 util/forgeCredits.lua <description.forge> <game directory> <main script> --- --- writes or replaces the description's credits block, just after its title, and touches nothing --- else in it. util/forgePortKaris.lua loads this file for creditsFor, so a converted description --- carries its credits from the start. - -local CREDIT_KEYS = { - { field = "developer", key = "DEVELOPER" }, - { field = "publisher", key = "PUBLISHER" }, - { field = "year", key = "YEAR" }, - { field = "platform", key = "PLATFORM" }, - { field = "genre", key = "GENRE" }, - { field = "creator", key = "CREATOR" }, - { field = "source", key = "SOURCE" }, - { field = "description", key = "DESCRIPTION" } -} -local LICENCE_PATTERNS = { "^copying", "^licen[cs]e", "^gpl" } -local GAMES_DAT_DEPTH = 2 -- How many folders above the script games.dat may be. - - -local function readFile(path) - local handle = io.open(path, "rb") - local text = nil - - if handle then - text = handle:read("a") - handle:close() - end - - return text -end - - --- The games.dat entry for the script: beside it or a folder or two above, the entry whose SCRIPT --- names this script, or the only entry there is. -local function gamesEntry(dir, main) - local folder = dir - - for _ = 0, GAMES_DAT_DEPTH do - local text = readFile(folder .. "/games.dat") - - if text then - local sandbox = {} - local chunk = load(text, "games.dat", "t", sandbox) - - if chunk and pcall(chunk) and (type(sandbox.GAMES) == "table") then - for _, entry in ipairs(sandbox.GAMES) do - local script = tostring(entry.SCRIPT or "") - - if script:lower():sub(-#main) == main:lower() then - return entry - end - end - if #sandbox.GAMES == 1 then - return sandbox.GAMES[1] - end - end - end - folder = folder:match("^(.*)[/\\][^/\\]+$") or folder - end - - return nil -end - - --- The header comment's "KEY: value" lines, as the author wrote them: program name, author, who --- converted it and for what. -local function headerNotes(dir, main) - local text = readFile(dir .. "/" .. main) or "" - local block = text:match("%-%-%[=*%[(.-)%]=*%]") - local notes = {} - - for line in (block or ""):gmatch("[^\r\n]+") do - local key, value = line:match("^%s*([A-Z][A-Z0-9 /%-]*[A-Z0-9])%s*:%s*(.-)%s*$") - - if key and (value ~= "") then - notes[#notes + 1] = key:sub(1, 1) .. key:sub(2):lower() .. ": " .. value - end - end - - return notes -end - - --- The licence files beside the script, by name. -local function licences(dir) - local found = {} - local handle = io.popen('ls -1 "' .. dir .. '" 2>/dev/null') - - if handle then - for name in handle:lines() do - for _, pattern in ipairs(LICENCE_PATTERNS) do - if name:lower():match(pattern) then - found[#found + 1] = name - break - end - end - end - handle:close() - end - table.sort(found) - - return found -end - - --- The credits block as description source, one line, or nil when there is nothing to credit. -function creditsFor(dir, main) - local entry = gamesEntry(dir, main) or {} - local parts = {} - local notes = headerNotes(dir, main) - local files = licences(dir) - - for _, credit in ipairs(CREDIT_KEYS) do - local value = entry[credit.key] - - if type(value) == "number" then - parts[#parts + 1] = string.format("%s = %d", credit.field, value) - elseif (type(value) == "string") and (value ~= "") then - parts[#parts + 1] = string.format("%s = %q", credit.field, value) - end - end - if #notes > 0 then - local quoted = {} - - for _, note in ipairs(notes) do - quoted[#quoted + 1] = string.format("%q", note) - end - parts[#parts + 1] = "notes = { " .. table.concat(quoted, ", ") .. " }" - end - if #files > 0 then - local quoted = {} - - for _, name in ipairs(files) do - quoted[#quoted + 1] = string.format("%q", name) - end - parts[#parts + 1] = "licenses = { " .. table.concat(quoted, ", ") .. " }" - end - if #parts == 0 then - return nil - end - - return "\tcredits = { " .. table.concat(parts, ", ") .. " }," -end - - --- The description with its credits line put just after the title, replacing one already there. -local function withCredits(text, line) - local out = {} - local after = false - - for row in (text .. "\n"):gmatch("(.-)\n") do - if not row:match("^\tcredits = {") then - out[#out + 1] = row - if (not after) and line and row:match("^\ttitle%s*=") then - out[#out + 1] = line - after = true - end - end - end - while (#out > 0) and (out[#out] == "") do - out[#out] = nil - end - - return table.concat(out, "\n") .. "\n", after -end - - -if arg and arg[0] and arg[0]:match("forgeCredits%.lua$") then - local description = arg[1] or error("a description") - local dir = arg[2] or error("the game's directory") - local main = arg[3] or error("its main script") - local text = readFile(description) or error("cannot read " .. description) - local line = creditsFor(dir, main) - local result, put = withCredits(text, line) - - if line and not put then - error("no title line in " .. description) - end - local handle = io.open(description, "wb") - - handle:write(result) - handle:close() - print(description .. ": " .. (line and line:gsub("^\t", "") or "nothing to credit")) -end diff --git a/util/forgeKit.py b/util/forgeKit.py deleted file mode 100644 index 1b51e93e9..000000000 --- a/util/forgeKit.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/env python3 -# Makes the Forge tutorial kit -- the pictures, sounds, music, clip, and model the tutorials in -# docs/Forge.adoc tell the reader to use -- into forge/kit/, which the build packs into -# Forge.game. Everything here is drawn or synthesised by this script, so the kit can be remade -# whenever a tutorial wants something changed: -# -# python3 util/forgeKit.py -# -# Needs Pillow and ffmpeg. The Fox model is copied from testScripts/Models (CC-BY 4.0, see the -# LICENSE the script writes). Names are ASCII and camelCase, as the tutorials print them. - -import math -import os -import shutil -import struct -import subprocess -import sys -import wave - -from PIL import Image, ImageDraw - -KIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "forge", "kit") -MODELS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "Models") -RATE = 22050 - -OUTLINE = (24, 20, 36, 255) -SKIN = (250, 214, 170, 255) -SHIRT = (230, 90, 170, 255) -TROUSERS = (60, 70, 140, 255) -BOOT = (70, 40, 30, 255) - - -def image(w, h): - return Image.new("RGBA", (w, h), (0, 0, 0, 0)) - - -def save(img, name): - img.save(os.path.join(KIT, name), optimize=True) - print(" " + name) - - -def outlined(draw, box, fill, shape="rect"): - if shape == "ellipse": - draw.ellipse(box, fill=fill, outline=OUTLINE) - else: - draw.rectangle(box, fill=fill, outline=OUTLINE) - - -# ---- the hero: 8 frames of 32 x 48, facing right: idle, walk x4, jump, fall, hit ---- -def heroFrame(draw, ox, pose): - # Legs first, so the body sits over them. - strides = {"idle": (0, 0), "w1": (-4, 4), "w2": (-2, 2), "w3": (4, -4), "w4": (2, -2), "jump": (-3, 3), "fall": (2, 2), "hit": (-2, -2)} - back, front = strides[pose] - lift = -4 if pose == "jump" else 0 - outlined(draw, (ox + 11 + back, 28 + lift, ox + 16 + back, 44 + lift), TROUSERS) - outlined(draw, (ox + 10 + back, 42 + lift, ox + 17 + back, 46 + lift), BOOT) - outlined(draw, (ox + 16 + front, 28 + lift, ox + 21 + front, 44 + lift), TROUSERS) - outlined(draw, (ox + 15 + front, 42 + lift, ox + 22 + front, 46 + lift), BOOT) - # Body and arms. - outlined(draw, (ox + 9, 17 + lift, ox + 22, 30 + lift), SHIRT) - if pose == "jump": - outlined(draw, (ox + 6, 10 + lift, ox + 10, 24 + lift), SHIRT) - outlined(draw, (ox + 21, 10 + lift, ox + 25, 24 + lift), SHIRT) - elif pose == "hit": - outlined(draw, (ox + 4, 18 + lift, ox + 10, 22 + lift), SHIRT) - outlined(draw, (ox + 21, 18 + lift, ox + 27, 22 + lift), SHIRT) - else: - swing = {"w1": 3, "w2": 1, "w3": -3, "w4": -1}.get(pose, 0) - outlined(draw, (ox + 6, 19 + lift, ox + 10, 29 + lift + swing), SHIRT) - outlined(draw, (ox + 21, 19 + lift, ox + 25, 29 + lift - swing), SHIRT) - # Head, with the eye on the right so the art faces right. - outlined(draw, (ox + 8, 3 + lift, ox + 23, 18 + lift), SKIN, "ellipse") - draw.rectangle((ox + 8, 3 + lift, ox + 23, 8 + lift), fill=(90, 50, 30, 255)) - draw.rectangle((ox + 18, 9 + lift, ox + 20, 11 + lift), fill=OUTLINE) - if pose == "hit": - draw.line((ox + 17, 9 + lift, ox + 21, 12 + lift), fill=OUTLINE) - draw.line((ox + 21, 9 + lift, ox + 17, 12 + lift), fill=OUTLINE) - - -def hero(): - img = image(256, 48) - draw = ImageDraw.Draw(img) - for at, pose in enumerate(["idle", "w1", "w2", "w3", "w4", "jump", "fall", "hit"]): - heroFrame(draw, at * 32, pose) - save(img, "hero.png") - - -def coin(): - img = image(16, 16) - draw = ImageDraw.Draw(img) - outlined(draw, (1, 1, 14, 14), (250, 200, 40, 255), "ellipse") - draw.ellipse((4, 4, 11, 11), outline=(200, 140, 20, 255)) - draw.point((5, 5), fill=(255, 250, 200, 255)) - save(img, "coin.png") - - -def key(): - img = image(16, 16) - draw = ImageDraw.Draw(img) - draw.ellipse((1, 4, 8, 11), fill=(250, 200, 40, 255), outline=OUTLINE) - draw.rectangle((8, 7, 14, 8), fill=(250, 200, 40, 255), outline=OUTLINE) - draw.rectangle((12, 8, 12, 11), fill=(250, 200, 40, 255), outline=OUTLINE) - draw.rectangle((14, 8, 14, 10), fill=(250, 200, 40, 255), outline=OUTLINE) - save(img, "key.png") - - -def crate(): - img = image(32, 32) - draw = ImageDraw.Draw(img) - outlined(draw, (0, 0, 31, 31), (170, 120, 60, 255)) - draw.rectangle((3, 3, 28, 28), outline=(120, 80, 40, 255)) - draw.line((3, 3, 28, 28), fill=(120, 80, 40, 255), width=2) - draw.line((28, 3, 3, 28), fill=(120, 80, 40, 255), width=2) - save(img, "crate.png") - - -def door(): - img = image(32, 48) - draw = ImageDraw.Draw(img) - outlined(draw, (2, 0, 29, 47), (120, 70, 40, 255)) - draw.rectangle((6, 4, 25, 43), outline=(80, 45, 25, 255)) - draw.line((15, 4, 15, 43), fill=(80, 45, 25, 255)) - draw.ellipse((19, 22, 23, 26), fill=(250, 200, 40, 255), outline=OUTLINE) - save(img, "door.png") - - -def spike(): - img = image(32, 16) - draw = ImageDraw.Draw(img) - for at in range(4): - x = at * 8 - draw.polygon([(x, 15), (x + 4, 0), (x + 8, 15)], fill=(200, 200, 215, 255), outline=OUTLINE) - save(img, "spike.png") - - -def tiles(): - img = image(128, 32) - draw = ImageDraw.Draw(img) - # 1 grass, 2 dirt, 3 stone, 4 brick. - outlined(draw, (0, 0, 31, 31), (140, 90, 50, 255)) - draw.rectangle((1, 1, 30, 8), fill=(90, 180, 70, 255)) - for x in range(2, 30, 4): - draw.line((x, 1, x, 4), fill=(60, 140, 50, 255)) - outlined(draw, (32, 0, 63, 31), (140, 90, 50, 255)) - for x, y in [(38, 8), (50, 20), (44, 26), (56, 6)]: - draw.point((x, y), fill=(110, 70, 40, 255)) - outlined(draw, (64, 0, 95, 31), (130, 130, 145, 255)) - draw.rectangle((68, 4, 80, 14), outline=(100, 100, 115, 255)) - draw.rectangle((78, 16, 92, 28), outline=(100, 100, 115, 255)) - outlined(draw, (96, 0, 127, 31), (170, 70, 60, 255)) - for row in range(4): - y = row * 8 - draw.line((96, y + 7, 127, y + 7), fill=(120, 40, 35, 255)) - offset = 8 if row % 2 else 0 - for x in range(96 + offset, 128, 16): - draw.line((x, y, x, y + 7), fill=(120, 40, 35, 255)) - save(img, "tiles.png") - - -def enemy(): - img = image(128, 32) - draw = ImageDraw.Draw(img) - for at in range(4): - ox = at * 32 - flap = [0, 4, 8, 4][at] - draw.polygon([(ox + 2, 16 + flap), (ox + 12, 12), (ox + 12, 20)], fill=(120, 60, 160, 255), outline=OUTLINE) - draw.polygon([(ox + 30, 16 + flap), (ox + 20, 12), (ox + 20, 20)], fill=(120, 60, 160, 255), outline=OUTLINE) - outlined(draw, (ox + 9, 8, ox + 22, 24), (150, 80, 190, 255), "ellipse") - draw.rectangle((ox + 12, 13, ox + 13, 15), fill=(255, 240, 80, 255)) - draw.rectangle((ox + 18, 13, ox + 19, 15), fill=(255, 240, 80, 255)) - save(img, "enemy.png") - - -def ship(): - img = image(32, 32) - draw = ImageDraw.Draw(img) - draw.polygon([(16, 1), (28, 26), (16, 20), (4, 26)], fill=(200, 210, 230, 255), outline=OUTLINE) - draw.polygon([(16, 8), (20, 18), (12, 18)], fill=(80, 160, 240, 255), outline=OUTLINE) - draw.rectangle((13, 26, 18, 30), fill=(250, 150, 40, 255), outline=OUTLINE) - save(img, "ship.png") - - -def shot(): - img = image(8, 8) - draw = ImageDraw.Draw(img) - draw.ellipse((1, 0, 6, 7), fill=(255, 240, 120, 255), outline=(200, 120, 20, 255)) - save(img, "shot.png") - - -def rock(): - img = image(32, 32) - draw = ImageDraw.Draw(img) - draw.polygon([(6, 4), (22, 2), (30, 12), (28, 26), (14, 30), (3, 20)], fill=(140, 130, 120, 255), outline=OUTLINE) - draw.polygon([(10, 10), (18, 8), (20, 16), (12, 18)], fill=(110, 100, 95, 255)) - save(img, "rock.png") - - -def target(): - img = image(48, 48) - draw = ImageDraw.Draw(img) - for radius, colour in [(23, (230, 60, 60, 255)), (17, (245, 245, 245, 255)), (11, (230, 60, 60, 255)), (5, (245, 245, 245, 255))]: - draw.ellipse((24 - radius, 24 - radius, 24 + radius, 24 + radius), fill=colour, outline=OUTLINE) - save(img, "target.png") - - -# ---- painted rooms for the adventure, 720 x 480 ---- -def yard(): - img = Image.new("RGB", (720, 480), (120, 170, 230)) - draw = ImageDraw.Draw(img) - for y in range(0, 200): - draw.line((0, y, 720, y), fill=(100 + y // 4, 150 + y // 5, 230)) - # A wall along the back, with a doorway on the right. - draw.rectangle((0, 200, 720, 300), fill=(180, 150, 110)) - for row in range(5): - y = 200 + row * 20 - draw.line((0, y + 19, 720, y + 19), fill=(140, 110, 80)) - offset = 30 if row % 2 else 0 - for x in range(offset, 720, 60): - draw.line((x, y, x, y + 19), fill=(140, 110, 80)) - draw.rectangle((560, 210, 620, 300), fill=(50, 35, 25)) - # The floor, in perspective, and a pillar in front of it. - draw.polygon([(0, 300), (720, 300), (720, 480), (0, 480)], fill=(160, 140, 100)) - for at in range(0, 720, 40): - draw.line((at, 300, at * 1.6 - 220, 480), fill=(140, 120, 85)) - for y in range(300, 480, 30): - draw.line((0, y, 720, y), fill=(140, 120, 85)) - draw.rectangle((300, 120, 340, 420), fill=(200, 200, 210), outline=(90, 90, 100)) - draw.rectangle((290, 110, 350, 130), fill=(180, 180, 190), outline=(90, 90, 100)) - draw.rectangle((290, 410, 350, 430), fill=(180, 180, 190), outline=(90, 90, 100)) - img.save(os.path.join(KIT, "yard.png"), optimize=True) - print(" yard.png") - - -def hall(): - img = Image.new("RGB", (720, 480), (60, 50, 70)) - draw = ImageDraw.Draw(img) - draw.rectangle((0, 0, 720, 280), fill=(90, 70, 90)) - for x in range(0, 720, 90): - draw.rectangle((x + 20, 60, x + 60, 240), fill=(120, 100, 120), outline=(60, 45, 60)) - draw.polygon([(0, 280), (720, 280), (720, 480), (0, 480)], fill=(120, 90, 70)) - for y in range(280, 480, 25): - draw.line((0, y, 720, y), fill=(100, 75, 60)) - draw.rectangle((80, 120, 140, 280), fill=(40, 30, 35)) - draw.rectangle((520, 150, 640, 250), fill=(200, 180, 120), outline=(120, 100, 60)) - draw.text((535, 190), "the hall", fill=(60, 45, 30)) - img.save(os.path.join(KIT, "hall.png"), optimize=True) - print(" hall.png") - - -# ---- sounds ---- -def tone(seconds, pitch, kind="square", pitchEnd=None, fade=True): - samples = [] - count = int(RATE * seconds) - for at in range(count): - t = at / RATE - f = pitch if pitchEnd is None else pitch + (pitchEnd - pitch) * at / count - phase = (t * f) % 1.0 - if kind == "square": - v = 1.0 if phase < 0.5 else -1.0 - elif kind == "saw": - v = phase * 2 - 1 - elif kind == "noise": - v = (hash((at * 7919) % 104729) % 2000) / 1000.0 - 1.0 - else: - v = math.sin(phase * 2 * math.pi) - gain = (1.0 - at / count) if fade else 1.0 - samples.append(v * gain * 0.4) - return samples - - -def writeWav(name, samples): - with wave.open(os.path.join(KIT, name), "wb") as out: - out.setnchannels(1) - out.setsampwidth(2) - out.setframerate(RATE) - out.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767)) for s in samples)) - print(" " + name) - - -def sounds(): - writeWav("jump.wav", tone(0.25, 300, "square", 900)) - writeWav("coin.wav", tone(0.08, 1200, "square", fade=False) + tone(0.18, 1800, "square")) - writeWav("shot.wav", tone(0.15, 0, "noise")) - writeWav("hit.wav", tone(0.3, 200, "saw", 60)) - writeWav("click.wav", tone(0.04, 2000, "square")) - writeWav("door.wav", tone(0.4, 120, "saw", 90)) - # An eight-second loop: a bass line under an arpeggio, one bar a second. - notes = [220, 262, 330, 262, 196, 247, 294, 247] - loop = [] - for bar in range(8): - root = notes[bar] - for step in range(8): - pitch = root * [1, 1.5, 2, 1.5][step % 4] - loop += [a + b for a, b in zip(tone(0.125, pitch, "square", fade=False), tone(0.125, root / 2, "saw", fade=False))] - writeWav("loopSource.wav", [s * 0.5 for s in loop]) - subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", os.path.join(KIT, "loopSource.wav"), "-c:a", "libvorbis", "-q:a", "3", os.path.join(KIT, "loop.ogg")], check=True) - os.remove(os.path.join(KIT, "loopSource.wav")) - print(" loop.ogg") - - -# ---- the clip: twelve seconds, 720 x 480 like the overlay, three moments with a clock along -# the bottom. Drawn at 480 x 320 and scaled, which keeps the shapes' numbers simple. -def clip(): - work = os.path.join(KIT, "clipFrames") - os.makedirs(work, exist_ok=True) - fps = 24 - total = 12 * fps - for frame in range(total): - t = frame / fps - img = Image.new("RGB", (480, 320), (40, 44, 60)) - draw = ImageDraw.Draw(img) - draw.rectangle((0, 0, 480, 200), fill=(70, 74, 96)) - draw.rectangle((0, 200, 480, 320), fill=(50, 52, 66)) - # A door on the left that opens between 3 and 5 seconds. - opening = max(0.0, min(1.0, (t - 3) / 2)) - draw.rectangle((60, 60, 160, 200), fill=(20, 20, 28)) - draw.rectangle((60, 60, 60 + int(100 * (1 - opening)), 200), fill=(120, 80, 50), outline=(30, 20, 10)) - # A target that rises from the floor between 6 and 8 seconds and sinks after 9. - rise = max(0.0, min(1.0, (t - 6) / 2)) - max(0.0, min(1.0, (t - 9) / 1)) - if rise > 0: - cy = 260 - int(120 * rise) - for radius, colour in [(30, (230, 60, 60)), (20, (245, 245, 245)), (10, (230, 60, 60))]: - draw.ellipse((300 - radius, cy - radius, 300 + radius, cy + radius), fill=colour) - # A hand reaching in from the right between 9 and 11 seconds. - reach = max(0.0, min(1.0, (t - 9) / 1)) - max(0.0, min(1.0, (t - 11) / 1)) - if reach > 0: - x = 480 - int(160 * reach) - draw.rectangle((x, 120, 480, 160), fill=(250, 214, 170)) - draw.ellipse((x - 30, 105, x + 30, 175), fill=(250, 214, 170)) - # The clock, so a reader can see the second a moment happens. - draw.rectangle((20, 300, 460, 310), outline=(200, 200, 210)) - draw.rectangle((20, 300, 20 + int(440 * t / 12), 310), fill=(255, 207, 74)) - draw.text((22, 286), "%4.1f s" % t, fill=(230, 230, 240)) - img.resize((720, 480), Image.NEAREST).save(os.path.join(work, "f%04d.png" % frame)) - subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(fps), "-i", os.path.join(work, "f%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "28", "-g", "24", os.path.join(KIT, "clip.mkv")], check=True) - shutil.rmtree(work) - print(" clip.mkv") - - -def model(): - shutil.copy(os.path.join(MODELS, "Fox.glb"), os.path.join(KIT, "fox.glb")) - print(" fox.glb") - - -def notes(): - with open(os.path.join(KIT, "LICENSE"), "w") as out: - out.write("The Forge tutorial kit.\n\n" - "Everything here except fox.glb was drawn or synthesised by util/forgeKit.py in the\n" - "Singe source tree and is released under CC0: use it in your own games as you like.\n\n" - "fox.glb is the Fox from the Khronos glTF sample models, CC-BY 4.0: model by PixelMannen,\n" - "rigging and animation by tomkranis, glTF conversion by AsoboStudio and scurest.\n" - "A game that ships it should carry this credit.\n") - with open(os.path.join(KIT, "README.txt"), "w") as out: - out.write("The Forge tutorial kit, addressed from a game as Forge/kit/<name>.\n\n" - "hero.png a sprite sheet, 8 columns of 32 x 48, facing right: idle, walk x4, jump, fall, hit\n" - "enemy.png a sheet, 4 columns of 32 x 32, flapping\n" - "tiles.png a tile sheet, 4 columns of 32 x 32: grass, dirt, stone, brick\n" - "coin.png key.png crate.png door.png spike.png ship.png shot.png rock.png target.png stills\n" - "yard.png hall.png painted rooms, 720 x 480, for the adventures\n" - "jump.wav coin.wav shot.wav hit.wav click.wav door.wav clips\n" - "loop.ogg eight seconds of music that loops\n" - "clip.mkv twelve seconds of 720 x 480 video at 24 frames a second: a door opens at 3 s, a target rises at 6 s, a hand reaches at 9 s\n" - "fox.glb an animated model (see LICENSE)\n") - print(" LICENSE, README.txt") - - -if __name__ == "__main__": - os.makedirs(KIT, exist_ok=True) - print("Forge kit into " + KIT) - for make in [hero, coin, key, crate, door, spike, tiles, enemy, ship, shot, rock, target, yard, hall, sounds, clip, model, notes]: - make() diff --git a/util/forgeLedger.py b/util/forgeLedger.py deleted file mode 100644 index 4782ddb3b..000000000 --- a/util/forgeLedger.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -# The ledger of FORGE.md section 17: every capability the library's converted titles use, which -# titles use it, and how the converter maps it, gathered from util/forgePortKaris.lua's report -# (FORGE_LEDGER) for each description it made. Nothing is run but the converter; no game plays. -# -# python3 util/forgeLedger.py [<out>] (default: testScripts/ports/ledger.md) -# -# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) -# -# A status is the converter's own: "forge" is a field of Forge's model, "framework" something -# Forge plays under the framework's names or numbers, "lua" something the game's own Lua still does. -# The runs-without-its-Lua column is testScripts/ports/standalone.txt as last proved, with its date. -import collections -import datetime -import hashlib -import os -import re -import subprocess -import sys -import tempfile - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported")) -OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "testScripts", "ports", "ledger.md") -STANDING = os.path.join(REPO, "testScripts", "ports", "standalone.txt") -MADE_BY = "by the port converter" -SCRIPT = re.compile(r'^\s*script\s*=\s*"([^"]+)"', re.M) -STATUSES = ("lua", "framework", "forge") -EXAMPLES = 6 - - -def descriptions(): - """Every description the converter made, as (title, directory, main script, name).""" - found = [] - for root, dirs, files in os.walk(LIBRARY): - dirs.sort() - for name in sorted(f for f in files if f.endswith(".forge")): - path = os.path.join(root, name) - text = open(path, errors="replace").read() - if MADE_BY not in text: - continue - script = SCRIPT.search(text) - if not script: - continue - title = os.path.relpath(path, LIBRARY)[:-len(".forge")] - found.append((title, root, script.group(1), name[:-len(".forge")])) - return found - - -def others(): - """The descriptions another tool made, named so the ledger says what it does not cover.""" - missing = [] - for root, dirs, files in os.walk(LIBRARY): - for name in files: - path = os.path.join(root, name) - if name.endswith(".forge") and MADE_BY not in open(path, errors="replace").read(): - missing.append(os.path.relpath(path, LIBRARY)) - return sorted(missing) - - -def addonsHash(directory): - path = os.path.join(directory, "Script", "addons.singe") - return hashlib.md5(open(path, "rb").read()).hexdigest() if os.path.exists(path) else None - - -def report(directory, script, name): - with tempfile.NamedTemporaryFile(suffix=".ledger", delete=False) as handle: - path = handle.name - try: - done = subprocess.run(["lua5.4", os.path.join("util", "forgePortKaris.lua"), directory, script, name], - cwd=REPO, env=dict(os.environ, FORGE_LEDGER=path), capture_output=True, text=True, timeout=120) - lines = [line.rstrip("\n").split("\t") for line in open(path) if line.strip()] - return lines, (done.stderr.strip().splitlines() or [""])[-1] if done.returncode else None - finally: - os.remove(path) - - -def main(): - standing = set() - proved = "never" - if os.path.exists(STANDING): - standing = {line.strip() for line in open(STANDING) if line.strip() and not line.startswith("#")} - proved = datetime.date.fromtimestamp(os.path.getmtime(STANDING)).isoformat() - - uses = collections.defaultdict(set) # (status, capability) -> titles - details = collections.defaultdict(set) # (status, capability) -> details - perTitle = {} - failed = [] - titles = descriptions() - # The add-ons most titles ship unchanged are the framework's own: their hooks are the stock - # ones, not the game's Lua. - hashes = collections.Counter(h for h in (addonsHash(row[1]) for row in titles) if h) - stock = hashes.most_common(1)[0][0] if hashes else None - for title, directory, script, name in titles: - lines, error = report(directory, script, name) - if error is not None: - failed.append((title, error)) - continue - if stock and addonsHash(directory) == stock: - lines = [["framework", capability, "stock add-ons"] if capability.startswith("hook.") else [status, capability, detail] for status, capability, detail in lines] - counts = collections.Counter() - family = "?" - for status, capability, detail in lines: - uses[(status, capability)].add(title) - if detail: - details[(status, capability)].add(detail) - counts[status] += 1 - if capability.startswith("family."): - family = capability[len("family."):] - folder = title.split("/")[1] if title.startswith("hypseus/") else title.split("/")[0] - perTitle[title] = (family, counts, "yes" if folder in standing else "not on the list") - - out = [] - out.append("# The ledger (FORGE.md section 17)") - out.append("") - out.append("Generated by `util/forgeLedger.py` on %s from the converter's report for each" % datetime.date.today().isoformat()) - out.append("description it made; do not edit by hand. **forge** is a field of Forge's own model,") - out.append("**framework** something Forge plays under the framework's names or numbers, **lua**") - out.append("something the game's own Lua still does. Rows are sorted by status, then by how many") - out.append("titles use them: the top of the table is what most blocks titles from being Forge's own.") - out.append("A hook from the add-ons file most titles ship unchanged (%d of them) is the framework's" % (hashes[stock] if stock else 0)) - out.append("stock one, and counts as framework. The globals are every global the game's script left") - out.append("that nothing names: most are the framework's own state and picture numbers, not data.") - out.append("") - out.append("%d titles reported; %d failed to report; %d descriptions made by another tool (not covered)." % (len(perTitle), len(failed), len(others()))) - out.append("") - - # The capabilities, with the globals and settings folded into one row each per status: there - # are hundreds, and their names are listed in the section after. - folded = collections.defaultdict(set) - for (status, capability), titles in uses.items(): - if capability.startswith("global.") or capability.startswith("setting."): - folded[(status, capability.split(".")[0] + ".*")] |= titles - else: - folded[(status, capability)] |= titles - out.append("## Capabilities") - out.append("") - out.append("| Status | Capability | Titles | Some of them | Details |") - out.append("|---|---|---|---|---|") - for (status, capability), titles in sorted(folded.items(), key=lambda item: (STATUSES.index(item[0][0]), -len(item[1]), item[0][1])): - names = sorted(titles) - more = ", ..." if len(names) > EXAMPLES else "" - shown = ", ".join(title.split("/")[-1] for title in names[:EXAMPLES]) + more - found = sorted(details.get((status, capability), [])) - note = "; ".join(found[:8]) + ("; ..." if len(found) > 8 else "") - out.append("| %s | %s | %d | %s | %s |" % (status, capability, len(names), shown, note)) - out.append("") - - for prefix, heading in (("setting.", "Settings under the framework's names"), ("global.", "Globals nothing names yet")): - rows = sorted(((capability, len(titles)) for (status, capability), titles in uses.items() if capability.startswith(prefix)), key=lambda row: (-row[1], row[0])) - out.append("## %s (%d)" % (heading, len(rows))) - out.append("") - out.append("By how many titles use each: " + ", ".join("%s %d" % (capability[len(prefix):], count) for capability, count in rows) + ".") - out.append("") - - out.append("## Titles") - out.append("") - out.append("Runs without its Lua: `standalone.txt`, last proved %s." % proved) - out.append("") - out.append("| Title | Family | forge | framework | lua | Runs without its Lua |") - out.append("|---|---|---|---|---|---|") - for title, (family, counts, standalone) in sorted(perTitle.items(), key=lambda item: (item[1][1]["lua"], item[1][1]["framework"], item[0])): - out.append("| %s | %s | %d | %d | %d | %s |" % (title, family, counts["forge"], counts["framework"], counts["lua"], standalone)) - out.append("") - if failed: - out.append("## Failed to report") - out.append("") - for title, error in failed: - out.append("* %s: %s" % (title, error)) - out.append("") - out.append("## Made by another tool") - out.append("") - out.append(", ".join(others()) + ".") - out.append("") - - with open(OUT, "w") as handle: - handle.write("\n".join(out)) - print("wrote %s: %d titles, %d capabilities, %d failed" % (OUT, len(perTitle), len(folded), len(failed))) - - -if __name__ == "__main__": - main() diff --git a/util/forgePortActionMax.py b/util/forgePortActionMax.py deleted file mode 100644 index 824f6aae0..000000000 --- a/util/forgePortActionMax.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/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") - out.append("-- framework's own lightSensor behaviour.") - 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("\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 deleted file mode 100644 index 53df5266f..000000000 --- a/util/forgePortKaris.lua +++ /dev/null @@ -1,1177 +0,0 @@ --- 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$", "") - --- A game that draws at random as it loads (a scene swap, a level's variant) draws the same every --- time, so a description made twice is made the same. -local CONVERT_SEED = 12345 - -math.randomseed(CONVERT_SEED) - --- ===== Loading the game's script ============================================================== - -local env = setmetatable({}, { __index = _G }) - -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 the game's config files in Cfg/, the first to name one winning. -local function configFiles(names) - local found = {} - - for _, name in ipairs(names) do - local handle = io.open(dir .. "/Cfg/" .. name, "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 - --- What the game was last saved with or shipped with: the framework reads Cfg/game.cfg and falls --- back to Cfg/default.cfg. -local function settings() - return configFiles({ "game.cfg", "default.cfg" }) -end - --- What the service screen's "Default" restores: Cfg/default.cfg alone. -local function defaults() - return configFiles({ "default.cfg" }) -end - --- ===== The moves ============================================================================== - -local function copy(t) - 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 - --- A game's swapScene is a function of the difficulty, the level, and the scene: Tron plays a --- different scene of five levels on the two easier settings. Nothing else goes into it, so every --- answer can be read here and written as a table -- difficulty, then level, then the scene asked --- for, giving the scene played -- and a game whose only hook is this one needs no Lua at play. --- Only the answers that differ are kept, so a game that swaps nothing writes nothing. -local function swaps() - if type(env.swapScene) ~= "function" then - return nil - end - local out = {} - local found = false - - for difficulty = 0, 3 do - local byLevel = {} - - for level = 1, (env.finalstage or 0) do - local scenes = (env.Level[level] or {})[4] or 0 - local byScene = {} - - for scene = 1, scenes do - env.dip_Difficulty = difficulty - env.iLevel = level - env.iScene = scene - env.bSwap = false - local ok = pcall(env.swapScene) - - if ok and (type(env.iScene) == "number") and (env.iScene ~= scene) then - byScene[scene] = env.iScene - found = true - end - end - if next(byScene) then - byLevel[level] = byScene - end - end - if next(byLevel) then - out[difficulty] = byLevel - end - end - env.dip_Difficulty = nil - env.iLevel = nil - env.iScene = nil - env.bSwap = nil - - return found and out or nil -end - --- ===== Writing ================================================================================ - -local out = {} - --- The original author's credits, from the game's own games.dat and script header (forgeCredits.lua --- beside this file), so a port names whoever made the game. -dofile((arg[0]:match("^(.*[/\\])") or "") .. "forgeCredits.lua") - - -local function put(line) - out[#out + 1] = line -end - - -local function putCredits() - local line = creditsFor(dir, main) - - if line then - put(line) - end -end - -local function lua(value, indent) - indent = indent or "" - if type(value) == "table" then - 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 level row as the description keeps it: the one place its fields are written down, the --- runtime building the loops' positional rows from it. play and playBy are its scenes, for a --- level the game can reach. -local function levelOf(level, play, playBy) - return { title = level[1], intro = level[2], introEnd = level[3], scenes = level[4], mirror = level[5], deathMirror = level[6], replay = level[7], selectStill = level[8], play = play, playBy = playBy } -end - --- Every row the game declares, those past its last stage and a secret one it cannot reach --- included, since the game's own Lua may read them. A map-mode game's levels are functions of --- its main.singe, run at play; nothing to read here. -if not rdg then - for index, level in pairs(env.Level) do - if (type(index) == "number") and (index ~= env.levelSecret) then - local play, playBy = nil, nil - - if index <= env.finalstage then - play, playBy = scenesOf(index) - end - levels[index] = levelOf(level, play, playBy) - end - end - if env.Level[env.levelSecret] then - local play, playBy = nil, nil - - if env.AllowSecret then - play, playBy = scenesOf(env.levelSecret) - end - levels.secret = levelOf(env.Level[env.levelSecret], play, playBy) - end -end - -local tiers = {} - -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. --- The game's boards from Cfg/hscore.cfg, as the framework reads them: ten lines each, a blank --- line between, for the normal game, the life-bar game, and the survivor game; then, after the --- trophy counts, each level's best percent ("name,percent!difficulty"). -local function boards() - local found = { normal = {}, lifeBar = {}, survivor = {}, percent = {} } - local order = { "normal", "lifeBar", "survivor" } - local handle = io.open(dir .. "/Cfg/hscore.cfg", "r") - local which = 1 - - if handle then - for line in handle:lines() do - local who, score = line:match("^%s*([^,]+),(%d+)%s*$") - local board = found[order[which]] - - local best, percent, difficulty = line:match("^%s*([^,]+),(%d+)!(%a)%s*$") - - if board and who then - board[#board + 1] = { who, tonumber(score) } - if #board == 10 then - which = which + 1 - end - elseif best then - found.percent[#found.percent + 1] = { best, tonumber(percent), difficulty } - end - end - handle:close() - 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 table literal of Author.singe's, by its local's name: the runtime's own names and constants, --- read from the runtime so they are written once. -local authorSource = nil - -local function authorLiteral(name) - if authorSource == nil then - local path = (arg[0]:match("^(.*[/\\])") or "") .. "../forge/Author.singe" - local handle = io.open(path, "rb") or error("cannot read " .. path) - - authorSource = handle:read("a") - handle:close() - end - local body = authorSource:match("\nlocal " .. name .. " = (%b{})") or error("no " .. name .. " in Author.singe") - - return load("return " .. body, name, "t", {})() -end - --- The clips and stills under Forge's names, out of the settings: the names are Author.singe's --- own QTE_CLIPS table, read from there so they are written once; a still is a frame global --- named by what follows "frame"; the attract steps are offsetIntro01, 02, and on. -local clips, stills = {}, {} - -do - local names = authorLiteral("QTE_CLIPS") - - if not rdg then - for _, clip in ipairs(names) do - if settings[clip[2]] ~= nil then - clips[clip[1]] = { settings[clip[2]], settings[clip[3]] } - settings[clip[2]] = nil - settings[clip[3]] = nil - end - end - local attract = {} - - while settings[string.format("offsetIntro%02d", #attract + 1)] ~= nil do - local step = #attract + 1 - - attract[step] = { settings[string.format("offsetIntro%02d", step)], settings[string.format("offsetIntro%02dend", step)] } - settings[string.format("offsetIntro%02d", step)] = nil - settings[string.format("offsetIntro%02dend", step)] = nil - end - if #attract > 0 then - clips.attract = attract - end - for key, value in pairs(settings) do - local name = key:match("^frame(%u%w*)$") - - if name and (type(value) == "number") then - stills[name:sub(1, 1):lower() .. name:sub(2)] = value - settings[key] = nil - end - end - end -end - --- The other named fields, by Author.singe's QTE_FIELDS: each value leaves the settings for its --- group (scoring, lifeBar, windowDelay, order, deaths, secret, video). -local groups = {} - -if not rdg then - for _, field in ipairs(authorLiteral("QTE_FIELDS")) do - local group, name = field[1]:match("^(%w+)%.(%w+)$") - - if settings[field[2]] ~= nil then - groups[group] = groups[group] or {} - groups[group][name] = settings[field[2]] - settings[field[2]] = nil - end - end -end - --- A setting equal to the constant the runtime lays down before the settings (Author.singe's QTE --- table, and HELD over it for a held game) is a copy of it, and is left out. -do - local constants = {} - - for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do - for key, value in pairs(authorLiteral(tableName)) do - if (key ~= "FLAG_OF") and (key ~= "BRANCH") then - constants[key] = value - end - end - end - for key, value in pairs(settings) do - if (type(value) ~= "table") and (constants[key] == value) then - settings[key] = nil - end - end -end - --- The segmented lineage's runtime reads no settings (its game's own Lua declares them all), so --- its descriptions carry none (see the qte table below). --- One source for each: the levels are in levels, and a dip is in dips (the runtime lays the dips --- over the settings, so a copy here would only look like it mattered). -if not rdg then - settings.Level = nil -end -for key in pairs(dips or {}) do - settings[key] = nil -end --- (A game that sets its rate by number rather than by name has no MovieFPS; under the --- hand-written games' catch-all stubs the name answers as a function, which is not data.) -settings.MovieFPS = (type(env.MovieFPS) == "number") and env.MovieFPS or nil - --- 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 - --- The add-ons, last of all: they carry the hooks the loop calls, and a hook that is a function --- of the state can be read here rather than run at play (see swaps). They are loaded after the --- settings are taken, because their own load-time globals (a sound handle, a map cursor) belong --- to them and not to the game's declarations -- a snapshot that carried them would describe a --- state the game never starts in. A file that will not load under the stubs is no loss; the --- game still names it and the loop still runs it. --- The functions they define or replace are the hooks the loop calls into the game's own Lua, which --- the ledger names (FORGE_LEDGER, below). -local hooks = {} - -do - local chunk = loadfile(dir .. "/Script/addons.singe", "t", env) - - if chunk then - local before = {} - - for key, value in pairs(env) do - before[key] = value - end - pcall(chunk) - -- A hook whose body is only blank lines and comments is a stub, and does nothing. - local source = {} - local handle = io.open(dir .. "/Script/addons.singe", "rb") - - for line in (handle and handle:read("a") or ""):gmatch("([^\n]*)\n?") do - source[#source + 1] = line - end - if handle then - handle:close() - end - for key, value in pairs(env) do - if (type(value) == "function") and (before[key] ~= value) then - local info = debug.getinfo(value, "S") - local body = {} - - for at = info.linedefined + 1, info.lastlinedefined - 1 do - body[#body + 1] = (source[at] or ""):gsub("%-%-.*$", "") - end - -- The one-line form, "function name() end", has its body on its own line. - if info.linedefined == info.lastlinedefined then - body[1] = (source[info.linedefined] or ""):gsub("%-%-.*$", ""):gsub("^.-%b()", ""):gsub("end%s*$", "") - end - hooks[#hooks + 1] = { name = key, empty = not table.concat(body, " "):find("%S") } - end - end - table.sort(hooks, function(a, b) return a.name < b.name end) - end -end - --- Where the games the qte behaviour plays differ, the description says which way, by name --- (FORGE.md 15.4): the runtime never asks which framework or version a game came from. Only --- the qte loop reads these; the other loops' games carry none. -local function optionsFor() - if alg or timetraveler then - return nil - end - local options = {} - - -- How the qte behaviour judges and runs this game; the default, windows, is left unsaid. - options.style = (timegal and "checkpoints") or (sdq and "trophies") or (rdg and "segments") or (kimmy and "held") or nil - - if frameworkVersion == "3.32b" then - options.gameOverAfterContinue = "seek" - options.secretInputs = { "p1BUTTON4", "p1UP" } - end - if mazescater then - options.hintSeconds = 4 - options.alternateMashRate = 6 - options.mashAnySwitch = true - options.doubleKind = 34 - end - - return (next(options) ~= nil) and options or nil -end - -local shipped = defaults() - -local qte = { - script = main, - addons = (not rdg) and "Script/addons.singe" or nil, - options = optionsFor(), - hsDR = kimmy and dieAndRetry() or nil, - saves = (not rdg) and saves() or nil, - settings = (not rdg) and settings or nil, - clips = next(clips) and clips or nil, - stills = next(stills) and stills or nil, - scoring = groups.scoring, - lifeBar = groups.lifeBar, - windowDelay = groups.windowDelay, - order = groups.order, - deaths = groups.deaths, - secret = groups.secret, - video = groups.video, - dips = dips, - defaults = (not rdg) and next(shipped) and shipped or nil, - swaps = (not rdg) and swaps() or nil, - highScores = rdg and RDG_BOARD or nil, - boards = (not rdg) and boards() or nil, - tweaks = rdg and RDG_TWEAKS[name] or nil, - trophyOrder = sdq and sdqOrder() or nil, - percents = sdq and RDG_EXTRA or nil, - flows = alg and algLevels() or nil, - levels = (not rdg) and levels or nil -} - --- ===== The ledger =============================================================================== --- --- FORGE_LEDGER=<file> writes, instead of the description, what the title uses and how this --- converter maps each thing (FORGE.md section 17), one line each: a status, a capability, and a --- detail. "forge" is a field of Forge's own model; "framework" is something Forge plays under --- the framework's names or numbers (a dip, a leftover global, a move kind, a style); "lua" is --- something the game's own Lua still does. util/forgeLedger.py gathers these over the library. - --- The judge (FORGE.md 15.4) each framework move kind is, by the kind's name in Author.singe's QTE --- table, or HELD's for a held game. -local KIND_JUDGES = { - UP = "press.direction", DOWN = "press.direction", LEFT = "press.direction", RIGHT = "press.direction", - BUTTON1 = "press.button", BUTTON2 = "press.button", BUTTON3 = "press.button", BUTTON4 = "press.button", - UPLEFT = "press.diagonal", UPRIGHT = "press.diagonal", DOWNLEFT = "press.diagonal", DOWNRIGHT = "press.diagonal", - ACTUP = "chord.buttonDirection", ACTDOWN = "chord.buttonDirection", ACTLEFT = "chord.buttonDirection", ACTRIGHT = "chord.buttonDirection", - COMBO = "chord", MASH = "mash.one", MASHMIN = "mash.one", MASHMAX = "mash.one", - MASH2 = "mash.two", MASH2MIN = "mash.two", MASH2MAX = "mash.two", RUN = "mash.run", RUNMIN = "mash.run", RUNMAX = "mash.run", - HOLDUP = "hold.direction", HOLDDOWN = "hold.direction", HOLDLEFT = "hold.direction", HOLDRIGHT = "hold.direction", - HOLDBUT = "hold.button", HOLD = "hold", LETGO = "letGo", DOUBLE = "double", MULTI = "count", - LOOP = "circle", LOOPLEFT = "circle", LOOPRIGHT = "circle", UD = "sequence", DU = "sequence", LR = "sequence", RL = "sequence", B1B2 = "sequence", - CHOOSE = "choose", PATH = "path", YESNO = "yesNo", TIMED = "timed", MTIMED = "timed.multi", - ANYTHING = "anything", NOTHING = "nothing", SKIP = "skip", - WAY = "jump", WAYOUT = "jump", TOLEVEL = "jump", TOSCENE = "jump" -} - - -local function ledger(path) - local lines = {} - local seen = {} - - local function use(status, capability, detail) - local key = status .. "\t" .. capability .. "\t" .. (detail or "") - - if not seen[key] then - seen[key] = true - lines[#lines + 1] = key - end - end - - local family = (videoland and "videoland") or (timetraveler and "timetraveler") or (alg and "alg") or ("qte." .. ((qte.options and qte.options.style) or "windows")) - - use("forge", "family." .. family, frameworkVersion) - -- The moves: every row's kind, as the judge it is. - local names = {} - - for _, tableName in ipairs(kimmy and { "QTE", "HELD" } or { "QTE" }) do - for key, value in pairs(authorLiteral(tableName)) do - if KIND_JUDGES[key] and (type(value) == "number") then - names[value] = key - end - end - end - local function moves(value) - for key, item in pairs(value) do - if (key == "moves") and (type(item) == "table") then - for _, row in ipairs(item) do - if (type(row) == "table") and (type(row[3]) == "number") then - local kind = names[row[3]] - - if kind then - use("framework", "judge." .. KIND_JUDGES[kind], kind) - else - -- Not a kind Forge names: the framework's own constants of that value, - -- as candidates for what it is. - local candidates = {} - - for key, value in pairs(env) do - if (value == row[3]) and (type(key) == "string") and key:match("^%u[%u%d]+$") then - candidates[#candidates + 1] = key - end - end - table.sort(candidates) - use("framework", "judge.unnamed", "kind " .. row[3] .. ((#candidates > 0) and (" (" .. table.concat(candidates, " or ") .. ")") or "")) - end - end - end - elseif type(item) == "table" then - moves(item) - end - end - end - moves(levels) - if qte.swaps then - use("forge", "scene.swaps") - end - -- The screens and the named groups. - for clip in pairs(clips) do - use("forge", "screen." .. clip) - end - for still in pairs(stills) do - use("forge", "still." .. still) - end - for group, fields in pairs(groups) do - for field in pairs(fields) do - use("forge", group .. "." .. field) - end - end - -- Persistence. - if qte.boards or qte.highScores then - use("forge", "persist.boards") - end - if qte.saves then - use("forge", "persist.saves") - end - if qte.hsDR then - use("framework", "persist.dieAndRetryRecords") - end - -- What is still the framework's: its settings, the globals nothing names, its style, and the - -- per-title tweaks and tables of the map-mode lineage. - for dip in pairs(dips or {}) do - use("framework", "setting." .. dip) - end - for key in pairs(qte.settings or {}) do - use("framework", "global." .. key) - end - for option in pairs(qte.options or {}) do - use((option == "style") and "framework" or "forge", "option." .. option, (option == "style") and qte.options.style or nil) - end - for tweak in pairs(qte.tweaks or {}) do - use("framework", "tweak." .. tweak) - end - if qte.trophyOrder then - use("framework", "order.trophies") - end - if qte.percents then - use("framework", "scoring.percents") - end - if qte.flows then - use("framework", "flows.handWritten", main:gsub("%.singe$", "") .. ".levels.lua") - end - -- What the game's own Lua still does. - if alg or videoland or timetraveler then - use("lua", "vocabulary", "Vocabulary.singe") - elseif rdg then - use("lua", "levelFunctions", main) - end - -- swapScene is run here, over every level, scene, and difficulty, into scene.swaps (see swaps). - for _, hook in ipairs(hooks) do - if hook.empty then - use("framework", "hook." .. hook.name, "empty") - elseif hook.name == "swapScene" then - use("forge", "scene.swaps", "read from swapScene") - else - use("lua", "hook." .. hook.name, "Script/addons.singe") - end - end - if qte.script and not (alg or videoland or timetraveler) then - use("lua", "script", qte.script) - end - table.sort(lines) - local handle = assert(io.open(path, "w")) - - handle:write(table.concat(lines, "\n") .. "\n") - handle:close() -end - - -if os.getenv("FORGE_LEDGER") then - ledger(os.getenv("FORGE_LEDGER")) - return -end - -if videoland then - -- The objects are set up by the game's own reset; the rooms, furniture, and directions - -- stand as the script declares them. - env.resetGameData() - put("-- " .. name .. ", ported to Forge from its original script by the port converter:") - put("-- the game's rooms, objects, and furniture as data for the videoland behaviour, which plays its loop") - put("-- under the console look; both live in Vocabulary.singe beside this file.") - put("return {") - put("\ttitle = " .. lua(name) .. ",") - putCredits() - put("\tvocabulary = \"Vocabulary.singe\",") - put("\tplayers = 1,") - put("\tlayers = { { kind = \"disc\" }, { kind = \"overlay\" } },") - 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 "qte" - -put("-- " .. (rawget(env, "Title") or name) .. ", ported to Forge from its original script by the port converter:") -put("-- the game's tables as data for the " .. loop .. " behaviour, which plays the game's loop.") -put("return {") -put("\ttitle = " .. lua(name) .. ",") -putCredits() -if alg then - -- The title's mechanics and screens are a vocabulary beside its script. - put("\tvocabulary = \"Vocabulary.singe\",") -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 = {") --- The qte behaviour's games draw their HUD with the qteHud look (FORGE.md 14.15), from the game's --- own pictures and font; the other loops' games draw none of it yet. -put("\t\tgame = { look = { kind = \"" .. ((loop == "qte") and "qteHud" or "none") .. "\" }, behaviours = { { kind = \"" .. loop .. "\" } } }") --- The HUD's second font (the options' Font row other than the game's own) is the framework's --- Media/Font/default.ttf, which the port cannot reach: it travels as the title's Fonts/hudDefault.ttf. -if loop == "qte" then - local font, found = frameworkFile:gsub("/Script/globals%.singe$", "/Media/Font/default.ttf") - local from = (found == 1) and io.open(font, "rb") or nil - local into = env.MYDIR .. "/Fonts/hudDefault.ttf" - - if from and not io.open(into, "rb") then - local bytes = from:read("a") - local handle = io.open(into, "wb") or error("cannot write " .. into) - - handle:write(bytes) - handle:close() - end - if from then - from:close() - end -end -put("\t},") -put("") -put("\trooms = {") -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 deleted file mode 100644 index d3fb75f1f..000000000 --- a/util/forgePorts.py +++ /dev/null @@ -1,321 +0,0 @@ -#!/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"} -# A title proved to play from its description alone (util/forgeStandalone.py writes the list): its -# port carries none of the game's Lua, and the description in it names none. What goes with the -# script is the Script folder it kept its add-ons in and the Fonts and Overlay folders only the -# game's own drawing opened; the loop draws with the engine's font, and its eight sounds come from -# Sounds, so that stays. -STANDALONE_LIST = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "testScripts", "ports", "standalone.txt") -HOSTING = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$') - - -def standalone(): - names = set() - if os.path.isfile(STANDALONE_LIST): - for line in open(STANDALONE_LIST): - line = line.strip() - if line and not line.startswith("#"): - names.add(line) - return names - - -STANDALONE = standalone() - - -def unhost(description): - # The description in the port, with the lines that name the game's Lua taken out. - if not os.path.isfile(description): - return - kept = [line for line in open(description, errors="replace").read().splitlines(True) if not HOSTING.match(line)] - with open(description, "w") as out: - out.writelines(kept) - - -def hostedFiles(description): - # What a description names beside itself, so a standalone port can leave it behind: the - # game's script, and the folders that went with it. - text = open(description, errors="replace").read() - named = set(re.findall(r'^\s*(?:script|addons)\s*=\s*"([^"]+)"', text, re.M)) - out = {"Fonts", "Overlay"} - for name in named: - out.add(name.split("/")[0]) - return out - - -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()) - if name in STANDALONE: - leave |= hostedFiles(path) - 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()) - if title in STANDALONE: - leave |= hostedFiles(path) - 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) - - # A standalone port's description names no Lua, so the loop reads its snapshot instead of - # loading files that are no longer beside it. - for description, _ in builds: - if os.path.basename(os.path.dirname(description)) in STANDALONE or os.path.basename(os.path.dirname(os.path.dirname(description))) in STANDALONE: - unhost(description) - - # 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, "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/forgeShots.py b/util/forgeShots.py deleted file mode 100644 index 7afa7c714..000000000 --- a/util/forgeShots.py +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env python3 -# Puts the tutorial pictures into the book: every NN-name.png a tutorial scene took (in -# screenshots/tutN/, where the harness leaves them) is copied to docs/images/tutorials/, scaled to -# the width a page needs and reduced to a palette, so ten tutorials of full-window screenshots do -# not make Forge.pdf ten times its size. -# -# python3 util/forgeShots.py [screenshots/tut1 ...] -# -# With no arguments every screenshots/tut* folder is taken. - -import glob -import os -import sys - -from PIL import Image - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -OUT = os.path.join(ROOT, "docs", "images", "tutorials") -WIDTH = 768 - - -def place(path): - img = Image.open(path).convert("RGB") - if img.width > WIDTH: - img = img.resize((WIDTH, img.height * WIDTH // img.width), Image.LANCZOS) - img = img.quantize(colors=128, method=Image.Quantize.MEDIANCUT) - img.save(os.path.join(OUT, os.path.basename(path)), optimize=True) - - -if __name__ == "__main__": - folders = sys.argv[1:] or sorted(glob.glob(os.path.join(ROOT, "screenshots", "tut*"))) - os.makedirs(OUT, exist_ok=True) - count = 0 - for folder in folders: - for path in sorted(glob.glob(os.path.join(folder, "[0-9][0-9]-*.png"))): - place(path) - count += 1 - print("%d pictures into %s" % (count, OUT)) diff --git a/util/forgeStandalone.py b/util/forgeStandalone.py deleted file mode 100644 index d22f16cc4..000000000 --- a/util/forgeStandalone.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -# Which Forge ports play from their description alone, with none of the game's own Lua? -# -# The qte behaviour's windows and held styles load only what the description names, so deleting its "script" and -# "addons" lines leaves the loop nothing but the snapshot the converter wrote. This runs each -# framework title that way, against its original, through the same comparison the ports are proved -# with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt. -# util/forgePorts.py reads that list and assembles those ports without the game's Lua. -# -# python3 util/forgeStandalone.py [<title> ...] (no arguments does them all) -# python3 util/forgeStandalone.py --hosted [<title> ...] -# -# --hosted runs each title as it ships instead, with its Lua where it is: the check to run after -# regenerating descriptions, since a converter that has learned something writes a description the -# loop reads differently. It writes no list. -# -# SINGE the engine binary (default: the harness picks the newest build) -# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) -# -# A title is tested on a hard-linked copy of itself, so the library is never edited and the videos -# are not duplicated. A title whose description still needs its Lua stays off the list and keeps -# it: startConf drawing at random and specialScore's own scoring are code, not data. -import os -import re -import shutil -import subprocess -import sys -import tempfile - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported")) -LIST = os.path.join(REPO, "testScripts", "ports", "standalone.txt") -HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$') - - -def titles(): - """Every description the qte behaviour plays, as (name, family, game argument, - description path). The library's own are a directory and a script; a Hypseus title is its - folder under hypseus/.""" - found = [] - for root, dirs, files in os.walk(LIBRARY): - for name in sorted(f for f in files if f.endswith(".forge")): - path = os.path.join(root, name) - text = open(path, errors="replace").read() - loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text) - if not loop or loop.group(1) != "qte": - continue - # The segmented styles run the game's own level functions every play: never standalone. - if re.search(r'style = "(segments|checkpoints|trophies)"', text): - continue - script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M) - if not script: - continue - relative = os.path.relpath(path, LIBRARY) - if relative.startswith("hypseus" + os.sep): - title = relative.split(os.sep)[1] - found.append((title, "hypseus", title, path)) - else: - folder = relative.split(os.sep)[0] - found.append((folder, "karis", "%s/%s" % (folder, script.group(1)), path)) - return found - - -def strip(source, into): - """The title, hard-linked so no video is copied, with its description's hosting lines gone. - The description itself is replaced rather than edited, since a hard link is shared.""" - os.makedirs(into, exist_ok=True) - target = os.path.join(into, os.path.basename(source)) - shutil.rmtree(target, ignore_errors=True) - subprocess.run(["cp", "-al", source, target], check=True) - return target - - -def unhost(descriptions): - for path in descriptions: - kept = [line for line in open(path, errors="replace").read().splitlines(True) if not HOSTED.match(line)] - os.remove(path) # The link, not the library's file. - with open(path, "w") as out: - out.writelines(kept) - - -def compare(family, game, library, run=None): - # One run directory, emptied between titles: compare.sh makes a fresh one under /tmp for every - # call otherwise, and a sweep of the library leaves a hundred of them behind. - if run: - shutil.rmtree(run, ignore_errors=True) - os.makedirs(run, exist_ok=True) - done = subprocess.run([os.path.join(REPO, "testScripts", "ports", "compare.sh"), family, game], - cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library, **({"SINGE_RUN": run} if run else {})), - capture_output=True, text=True, timeout=1800) - out = done.stdout + done.stderr - if "plays as the original does" in out: - return True, [line for line in out.splitlines() if "plays as" in line][0] - first = [line for line in out.splitlines() if ("differ" in line) or ("Error" in line) or ("FAIL" in line)] - return False, first[0] if first else "no verdict" - - -def main(): - wanted = sys.argv[1:] - hosted = "--hosted" in wanted - - if hosted: - wanted.remove("--hosted") - rows = [row for row in titles() if not wanted or row[0] in wanted] - if not rows: - sys.exit("forgeStandalone: no titles to test") - work = tempfile.mkdtemp(prefix="forgeStandalone.") - run = os.path.join(work, "run") - passed = [] - - for title, family, game, path in rows: - if hosted: - # As it ships: the library itself, nothing copied, nothing stripped. A Hypseus - # title's library is the hypseus folder inside it, which is what compare.sh looks - # under; handing it the root leaves it unable to find the video. - ok, saying = compare(family, game, os.path.join(LIBRARY, "hypseus") if family == "hypseus" else LIBRARY, run) - print("%-28s %s" % (title, "agrees" if ok else "DIFFERS"), flush=True) - print(" %s" % saying, flush=True) - if ok: - passed.append(title) - continue - # The title, and for a Hypseus one the hypseus/ folder the harness looks under. - if family == "hypseus": - library = os.path.join(work, "hypseus") - copied = strip(os.path.join(LIBRARY, "hypseus", title), library) - else: - library = work - copied = strip(os.path.join(LIBRARY, title), library) - inside = [os.path.join(r, f) for r, _, fs in os.walk(copied) for f in fs if f.endswith(".forge")] - unhost(inside) - ok, saying = compare(family, game, library, run) - print("%-28s %s" % (title, "STANDS ALONE" if ok else "needs its Lua"), flush=True) - print(" %s" % saying, flush=True) - if ok: - passed.append(title) - shutil.rmtree(copied, ignore_errors=True) - shutil.rmtree(work, ignore_errors=True) - - if hosted: - print("\n%d of %d agree as they ship" % (len(passed), len(rows))) - elif not wanted: - with open(LIST, "w") as out: - out.write("# Titles whose Forge port plays from its description alone, proved by\n") - out.write("# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua\n") - out.write("# out of these ports. Regenerate the list; do not edit it by hand.\n") - # A folder with two games in it (Chantze's Stone, Fire and Ice's two cuts) is tested - # once per description and named once here. - for title in sorted(set(passed)): - out.write(title + "\n") - print("\n%d of %d stand alone, %d titles; written to %s" % (len(passed), len(rows), len(set(passed)), LIST)) - else: - print("\n%d of %d stand alone (the list is only written by a full run)" % (len(passed), len(rows))) - - -if __name__ == "__main__": - main() diff --git a/util/forgeVocabulary.lua b/util/forgeVocabulary.lua deleted file mode 100644 index 558d61e68..000000000 --- a/util/forgeVocabulary.lua +++ /dev/null @@ -1,71 +0,0 @@ --- Writes the Forge vocabulary -- every layer, look, behaviour, event, condition, and action the --- manifest in forge/Author.singe declares -- as AsciiDoc, for docs/Forge.adoc to include. --- The manifest is the one source of truth; this keeps the manual from drifting from it. --- --- lua5.4 util/forgeVocabulary.lua > docs/ForgeVocabulary.adoc --- --- Author.singe touches nothing of the engine when it is loaded, only when a game begins, so plain --- Lua can read it. - -local function keysOf(t) - local keys = {} - - for key in pairs(t or {}) do - keys[#keys + 1] = key - end - table.sort(keys) - - return keys -end - - --- A parameter and its type, or the words it takes when the manifest lists them. -local function paramsText(params, choices) - local parts = {} - - for _, key in ipairs(keysOf(params)) do - local words = (choices or {})[key] - - parts[#parts + 1] = "`" .. key .. "` (" .. (words and table.concat(words, ", ") or params[key]) .. ")" - end - if #parts == 0 then - return "--" - end - - return table.concat(parts, ", ") -end - - -local function section(title, set, extra) - print("=== " .. title) - print("") - print('[cols="1,3,2' .. (extra and ',1' or '') .. '"]') - print("|===") - print("| Name | What it does | Parameters" .. (extra and (" | " .. extra) or "")) - print("") - for _, name in ipairs(keysOf(set)) do - local entry = set[name] - - print("| `" .. name .. "`") - print("| " .. (entry.help or "")) - print("| " .. paramsText(entry.params or entry.filter, entry.choices)) - if extra then - print("| " .. (entry.waits and "yes" or "")) - end - print("") - end - print("|===") - print("") -end - - -dofile("forge/Author.singe") - -print("// Generated by util/forgeVocabulary.lua from forge/Author.singe. Do not edit.") -print("") -section("Layers", AUTHOR.layers) -section("Looks", AUTHOR.looks) -section("Behaviours", AUTHOR.behaviours) -section("Events", AUTHOR.events) -section("Conditions", AUTHOR.conditions) -section("Actions", AUTHOR.actions, "Waits") diff --git a/util/insertCoinArt.py b/util/insertCoinArt.py index 7a6c7080e..71c5ede10 100644 --- a/util/insertCoinArt.py +++ b/util/insertCoinArt.py @@ -286,7 +286,16 @@ KART_TEAMS = [((230, 40, 40), (140, 20, 30)), ((40, 110, 240), (20, 50, 140)), ( # The boss, drawn larger and smoothly: a core, an arm, and a turret, each turned by the demo. -def bossCore(size=96): +# The modern eras' sprites (the boss, its bullets, the gallery's targets) are drawn this many pixels to +# a design unit of the demo (demos/kit.lua, K.sprite), for 1920 by 1080; the pixel art is not. +DENSITY = 2.25 + + +def dense(v): + return round(v * DENSITY) + + +def bossCore(size=dense(96)): y, x = np.mgrid[0:size, 0:size] + 0.5 c = size / 2 d = np.sqrt((x - c) ** 2 + (y - c) ** 2) / c @@ -307,24 +316,25 @@ def bossCore(size=96): def bossArm(w=96, h=24): - img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + img = Image.new("RGBA", (dense(w), dense(h)), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - d.polygon([(0, 4), (w - 16, 6), (w - 4, h // 2), (w - 16, h - 6), (0, h - 4)], fill=(90, 40, 130, 255), outline=(220, 160, 255, 255)) + d.polygon([(0, dense(4)), (dense(w - 16), dense(6)), (dense(w - 4), dense(h / 2)), (dense(w - 16), dense(h - 6)), (0, dense(h - 4))], fill=(90, 40, 130, 255), outline=(220, 160, 255, 255), width=dense(1)) for i in range(4): - d.rectangle([10 + i * 18, 9, 18 + i * 18, h - 9], fill=(255, 120, 40, 255)) + d.rectangle([dense(10 + i * 18), dense(9), dense(18 + i * 18), dense(h - 9)], fill=(255, 120, 40, 255)) return img def turret(size=28): - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + img = Image.new("RGBA", (dense(size), dense(size)), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - d.ellipse([2, 2, size - 3, size - 3], fill=(60, 200, 220, 255), outline=(220, 255, 255, 255)) - d.rectangle([size // 2 - 3, size // 2, size // 2 + 2, size - 1], fill=(30, 80, 110, 255)) - d.ellipse([size // 2 - 5, size // 2 - 5, size // 2 + 4, size // 2 + 4], fill=(255, 255, 180, 255)) + h = size / 2 + d.ellipse([dense(2), dense(2), dense(size - 3), dense(size - 3)], fill=(60, 200, 220, 255), outline=(220, 255, 255, 255), width=dense(1)) + d.rectangle([dense(h - 3), dense(h), dense(h + 2), dense(size - 1)], fill=(30, 80, 110, 255)) + d.ellipse([dense(h - 5), dense(h - 5), dense(h + 4), dense(h + 4)], fill=(255, 255, 180, 255)) return img -def bullet(size=12): +def bullet(size=dense(12)): y, x = np.mgrid[0:size, 0:size] + 0.5 d = np.sqrt((x - size / 2) ** 2 + (y - size / 2) ** 2) / (size / 2) img = np.zeros((size, size, 4)) @@ -554,11 +564,10 @@ def duskSky(w=2048, h=1024): return Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), "RGB") +# One screen row in three darkened: the demo repeats this down a quad over the whole screen. def scanlines(): - img = Image.new("RGBA", (4, 4), (0, 0, 0, 0)) - for x in range(4): - img.putpixel((x, 1), (0, 0, 0, 110)) - img.putpixel((x, 3), (0, 0, 0, 110)) + img = Image.new("RGBA", (1, 3), (0, 0, 0, 0)) + img.putpixel((0, 2), (0, 0, 0, 110)) return img @@ -662,12 +671,13 @@ def fighterSheet(gi, belt, mirror): # A shooting-gallery target: a bullseye of red and white rings on a wooden post. def target(size=64): - img = Image.new("RGBA", (size, size + 28), (0, 0, 0, 0)) + img = Image.new("RGBA", (dense(size), dense(size + 28)), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - d.rectangle([size // 2 - 4, size - 6, size // 2 + 3, size + 27], fill=(140, 90, 50, 255), outline=(70, 40, 20, 255)) + h = size / 2 + d.rectangle([dense(h - 4), dense(size - 6), dense(h + 3), dense(size + 27)], fill=(140, 90, 50, 255), outline=(70, 40, 20, 255), width=dense(1)) for k, colour in enumerate([(200, 20, 30), (250, 250, 250), (200, 20, 30), (250, 250, 250), (200, 20, 30)]): - r = size // 2 - 1 - k * 6 - d.ellipse([size // 2 - r, size // 2 - r, size // 2 + r, size // 2 + r], fill=colour + (255,), outline=(60, 10, 10, 255)) + r = h - 1 - k * 6 + d.ellipse([dense(h - r), dense(h - r), dense(h + r), dense(h + r)], fill=colour + (255,), outline=(60, 10, 10, 255), width=dense(1)) return img diff --git a/util/mirrorballArt.py b/util/mirrorballArt.py index bf2c38bb8..a160c26c7 100644 --- a/util/mirrorballArt.py +++ b/util/mirrorballArt.py @@ -19,6 +19,7 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OUT = os.path.join(REPO, "demos", "mirrorball", "art") SS = 4 # Sprites are drawn this many times larger and shrunk, for smooth edges +DENSITY = 2.25 # The overlay sprites' pixels to a design unit (demos/kit.lua, K.sprite): 1920 by 1080 FONT_WIDE = "/usr/share/fonts/truetype/lato/Lato-BlackItalic.ttf" GOLD_TOP = np.array([255, 250, 210]) GOLD_MID = np.array([200, 130, 30]) @@ -75,6 +76,8 @@ def tile(size=128): def chromeText(text, size, pad=24): + size = round(size * DENSITY) + pad = round(pad * DENSITY) f = ImageFont.truetype(FONT_WIDE, size * SS) box = f.getbbox(text) w = (box[2] - box[0]) // SS + pad * 2 @@ -88,9 +91,9 @@ def chromeText(text, size, pad=24): upper = GOLD_TOP + (GOLD_MID - GOLD_TOP) * (t / 0.5) lower = GOLD_DARK + (GOLD_LOW - GOLD_DARK) * ((t - 0.5) / 0.5) grad = np.repeat(np.where(t < 0.5, upper, lower)[:, None, :], w * SS, axis=1) - outer = ndimage.maximum_filter(m, size=5 * SS) - ring = ndimage.maximum_filter(m, size=2 * SS) - halo = ndimage.gaussian_filter(outer, 7 * SS) + outer = ndimage.maximum_filter(m, size=round(5 * SS * DENSITY)) + ring = ndimage.maximum_filter(m, size=round(2 * SS * DENSITY)) + halo = ndimage.gaussian_filter(outer, 7 * SS * DENSITY) img = np.zeros((h * SS, w * SS, 4)) img[..., :3] = np.array([255, 160, 40]) img[..., 3] = np.clip(halo * 1.4, 0, 1) * 170 @@ -156,7 +159,7 @@ def boing(w=256, h=128): return Image.fromarray(img.astype(np.uint8), "RGB") -def bob(size=64): +def bob(size=round(64 * DENSITY)): y, x = np.mgrid[0:size, 0:size] + 0.5 r = size / 2 - 1 dx = (x - size / 2) / r diff --git a/util/neonCoastArt.py b/util/neonCoastArt.py index 09bfb1e73..864aa374f 100644 --- a/util/neonCoastArt.py +++ b/util/neonCoastArt.py @@ -26,6 +26,7 @@ SKY_H = 1024 SUN_LAT = 9.0 # Degrees above the horizon of the sun's centre SUN_RADIUS = 13.0 # Degrees GRID_CELL = 4.0 # The painted floor grid's cell, in camera heights +DENSITY = 2.25 # The overlay sprites' pixels to a design unit (demos/kit.lua, K.sprite): 1920 by 1080 FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" FONT_WIDE = "/usr/share/fonts/truetype/lato/Lato-BlackItalic.ttf" @@ -223,7 +224,7 @@ def palm(): alpha = a[..., 3] > 0 edge = alpha & ~np.roll(alpha, -SS, axis=1) a[edge] = [255, 120, 170, 255] - return shrink(Image.fromarray(a), w, h) + return shrink(Image.fromarray(a), round(w * DENSITY), round(h * DENSITY)) def lamp(): @@ -233,7 +234,7 @@ def lamp(): d.rectangle(pts([(30, 20), (35, 199)]), fill=(20, 14, 30, 255)) d.line(pts([(33, 22), (8, 14)]), fill=(20, 14, 30, 255), width=4 * SS) d.ellipse(pts([(0, 8), (18, 20)]), fill=(255, 245, 210, 255)) - return shrink(im, w, h) + return shrink(im, round(w * DENSITY), round(h * DENSITY)) # The night road a row at a time, as in the arcade racers: four kinds of row (light and dark road, @@ -268,7 +269,7 @@ def roadStrip(): # A shaded ball for vector bobs: white and grey, so spriteSetTint colours it. -def bob(size=64): +def bob(size=round(64 * DENSITY)): y, x = np.mgrid[0:size, 0:size] + 0.5 cx = cy = size / 2 r = size / 2 - 1 @@ -301,6 +302,8 @@ def glow(size=128, power=2.2): # Lettering in eighties chrome: a sky-to-sunset gradient with a hard horizon line through the middle, # a white outline, a dark outline outside that, and a soft magenta glow behind. def chromeText(text, size, font=FONT_WIDE, pad=24): + size = round(size * DENSITY) + pad = round(pad * DENSITY) f = ImageFont.truetype(font, size * SS) box = f.getbbox(text) w = (box[2] - box[0]) // SS + pad * 2 @@ -316,9 +319,9 @@ def chromeText(text, size, font=FONT_WIDE, pad=24): rowColour = np.where(t < 0.5, sky, sunset) grad = np.repeat(rowColour[:, None, :], w * SS, axis=1) from scipy import ndimage - outer = ndimage.maximum_filter(m, size=5 * SS) - ring = ndimage.maximum_filter(m, size=2 * SS) - halo = ndimage.gaussian_filter(outer, 7 * SS) + outer = ndimage.maximum_filter(m, size=round(5 * SS * DENSITY)) + ring = ndimage.maximum_filter(m, size=round(2 * SS * DENSITY)) + halo = ndimage.gaussian_filter(outer, 7 * SS * DENSITY) img = np.zeros((h * SS, w * SS, 4)) img[..., :3] = np.array([255, 40, 200]) * 1.0 img[..., 3] = np.clip(halo * 1.4, 0, 1) * 180 diff --git a/util/neonCoastCar.sh b/util/neonCoastCar.sh index 380ca48fc..89260f403 100755 --- a/util/neonCoastCar.sh +++ b/util/neonCoastCar.sh @@ -9,6 +9,12 @@ set -euo pipefail REPO=$(cd "$(dirname "$0")/.." && pwd) BIN=$(ls "$REPO"/.builddir/Singe-v*-Linux-x86_64 | head -1) SCRATCH=${SCRATCH:-/tmp/neonCoastCar} +# The sheet's pixels to a design unit of the demo (demos/kit.lua, K.sprite): each frame is 288 design +# units wide, rendered from a view this many times 1024 by 768. +DENSITY=2.25 +RENDER_W=$(python3 -c "print(round(1024 * $DENSITY))") +RENDER_H=$(python3 -c "print(round(768 * $DENSITY))") +FRAME_W=$(python3 -c "print(round(288 * $DENSITY))") rm -rf "$SCRATCH" mkdir -p "$SCRATCH/run/car" @@ -24,13 +30,13 @@ if [ "$SHEET" = "traffic" ]; then SCRIPT=car/traffic.singe fi cd "$SCRATCH/run" -SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 120 "$BIN" -k -s -w -C 1024x768 -x 1024 -y 768 --deterministic -d "$SCRATCH/data" "$SCRIPT" > "$SCRATCH/run.log" 2>&1 || true -python3 - "$SCRATCH/data" "$REPO/demos/neonCoast/art/$SHEET.png" <<'PY' +SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=dummy timeout -k 10 120 "$BIN" -k -s -C ${RENDER_W}x$RENDER_H -x $RENDER_W -y $RENDER_H --deterministic -d "$SCRATCH/data" "$SCRIPT" > "$SCRATCH/run.log" 2>&1 || true +python3 - "$SCRATCH/data" "$REPO/demos/neonCoast/art/$SHEET.png" "$FRAME_W" <<'PY' import glob, os, sys import numpy as np from PIL import Image -data, out = sys.argv[1], sys.argv[2] +data, out, width = sys.argv[1], sys.argv[2], int(sys.argv[3]) shots = {os.path.basename(p)[:-4]: p for p in glob.glob(data + "/**/shot*.png", recursive=True)} takes = sorted({int(k[4:-1]) for k in shots}) frames = [] @@ -49,7 +55,6 @@ for f in frames: mask |= f[..., 3] > 8 ys, xs = np.where(mask) x0, x1, y0, y1 = xs.min() - 2, xs.max() + 3, ys.min() - 2, ys.max() + 3 -width = 288 height = int((y1 - y0) * width / (x1 - x0)) sheet = Image.new("RGBA", (width * len(frames), height), (0, 0, 0, 0)) for i, f in enumerate(frames): diff --git a/util/neonCoastCar.singe b/util/neonCoastCar.singe index d32db8346..0c3e4cfbe 100644 --- a/util/neonCoastCar.singe +++ b/util/neonCoastCar.singe @@ -6,8 +6,8 @@ -- Run headless with --deterministic; it saves car<angle><b|w>.png and quits. dofile("Singe/Framework.singe") -local W = 1024 -local H = 768 +local W = discGetWidth() -- The canvas the shell asks for (util/neonCoastCar.sh) +local H = discGetHeight() local ANGLES = { -30, -20, -10, 0, 10, 20, 30 } local RED = { 215, 14, 30 } -- The traffic: the same car straight from behind in other paints (CAR_TRAFFIC set by the wrapper).