commit ddec1696e2d4ca4948c3f1ef9206fd5bf931a278 Author: Scott Duensing Date: Tue Oct 6 16:57:52 2026 -0500 Forge is now it's own project. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e1f8313 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,90 @@ +# +# Image +# +*.xcf filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.bmp filter=lfs diff=lfs merge=lfs -text +*.avif filter=lfs diff=lfs merge=lfs -text +*.tiff filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.jxl filter=lfs diff=lfs merge=lfs -text +*.pcx filter=lfs diff=lfs merge=lfs -text +*.pnm filter=lfs diff=lfs merge=lfs -text +*.pgm filter=lfs diff=lfs merge=lfs -text +*.qoi filter=lfs diff=lfs merge=lfs -text +*.tga filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.tif filter=lfs diff=lfs merge=lfs -text +*.jbg filter=lfs diff=lfs merge=lfs -text + +# +# Other Binary +# +*.exe filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.dat filter=lfs diff=lfs merge=lfs -text + +# +# Singe game and menu descriptors are Lua text, not binary. +# +games.dat !filter !diff !merge text +menu.dat !filter !diff !merge text + +# +# Fonts +# +*.ttf filter=lfs diff=lfs merge=lfs -text + +# +# Audio +# +*.wav filter=lfs diff=lfs merge=lfs -text +*.flac filter=lfs diff=lfs merge=lfs -text +*.vgz filter=lfs diff=lfs merge=lfs -text +*.nsf filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mod filter=lfs diff=lfs merge=lfs -text +*.it filter=lfs diff=lfs merge=lfs -text +*.itz filter=lfs diff=lfs merge=lfs -text +*.MOD filter=lfs diff=lfs merge=lfs -text +*.med filter=lfs diff=lfs merge=lfs -text +*.xm filter=lfs diff=lfs merge=lfs -text +*.oxm filter=lfs diff=lfs merge=lfs -text +*.raw filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text +*.opus filter=lfs diff=lfs merge=lfs -text + +# +# Document +# +*.odt filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.docx filter=lfs diff=lfs merge=lfs -text + +# +# Video +# +*.mkv filter=lfs diff=lfs merge=lfs -text +*.avi filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.mpg filter=lfs diff=lfs merge=lfs -text + +# +# Archive +# +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.lz filter=lfs diff=lfs merge=lfs -text +*.lzma filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text + +# +# Model +# +*.glb filter=lfs diff=lfs merge=lfs -text +*.stl filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c60fb45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*~ +build/ +*.log +**/__pycache__/ + +# Written by Singe when it is run from here; never source. +/Singe/ +/data/ +screenshots/ diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..b50e549 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,241 @@ +FORGE 1.00 +========== + + Unreleased + + +New Features +------------ + +- Forge is a project of its own, apart from Singe. ./build.sh renders the + book and packs Forge.game with whichever Singe binary it is pointed at, + and testScripts/run.sh runs Forge's tests against the same binary. Forge + needs Singe 3.00 or later. + +- 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. + +- 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. + +- 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. + +- Publishing from Forge writes a new file beside the old one and releases + the old copy only once the new one is in place, so an interrupted write + cannot lose either. + +- 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. + +- 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. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/FORGE.md b/FORGE.md new file mode 100644 index 0000000..aae8d28 --- /dev/null +++ b/FORGE.md @@ -0,0 +1,5403 @@ +# Forge + +The plan for Forge, Singe's authoring tool. Forge is distributed on its own, +beside the engine and never inside it, and is planned on its own for the same +reason: `PLAN.md` is the engine's. Its documentation is `docs/Forge.adoc`; +its code is `forge/`; its tests are `testScripts/scene52.singe` +onward. + +Section 1 is the first design and what was built to it. Section 2 re-reads +that against the whole engine and lays out every kind of game. Section 3 +records the owner's review of section 2 and what changed: nothing here owes +anything to what was built before, and the Sierra and LucasArts adventure is a +first-class target in 2D and in 3D. + + +## 1. Design: authoring tools (written 2026-09-13) + +**The first slice is built (2026-09-13); see "What exists" at the end of this +section.** The rest of what follows is the design it was built to. The owner's +ask: "Singe-based game authoring tools to allow users with very little +programming skill the ability to create games", and, when the first sketch came +back too laserdisc-shaped: "laserdisc games are the 'old' style game. We also +need to think of the future" -- QTE games, hybrids like MACH3, 2D and 3D +platformers, shooters, adventure games, "and more". + +### What the library says is actually hard + +Counted rather than guessed, the way section 51 counted: + +* `CrimePatrol/Script/hitbox-stripbar.singe` is **12,241 lines** of + `civillian[2741] = {295, 111, 307, 135}`. The largest artifact in a + laserdisc game is not code. It is data entry against a video. +* `ActionMax/38AmbushAlley.singe` is **14 lines** -- a gameID, five lengths, a + background frame, two thresholds, four sensor coordinates, and + `dofile(MYDIR .. "/Emulator.singe")`. A complete, shipping game. Five + ActionMax titles share the one engine. + +So the repository already holds both the disease and the cure, and the cure is +the pattern section 51 started: the game kit lifted the *utility* boilerplate +out of 149 copies. This lifts the *structural* boilerplate, and what is left +is data. + +### Do not model genre. Model layers. + +The manual says it three times over: "the disc, the overlay, the GUIs, the 3D +scene and the particles". That is a composite of independent layers, and a +genre is only which of them a game uses: + +``` +light gun disc + overlay +QTE disc + GUI, rules on time windows +MACH3 disc + overlay + particles +2D platformer scene with physicsSet2D, or overlay +3D shooter scene + GUI + particles +adventure disc or scene, + GUI +``` + +The document model therefore mirrors the engine's layer model and the tool +never learns what a genre is. MACH3 stops being a hybrid special case and +becomes an ordinary two-layer document. This is the decision the whole design +rests on: a per-genre tool is N tools. + +### Three nouns cover all of it + +* **Entities** -- a thing on a layer with a position, a look (sprite, model, + text, video) and state. +* **Behaviours** -- reusable bundles attached to an entity, and where the + engine gets handed to non-programmers for nothing. `playerNew`, + `playerMove`, `playerJump`, `playerIsOnGround`, `playerSetStep`, + `playerSetSlope`, `playerSetSwim` already exist and are tested, so a + "Platformer" behaviour is a checkbox over a character controller that + works. So is "Follow Path" over `navAgentMoveTo`, and "Physics Body" over + `bodyNew`. +* **Rules** -- conditions to actions. The event sheet. + +### The vocabulary is registered, not compiled in + +Conditions, actions and behaviours are declared in a manifest -- name, +parameters, the Lua that implements them -- which the editor reads. Adding QTE +support is registering "time is within window" and "button pressed" plus a +couple of actions. **A genre is a pack, not an editor release**, and third +parties can write one. The same goes for layer types, asset types and views. + +The test to apply to every later decision: *when this changes, is it data or is +it a release?* + +### Blockly: considered, not first + +Two problems. Blockly is browser technology and Singe has no web view and +should not grow one. A native block canvas is a great deal of UI work -- +layout, snapping, hit-testing, scrolling -- and RmlUi is a document engine, +excellent at lists (the menu scrolls one) and unproven as a canvas. + +The event sheet gets most of the benefit for a fraction of the cost because it +is a table. The move that keeps the door open: **rules are stored as +structure, never as UI layout**, so a block canvas can be added later as a +second view of the same data. Borrow the two things blocks are genuinely +better at -- nested condition groups with AND/OR, and a small safe expression +syntax with a picker (`score + 100`, `player.x < 50`). Rule lists that allow +only constants are what send people back to code. + +### Compile to Lua; do not interpret + +Walk the rules at build time and emit readable Lua, keeping the source data +beside it in the `.game` for round-tripping. Three reasons: no per-frame +interpreter on a Pi or a handheld; the output is an ordinary Singe game, +debuggable through the ZeroBrane integration in `zbstudio/`; and it keeps the +ladder continuous -- rules and hand-written Lua are the same material. + +### The pressure valve, from the first release + +**A "Lua action" and a "Lua behaviour".** One rule needs something the +vocabulary cannot say? Drop to Lua for that rule and keep the project. +Without it every gap is a feature request and the tool grows without bound. + +### The editor is a Singe game + +* WYSIWYG is literal: same renderer, same video decoder, same fonts, same + overlay coordinates, same light-gun path. Not a preview -- the runtime. +* No second toolchain to build, sign, ship or port. It runs wherever Singe + does, including on the cabinet. +* Play with no export step. +* Precedent: `Tools.singe` is 40 KB of in-engine UI and `MenuDocument.singe` + a full RmlUi document app. + +### What the spike settled (2026-09-13) + +Chrome in RmlUi, canvas in the overlay. **Not RmlUi as the canvas.** + +Proven live, one frame showing all of it: a click on a chrome row consumed by +RmlUi with its Lua handler firing; a click on the canvas falling through to +`onInputPressed`, hit-testing the right rectangle and grabbing it; a click on +empty canvas falling through with nothing under it; motion arriving +throughout. + +Guaranteed by the dispatch rather than merely permitted: + +* `singe.c:15758` calls `_guiMouseMove()` then `_fireMouseMoved()` + **unconditionally**. Motion is never consumed, so the canvas always knows + the pointer even while it is over the GUI. +* `singe.c:15767` offers buttons to the GUI first; they reach the script only + if nothing used them. +* RmlUi has `pointer-events: none | auto`, inherited, if a document ever must + not block the canvas. + +**Re-run under a real window manager (2026-09-13), and the earlier reading was +wrong.** `metacity` is installed, so the spike was repeated under it on an +Xvfb display sized to the overlay, giving an exact 1:1 mapping -- confirmed by +the engine reporting a press at `290,250` for an injected `290,250`. Pointer +motion then arrived properly (17 events) and canvas picking worked perfectly. + +But **nothing reached the RmlUi document at all** -- not `dragstart`, and not +`mouseover`, `mousedown` or a row click either, all of which were added to +bisect it. So "RmlUi drag does not fire" was never the finding; in that +configuration the document was not receiving pointer input in the first place, +and the one earlier run where a row click *did* land had a mistaken coordinate +scale. `guiMouseButton` and `guiMouseMove` feed RmlUi correctly +(`gui.cpp:790`, `818`); the routing above them, `_guiPointer` +(`singe.c:3358`), decides which GUI is under the pointer from the rectangles +drawn last frame, and that is where the trail stops. **Why a full-screen +document drawn with `guiDraw` and `guiSetInput(true)` gets no pointer under +`-w` remains open.** + +Chased further on 2026-09-13 against the *bundled menu*, on the theory that if +it were real then the menu's documented "click a row to select that game" would +be broken in shipped behaviour. That is **not settled**: no run yet has both +delivered pointer motion and clicked after `menuActive()` became true. One run +delivered motion (`pointer 100,120`) but clicked during the intro, when +`menuRowClick` correctly ignores it; every later run, with and without the +pointer grab released, reported `pointer 0,0` throughout even though ManyMouse +reported `Mice Found: 1` through XInput2. So the difficulty is in delivering +synthetic pointer input to this engine under Xvfb, and **no claim should be +made either way about the menu's mouse** until someone clicks a row on real +hardware. That is a ten-second check for a person and has defeated a long +series of attempts here. + +Because of it the event sheet was built **keyboard-first**, which is what the +menu does and what a cabinet needs, and so does not depend on the answer. + +**What that changed in the editor.** The panel's drag tab was first an element +in the document with a `mousedown` handler. On this evidence that is a path +the editor cannot rely on, so the tab is drawn on the *canvas* just outside the +panel and picked with `collidePointRect`, and `scene54` presses it through +`authorEditPress` -- the same door a real pointer goes through. Reaching for +the GUI would have looked fine in a test that called the handler directly. + +**Three harness facts, each of which cost a run:** a spike must quit on +`singeGetTicks`, not a frame count, because with no vsync under Xvfb the frame +rate is unbounded and it finishes before the driver's first move; the window is +not the overlay (`-C 720x480` gave a 960x640 window, and `shoot.sh` forces `-w`, +which changes input behaviour, so the binary had to be run directly); and +`controls.cfg:56` maps the left mouse button to `INPUT_ACTION_3`, not +`ACTION_1`. + +### First slice: two unrelated genres, and not a light gun game + +Build the vertical slice with **a 2D platformer and a QTE video scene**. One +genre teaches nothing about generality -- its assumptions end up in the core and +are only discovered on the second. Two unrelated ones force the layer model, +the behaviours and the rule vocabulary to be real. A light gun game afterwards +is nearly free, and the whole thing does not end up shaped by the past. + +The keyframe point, worth more than most of the rest: nobody types 2,750 +rectangles. Draw a box on frame 100, another on frame 130, interpolate. +`tweenValue` already exists. + +### What exists (2026-09-13) + +The first two slices: **the format, the vocabulary, the compiler, the runtime, +and a first editor.** + +*(Paths below are as first built; everything now lives in `forge/` +and the editor is `Forge.singe`. See the 2026-09-13 evening note at the end.)* + +* **`assets/Author.singe`** -- the runtime a compiled game calls, and the + manifest itself. Layers `world2d`, `overlay`, `disc`; looks `box`, + `sprite`, `text`; behaviours `solid`, `platformer`, `drift`; nine + conditions and nine actions, each declaring its parameters and the Lua it + emits. +* **`assets/AuthorCompile.singe`** -- `authorCompile(game)` returns source, + `authorBuild(description, output)` writes it. Written in Lua rather than as + a `util/` script because the editor will be a Singe game and has to compile + what it is editing without leaving the engine. +* **`testScripts/author/platformer.forge`** and **`qte.forge`** -- the two + unrelated genres, sharing nothing but the three nouns. +* **`testScripts/scene52.singe`** and **`scene53.singe`** -- in the regression + set. 52 compiles both descriptions, checks both parse, and plays the + platformer; 53 plays the QTE over the disc. Both verified in both + directions: the platformer reaches the prize (`score=100`) and the QTE + scores 250 when answered and 0 with a `MISS` when it is not. +* **`assets/AuthorEdit.singe`** and **`AuthorEdit.rml`** -- the editor, built to + the spike: entity list and details in RmlUi, canvas in the overlay, picked + with `collidePointRect`. `authorEditSave()` writes the description back, + `authorEditBuild()` compiles what is on screen. +* **`authorLoad`/`authorSave`** in the compiler, and the invariant the editor + rests on: **a description survives the round trip** -- load, save, load, and + it compiles to the same game byte for byte. Getting there found the bug worth + knowing about the format: a condition is a *mixed* table (`{ "keyHeld", + key = "LEFT" }`, an array part plus named keys) and a serialiser that writes + only the array part silently drops every parameter. +* **The event sheet**, in the same file: `Tab` swaps the panel between the + entities and the rules, the selected rule opens in place with its conditions + and actions under it, and `authorEditRuleNew`, `authorEditRuleAdd`, + `authorEditPartSet` and `authorEditPartDelete` edit them. Adding a part + selects it, and a new part gets a starting value per parameter type, so a + rule compiles the moment it is made. The vocabulary comes from `AUTHOR`, so + the rule editor needs no change when a condition or action is added. + **Driven by keys** (`authorEditKey`) rather than the pointer -- see the + window-manager finding above. +* **`testScripts/scene54.singe`** -- opens the editor on a copy of a real + description, picks an entity, drags it, saves, reloads from disc, and checks + the move reached the compiled game. +* **`testScripts/scene55.singe`** -- builds a rule through the editor + (`keyHeld UP` and `onGround hero`, then `jump hero`), checks the compiled + game carries all three, deletes a condition and checks it is gone again. +* All are embedded and extracted like the other support files, and the manual + has a chapter, "Describing a Game Instead of Writing One". + +**What running it in three configurations taught, which is worth keeping.** +`scene52` first drove its keys by frame count: 100 under `--deterministic`, 0 +under the sanitizer build. Driving on `authorTime()` instead was also wrong, +and failed again as soon as the sanitizer build had a disc to decode -- +`physicsUpdate` accumulates wall clock but takes at most `MAX_STEPS_PER_FRAME` +(4, so 66 ms) per frame, so on a machine that cannot keep up the simulation +deliberately falls behind the clock and a clock-driven script outruns it. +**A test of an authored game has to be driven by what the game is doing**: +hold right, jump when `playerIsOnGround` and past a mark, stop when the prize +is taken. That version gives `score=100 after=2.5s` identically under +`--deterministic`, free-running with a disc, and the sanitizer build with a +disc. Any test of an +authored game has to be written on the clock. + +**Known limits**, so they are not discovered as surprises: a rule is a flat AND +of its conditions, with "either" expressed as two rules; there are no +expressions beyond what the `lua` action carries; `once` is keyed by a tag the +author picks and never resets; the 2D looks draw with `overlayLine` per row, +which is fine for placeholders and is not a sprite pipeline; the editor had no +undo and no way to add or delete an entity (both added that evening, see below); +a rule's conditions are a flat AND, +so "either" is still two rules; and parameter values are set from script rather +than typed into the panel, which is the next thing the rule editor needs. The panel being under part of the world was the earlier limit +and is fixed: it slides, by a tab drawn on the canvas, so nothing is +permanently out of reach. The event sheet +is the next slice and the one that matters most, because rules are where a +non-programmer actually spends their time. + +### Forge as a game (2026-09-13, evening) + +Until this pass `Forge.singe` was a library only: it defined the editor and +nothing ran it, so the menu entry opened on a blank screen. Now: + +* **A chooser** when nothing is open: the descriptions in Forge's data + directory, any in `Forge/` itself (opened as a copy -- packed, they are read + only), and *New game*, which writes `forgeTemplate()` and opens it. +* **Engine callbacks** -- keys to `forgeKey`, the left mouse button and motion + to `forgePress`/`forgeDrag`/`forgeRelease`, the pad on the chooser. +* **Play** (`P`): save, build beside a copy of the runtime, `scriptPush`. The + engine reruns Forge from the top when the game ends, so the open file is + noted in `last.txt` and reopened on the way back. +* **Entities**: add, duplicate, delete, and every field typed through the same + ENTER form a rule's parameters use -- `forgeFields()` says what the fields of + the selection are and how to reach them, and the entity's come from the + manifest (the look's parameters, then each behaviour's as `type.param`). + Renaming an entity renames it in every rule. Sprite looks draw their image. +* **Pickers** for conditions and actions (`C`, `T`), listing the manifest with + its help line; rules reorder with `[` and `]`; the rule's note is editable. +* **Undo** (`U`): a copy of the description before each change, forty deep. +* `scene57` runs Forge as itself -- no `FORGE_LIBRARY` -- and drives the real + callbacks through the chooser, the entity form, the pickers, undo, close and + reopen, and build. +* Fixed on the way: the compiler loaded `Singe/Author.singe`, which never + shipped (it is `Forge/Author.singe`, and the compiler now loads + `AUTHOR_RUNTIME`); `keyHeld` compared a key name against the scancode the + engine delivers and never fired from a real keyboard; `authorTouching` and + the `solid`/`platformer` behaviours read `look.w` on sprite and text looks + that have none (`authorSize` now answers for every look); an unknown look + type ended the game rather than drawing a box. + +Still open, so it is not rediscovered: no expressions beyond the `lua` +action; a rule is a flat AND; `once` never resets; no light gun layer or +keyframed hitboxes yet; the pointer path through the RmlUi document remains +unproven on a real desktop (the canvas path is what the editor relies on); and +the chooser is drawn with `fontPrint`, not the document, because the GUI is +only created once a description is open. + +### Out of scope, stated so it is not assumed + +A web view. A node-graph editor as the first UI. A separate desktop +application. Importing existing hand-written Singe games. Any tool that +cannot be dropped out of into Lua. And an editor grows without limit unless +this list does too: add to it before adding features. + + +## 2. Design: Forge for every kind of game (written 2026-09-13) + +Section 1 was written for the first slice and built to it: a 2D platformer and +a QTE over video, and then an editor that could move a box and type a rule. It +was right about the shape of the thing and silent about most of the engine. The +owner's ask now is broader and specific: "we especially want to be able to +support 3D gun games like House of the Dead. Plan for every game type you think +you can create." This section re-reads 58 against the engine as it actually is, +says what is wrong or missing, extends the model until the whole catalogue below +fits it, and orders the work. + +### What section 1 got right, kept + +* **Layers, not genres.** Still the decision everything rests on. The + catalogue below has thirty-odd types of game and every one of them is a + choice of layers plus vocabulary; none needs a mode switch in the core. +* **Three nouns** -- entities, behaviours, rules -- and a manifest the compiler + and the editor read rather than know. +* **Compile to Lua.** A rail shooter on a Pi cannot afford an interpreter in + its frame either. +* **The editor is a Singe game**, and the `lua` action is the way out. +* **Keyboard first**, since the document pointer path is still unproven. + +### What section 1 got wrong or left out, counted + +The engine registers **502** Lua calls. The manifest wraps **31** of them: three +layers, three looks, three behaviours, nine conditions, nine actions, all of +them 2D. By subsystem, what has no place in a description today: + +| Subsystem | Calls | In the manifest | +|---|---|---| +| Nodes, meshes, materials, lights, models, animation, camera, views | 31+9+20+6+4+10+3+3 | none | +| Physics beyond a static box and the character controller: bodies, joints, vehicles, ragdolls, soft bodies, raycast, triggers | 17+5+18+9+8+5 | none | +| Navigation (mesh, agents, crowd, raycast) | 18 | none | +| Particles (2D and 3D emitters, trails, bursts) | 31 | none | +| Sound, music, MIDI | 17+8+17 | none | +| GUI documents, subtitles | 16+5 | none | +| Video players beyond the disc, video on a material | 22 | none | +| Saves, timers, tweens, the score bezel, the master service (boards, scores) | 7+4+3+7+8 | none | +| Disc | 27 | play, seek, frame window | +| Mice, guns, controllers, MIDI in | 7+8+6 | held keys and switches | + +And the structural gaps, which matter more than the counts: + +1. **Positions are `x, y`.** There is no `z`, so there is no 3D at all. +2. **Every entity is a singleton declared up front.** A shoot-em-up has two + hundred bullets alive; House of the Dead spawns zombies by the dozen and + throws them away. There is no notion of a *type* with *instances*, and no + `spawn`. +3. **Rules only poll.** The engine delivers events -- `onCollision`, + `onTrigger`, `onNavArrived`, `onSoundCompleted`, `onInputPressed`, + `onMouseMoved` -- and the manifest cannot express any of them, so `touching` + is a rectangle test every frame and a key press has no edge. +4. **No state.** An entity has `drive`, `text`, and `visible`. No health, no + ammo, no per-instance variables, and no state machine -- and a state machine + (idle, walk, attack, hit, dead) *is* an enemy. +5. **No time structure.** No timeline, no waves, no camera path, no timers + (`timerAfter` exists and is unused), no levels, no game over. +6. **No expressions and a flat AND.** `once` is keyed by a tag the author picks + and never resets, which breaks the moment something respawns. +7. **No sound, no music, no HUD** beyond a text look, no lives or credits, no + high scores -- the kit and the service (sections 51, 54, 55) are invisible. +8. **The keyframed hitbox** -- the thing section 1 called "worth more than most of the + rest", the answer to the 12,241-line file -- was never built. +9. **Input is two conditions.** No pointer position, no "pressed this frame", + no second gun. A gun game is nothing without the pointer. +10. **The editor** has a 2D canvas, no timeline, no asset list, no 3D view. + +None of it argues against the design. All of it says the vocabulary and the +format have to grow along five axes -- depth, instances, events, state, time -- +and the editor along two -- a timeline and a 3D viewport. + +### The model, extended + +Every extension below is backward compatible: a section 1 description is a +valid section 2 one. Nothing is added that a 2D platformer has to write. + +**Positions.** `x, y, z`. Absent `z` is zero; 2D layers ignore it. +Rotation `rx, ry, rz` and `scale` likewise, all optional. + +**Layers.** Keep `world2d`, `overlay`, `disc`. Add: + +* `scene3d` -- `sceneEnable`, gravity, sky, ambient, fog, exposure, shadows. + Parameters are the `sceneSet*` calls, nothing more. +* `hud` -- an RmlUi document (`guiNew`/`guiLoad`) bound to variables: every + `{{score}}`, `{{lives}}`, `{{self.health}}` in the document is refreshed when + the value changes. The score bezel is the same layer with `bezel = true`. +* `music` -- a track and a volume; `musicPlay` on begin. + +**Types and instances.** `types = { zombie = { look, behaviours, vars } }`. +An entity in `entities` may name `kind = "zombie"` and override fields; a +`spawn` action or a `spawner` behaviour makes more at run time. The runtime +keeps instances in `AUTHOR_ORDER` as now, with an id per instance (`zombie#7`) +and `authorEach("zombie")` to walk them. A rule may be scoped: +`each = "zombie"` runs its conditions and actions once per live instance with +`self` bound, which is how "every zombie that reaches the camera bites" is one +rule and not one per zombie. + +**Per-instance state.** `vars = { health = 3, ammo = 6 }` on a type or an +entity; `self.health` in expressions; `setVar`/`addVar` actions. `state` is a +reserved var: `setState` changes it, `animator` and `sound` behaviours listen +to it, `inState` tests it. + +**Looks.** Keep `box`, `sprite`, `text`. Add `sprite` frames and speed; +`model` (`modelLoad`/`modelInstance`, file, scale, starting clip); `billboard` +(a sprite in the scene, `nodeSetSprite` + `nodeSetBillboard`); `mesh` +(box/sphere/plane/cylinder with a material: colour, texture, metallic, +roughness, unlit); `light` (point/spot/sun with colour, range, cone, shadow); +`particles` (an emitter preset); `text3d` (`nodeSetText`); `video` (a mesh +with `materialSetVideo` -- the disc or a file on a surface); `gui` (a document +on a material, `materialSetGui`). A look declares its own `size(entity)` so +`touching` and the bodies stop assuming `w, h` (the `authorSize` fix of +2026-09-13 is the first step). + +**Behaviours.** The bundles, each a page of Lua over calls that exist: + +| Behaviour | Over | For | +|---|---|---| +| `solid` | `bodyNew` static | walls, floors (exists) | +| `platformer` | player controller, 2D | (exists) | +| `character` | player controller, 3D: move relative to the camera, jump, swim, step, slope | third-person and first-person games | +| `body` | `bodyNew` dynamic: mass, bounce, friction, buoyancy; `bodyApplyImpulse` on demand | crates, balls, debris, projectiles | +| `trigger` | `bodySetTrigger` | checkpoints, doors, kill volumes, weak spots | +| `drift` | (exists) | clouds, platforms | +| `path` | waypoints, speed, loop or once, `tweenValue`; raises `arrived` at each point | moving platforms, patrols, the camera rail | +| `agent` | `navAgentNew`/`navAgentMoveTo`, target entity or point, speed | enemies that walk to the player, crowds | +| `follow` | `nodeLookAt`/lerp toward a target at a distance | companions, homing shots | +| `seek` | straight-line movement toward a target (no nav mesh) | zombies closing on the camera, missiles | +| `vehicle` | `vehicleNew` + wheels, engine, gears, steering | cars, bikes, tanks, boats | +| `thrust` | `bodyApplyForce` along facing, turn rate | ships, planes, hovercraft | +| `health` | `vars.health`, `damage` action, `death` event, optional `ragdoll` on death, `invulnerable` seconds after a hit | anything that can die | +| `shooter` | spawns a `projectile` type from a muzzle offset at a rate, on a switch or a rule | players and turrets | +| `projectile` | speed, life, damage on hit, destroyed on contact | bullets, arrows, fireballs | +| `gun` | the pointer: `mouseGetPosition` (and device index for two guns), 2D hit = `collidePointRect` against types, 3D hit = `sceneUnproject` twice + `physicsRaycast`; raises `hit`/`miss` events with the target; ammo and reload (a button, or a shot off-screen) | light gun games in 2D, over video and in 3D | +| `hitboxes` | keyframed rectangles by disc frame or by time, interpolated | guns over video -- the 12,241-line file becomes a track in the editor | +| `animator` | state to clip: `{ idle = "Idle", walk = "Walk", attack = "Bite", dead = "Die" }`, fades, `animationIsPlaying` polled for `animationDone` | every model with clips | +| `ragdoll` | `ragdollNew`, activated on death, sleeps, and is removed after a time | shooter deaths | +| `spawner` | type, where (a point, along a path, in a trigger, at a waypoint), every N seconds or on demand, max alive, waves | enemies, pickups, traffic | +| `timer` | `timerAfter`/`timerEvery`, raises `timer` events by name | anything timed | +| `sound` | clips by event: `{ hit = "grunt.wav", dead = "fall.wav", spawn = "moan.wav" }` | everything | +| `emit` | an emitter parented to the entity, bursts by event | blood, muzzle flash, sparks, exhaust | +| `camera` | `fixed`, `follow` (offset, lag), `orbit` (mouse look), `first` (on a character's eyes), `rail` (a `path` with look-at targets and stops) | every 3D game | +| `collectible` | destroyed on touch by a named type, adds score or a var | coins, ammo, health packs | +| `lives` | player deaths, continues, credits from the score bezel, game over | arcade games | +| `keys` | maps keys/switches/pad axes to vars (`self.drive`, `self.aim`) so rules read intent rather than hardware | everything with a player | + +**Events.** A rule may say `on = "..."` instead of, or as well as, being +polled every frame: + +`frame` (the default), `pressed`/`released` (key or switch, an edge), +`collision` (two types, from `onCollision`), `enter`/`leave` (a trigger, from +`onTrigger`), `hit`/`miss` (from `gun`), `death`, `spawn`, `arrived` (a path +point or `onNavArrived`), `animationDone`, `soundDone`, `timer` (by name), +`waveCleared`, `frameReached` (a disc frame), `levelStart`, `levelEnd`. +The compiler emits one dispatcher per engine callback that routes to the rules +by type and id; a description with no event rules compiles to what it does +today. + +**Conditions.** Keep the nine. Add `pressed` (edge), `pointerIn(entity)`, +`pointerOffscreen`, `inState`, `var` compare, `distance` less or more, +`inView`, `count(type)` compare, `random(chance)`, `every(seconds)`, +`animationDone`, `atWaypoint`, `waveCleared`, `onScreen`, `lineOfSight` +(`navRaycast`/`physicsRaycast`). `once` gains `per = "instance"` and resets +when the instance is destroyed or the level restarts. + +**Actions.** Keep the nine. Add `spawn`, `destroy`, `setVar`, `addVar`, +`setState`, `damage`, `heal`, `playAnimation`, `playSound`, `playMusic`, +`emit` (burst), `shake` (camera), `flash` (overlay), `pathGo`/`pathStop`/ +`pathNext`, `cameraCut`, `lookAt`, `push` (impulse), `say` (subtitle or HUD +line for a time), `timerStart`, `gameOver`, `restart`, `nextLevel`, +`loadLevel`, `submitScore` (master service, queued offline), `saveSet`, +`discBranch` (the branching FMV table), `credit`. + +**Expressions.** A small, safe syntax where a number goes today: numbers, +strings, `+ - * / %`, comparisons, `and or not`, `self.var`, `score`, `lives`, +`time`, `frame`, `entity("hero").x`, `count("zombie")`, `random(a, b)`, +`distance(a, b)`. Parsed by a hundred-line recursive descent in +AuthorCompile.singe into plain Lua against a whitelist -- no other calls, no +indexing of anything but the names above. The picker offers the names. The +`lua` action stays the door for everything else. This is the item that +decides whether the tool sends people back to code; it goes in the first +milestone, not the last. + +**Groups.** `when = { { ... }, any = { { ... }, { ... } } }`: a rule's +conditions are an AND, an `any` inside them is an OR, and they nest. The +editor shows an `any` as an indented block. + +**Tracks.** One structure for everything keyed by time or by disc frame: + +``` +tracks = { + { name = "camera", key = "time", points = { { at = 0, x, y, z, look = "door" }, { at = 8, ... , stop = "wave1" } } }, + { name = "bandit1", key = "frame", boxes = { { at = 1200, x, y, w, h }, { at = 1230, x, y, w, h } } }, + { name = "waves", key = "time", spawns = { { at = 3, kind = "zombie", count = 3, from = "left" } } }, + { name = "lines", key = "frame", say = { { at = 900, text = "Get down!" } } } +} +``` + +The camera rail, the light-gun hitboxes, a wave schedule, a cut-scene, and a +subtitle sheet are all tracks; the runtime interpolates points and boxes with +`tweenValue`, and the editor's timeline edits all of them the same way. A +`stop` on a camera point holds the rail until the named wave is cleared (or a +timeout), which is exactly what House of the Dead does at every doorway. + +**Levels.** A description may hold `levels = { { title, entities, tracks }, +... }` with `types` and `rules` shared; `nextLevel` tears the world down and +builds the next. A single-level description is `levels` of one, implicitly. +Between levels the `hud` layer shows a results card from a template. + +**Players.** `players = 2` on the description; `keys` and `gun` behaviours +take a `player` index (mouse device index in `MOUSE_MANY`, controller slot), +score and lives are per player, the bezel's twin score is switched on. + +### The catalogue + +Every kind of game the engine can carry, with the layers it uses, what it +needs of the vocabulary above, and what is still missing (**E** = engine work, +**V** = vocabulary only, **ED** = editor). A reference title says what is +meant. Where a type is a combination of others it says so; that is the point +of layers. + +**A. Over video (the laserdisc lineage)** + +1. **Branching FMV** (Dragon's Lair, Space Ace). `disc` + `overlay` or `hud`. + A `frameReached`/`discBetween` window per decision, `pressed` for the move, + `discBranch` to the success or death clip, lives, a move table. V: the + branch table as a track. Missing nothing in the engine; section 47 gave + it every disc control. +2. **Light gun over video** (Mad Dog McCree, Crime Patrol, the ActionMax + titles). `disc` + `overlay`. `gun` + `hitboxes` tracks, `hit` events, + `frameReached` for reload prompts, scoring, civilians as boxes with a + penalty. V + ED: the timeline with frame scrubbing and box drawing is the + whole job; `tweenValue` interpolates between keyframes. +3. **QTE / rhythm over video** (Road Blaster, Sega's Time Gal). Built. Adds + `pressed` edges and a `say` track for prompts. +4. **Video plus sprites** (MACH3, Firefox, Cobra Command). `disc` + `overlay` + + 2D emitters. Sprite types spawned from a track by frame, `shooter`, + `projectile`, `collision`. V. +5. **Sensor games** (ActionMax). A special case of 2: four hitboxes and a + light sensor; `gun` with `pointerIn` and a frame window. V. +6. **Interactive movie / adventure** (Night Trap, Plumbers Don't Wear Ties). + `disc` + `hud`. Choices as GUI buttons bound to `discBranch`, inventory as + vars, `saveSet` for progress. V (the `hud` layer's bindings). +7. **3D over video** -- House of the Dead's enemies over a filmed background. + `disc` shown on a `video` look filling the view, or the disc under the + scene with a transparent clear, plus everything in 20. E: confirm the + scene composites over the disc with alpha (the overlay does; the scene's + clear colour needs an alpha of zero). + +**B. 2D** + +8. **Platformer** (built). Gains `health`, `collectible`, `spawner`, + `animator` on sprite frames, levels. +9. **Shoot-em-up / twin-stick** (Xenon, Robotron). `world2d` without gravity + or `overlay` only, `keys` giving `self.aim`, `shooter`/`projectile` types, + `spawner` waves from a track, `collision` events, `health`, `emit`. V. + This is the second genre of milestone 1 because it forces types, spawn and + events. +10. **Run-and-gun** (Contra): 8 + 9. +11. **Arcade physics** (Breakout, Pong, Pinball). `world2d` with `body` + (bounce 1.0), `solid`, `collision` events, `spawner` for bricks from a + grid. V. +12. **Maze / dot-eater** (Pac-Man). `world2d` on a grid: `agent` over a nav + mesh built from the maze (`navBuild` on a plane with holes, or the 2D + grid in `bump.lua` which is already embedded), `collectible`, `inState` + for frightened ghosts. V, plus a `grid` look that lays a tile map (E: + small -- a tile look drawing a sprite sheet per cell). +13. **Puzzle** (Tetris, match-three, sliding). `overlay` + a grid of types, + `keys`, `timer`, expressions over a 2D array var. V, but the array var + and a `grid` helper are needed; the first puzzle will find the gaps. +14. **Point-and-click adventure** (Monkey Island). `overlay` sprites, a + `hud` verb bar, `pointerIn`, `pressed`, `say`, inventory vars, `agent` + walking on a 2D nav mesh, `saveSet`. V. +15. **Visual novel / interactive fiction**. `hud` only: a document with a + text box and choices, vars for flags, `say`, `playMusic`, `saveSet`. V. +16. **Tower defence** (2D). `world2d`, `agent` creeps along a path (a `path` + behaviour suffices), turrets as `shooter` types placed by `pressed` on a + grid, `health`, waves from a track, a `hud`. V. +17. **Top-down driving** (Micro Machines). `world2d`, `body` + `thrust` + + a turn rate, `trigger` checkpoints and a lap counter, an `agent` for AI + cars. V. (Jolt vehicles are 3D only: "a wheeled type in a 2D world" + raises an error.) +18. **Fighting / beat-em-up** (Streets of Rage). `world2d`, `animator` on + sprite frames, `state` machines, and hit/hurt boxes keyed to *animation + frames* -- the same `hitboxes` track keyed by frame of a clip rather than + of the disc. V: the track's key becomes `frame`, `time`, or `clip`. +19. **Rhythm** (Guitar Hero, Rhythm Heaven). `hud` + `music` or MIDI + (`midiPlay`, `onMidiMessage`), a track of notes by time, `pressed` within + a window, `every(seconds)`. V; MIDI input as switches is E (small). + +**C. 3D** + +20. **Rail shooter** (House of the Dead, Time Crisis, Virtua Cop). Worked + through in the next subsection. The reference for milestone 2. +21. **First-person shooter / arena** (Doom-like). `scene3d`, `character` + + `camera first` + mouse look (`keys` from `onMouseMoved` relative), `gun` + with a raycast from the view centre, `agent` enemies over a nav mesh, + `health`, `spawner`, `collectible`, `hud`. V. +22. **Third-person action / adventure** (Tomb Raider, Zelda). `character` + + `camera follow`/`orbit`, `agent` enemies, `trigger` doors and switches, + `collectible`, `say`, `saveSet`, levels. V. +23. **3D platformer** (Mario 64). 22 with jumping first; the second genre of + milestone 2 because it shares nothing with a rail shooter but the scene. +24. **Racing / driving** (Outrun, Mario Kart). `vehicle` cars on a + heightmap or model track, `trigger` checkpoints, lap and position vars, + `agent` opponents (a `path` with a vehicle following it), `camera follow`, + a `hud` speedometer from `vehicleGetSpeed`. V. +25. **Flying / space / boats** (Star Fox, After Burner, Wave Race). `body` + + `thrust` with pitch and roll from `keys`, `camera follow`, `shooter`, + `spawner`; boats over `bodySetWater`. V. +26. **Sports with a ball** (bowling, golf, billiards, pinball in 3D). `body` + with bounce and friction, `push` from a charged `pressed`, `trigger` + pockets and gutters, `collision` events, `timer` for turns. V. +27. **Physics puzzle / toy** (Angry Birds in 3D, bridge builders). `body`, + `joint` (a `joint` behaviour: hinge, ball, slider between two entities), + `soft` bodies (`softNew`), `push`, `collision` scoring. V: the joint + behaviour is the only new piece. +28. **Tower defence / RTS-lite in 3D**. 16 over a nav mesh with `agent` + crowds and `navRandomPoint`; selection by `sceneUnproject` + raycast. V. +29. **Walking sim / horror** (Amnesia). `character` + `camera first`, + `trigger` scares, `sound` and `emit` on events, `say` notes, `light` + looks that flicker (`addVar` on intensity), `saveSet`. V. +30. **Rhythm in 3D** (Beat Saber-like without VR): 25's types coming down a + `path` on a note track, `gun` or `pointerIn` to hit them. V. + +**D. Not games, built the same way** + +31. **Attract modes, menus, kiosks**: `hud` + `music` + tracks; the bundled + menu is a hand-written one. +32. **Cut-scenes**: a `camera rail` track, `say` lines, `playAnimation` + actions by time, between levels. + +Nothing in the catalogue needs the core to know its genre, which is the test +section 1 set. What it needs is the five axes above, built once. + +### The rail shooter, worked + +House of the Dead is: a camera on a rail that stops at encounters; enemies +that spawn at each stop, approach, and attack on a timer unless killed; a gun +that hits what the pointer is over; civilians who must not be hit; ammo and a +reload gesture; lives, continues, credits; bosses with weak points; branch +points; a results screen. In the model above: + +``` +layers = { { kind = "scene3d", sky = "dusk.hdr", fog = { 40, 90 } }, { kind = "hud", document = "hud.rml" } } +players = 2 +types = { + zombie = { look = { kind = "model", file = "zombie.glb", scale = 1 }, + vars = { health = 3, damage = 1 }, + behaviours = { { kind = "health", ragdoll = true }, + { kind = "seek", target = "camera", speed = 1.2, stopAt = 1.5 }, + { kind = "animator", idle = "Idle", walk = "Walk", attack = "Bite", hit = "Flinch", dead = "Die" }, + { kind = "timer", name = "bite", after = 1.4 }, + { kind = "sound", spawn = "moan.wav", hit = "grunt.wav", dead = "fall.wav" } } }, + civilian = { look = { kind = "model", file = "hostage.glb" }, vars = { health = 1 }, + behaviours = { { kind = "health" }, { kind = "path", speed = 2 } } }, + weakspot = { look = { kind = "box3d", w = 0.3, h = 0.3, d = 0.3, visible = false }, + behaviours = { { kind = "trigger" } } } +} +entities = { { id = "camera", behaviours = { { kind = "camera", mode = "rail", track = "rail" } } }, + { id = "gun1", behaviours = { { kind = "gun", player = 1, ammo = 6, reload = "offscreen" } } }, + { id = "gun2", behaviours = { { kind = "gun", player = 2, ammo = 6, reload = "offscreen" } } } } +tracks = { + { name = "rail", key = "time", points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.5, -10 } }, + { at = 6, x = 0, y = 1.6, z = -8, look = "door", stop = "hall" }, + { at = 14, x = 4, y = 1.6, z = -20, look = { 4, 1.5, -30 }, stop = "yard" } } }, + { name = "waves", key = "stop", spawns = { { at = "hall", kind = "zombie", count = 3, from = { "doorL", "doorR", "stairs" }, every = 1.5 }, + { at = "hall", kind = "civilian", count = 1, from = "corridor", path = "flee" }, + { at = "yard", kind = "zombie", count = 6, from = "gate", every = 1.0 } } } +} +rules = { + { note = "A shot lands", on = "hit", each = "zombie", act = { { "damage", entity = "self", amount = 1 }, { "emit", entity = "self", what = "blood" }, { "addScore", amount = 100 } } }, + { note = "Headshot", on = "hit", each = "zombie", when = { { "hitPart", part = "Head" } }, act = { { "damage", entity = "self", amount = 3 }, { "addScore", amount = 400 } } }, + { note = "Reached the camera", on = "arrived", each = "zombie", act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite" } } }, + { note = "The bite lands", on = "timer", each = "zombie", when = { { "inState", entity = "self", state = "attack" } }, act = { { "damage", entity = "player", amount = "self.damage" }, { "flash", color = "red" }, { "shake", amount = 0.3 } } }, + { note = "Hit a civilian", on = "hit", each = "civilian", act = { { "damage", entity = "player", amount = 1 }, { "say", text = "Don't shoot!" } } }, + { note = "Out of shots", when = { { "var", entity = "gun1", name = "ammo", is = "== 0" } }, act = { { "say", text = "RELOAD" } } }, + { note = "The hall is clear", when = { { "count", kind = "zombie", is = "== 0" }, { "waveDone", name = "hall" } }, act = { { "pathNext", entity = "camera" } } }, + { note = "Dead", on = "death", each = "player", act = { { "lives", change = -1 } } }, + { note = "Level over", on = "levelEnd", act = { { "submitScore", board = "main" }, { "nextLevel" } } } +} +``` + +How the runtime does each piece, over calls that exist: + +* **The rail.** `camera rail` is a `path` on the view's node: position and + look-at interpolated between points with `tweenValue`, `nodeLookAt` each + frame, a `stop` holding until the named wave is cleared (`count == 0` and + the spawner has finished) or a timeout. Pure Lua. +* **The gun.** `mouseGetPosition(device)` in `MOUSE_MANY` (one device per + player; `--manymouse` and the Sinden border already exist), then + `sceneUnproject(sx, sy, 0)` and `sceneUnproject(sx, sy, 100)` give the ray, + `physicsRaycast` gives the node hit. The runtime maps the node to the + instance (`AUTHOR_WORLD` by node) and raises `hit` with `part` = the node's + name, so a ragdoll's `Head` bone or a `weakspot` child is a part. A shot + outside the overlay is `miss` with `offscreen = true`, which `reload = + "offscreen"` consumes. **E, to verify:** that `physicsRaycast` on a model + with a ragdoll reports the bone's node rather than the instance root; if + not, the runtime attaches invisible trigger boxes to named bones instead. +* **Enemies.** `seek` moves the node toward the camera each frame and raises + `arrived` at `stopAt`; `animator` switches clips on `state` + (`animationPlay` with a fade); `health` takes `damage`, sets `hit` then + the previous state after `animationDone`, and on zero sets `dead`, activates + the ragdoll (`ragdollNew`/`ragdollActivate`) and destroys the instance once + `ragdollIsResting` or after a time. `timer` is `timerAfter` by name. +* **Civilians** are the same type machinery with a `path` and a penalty rule. +* **Bosses** are a type with more health, `weakspot` children, an `animator` + with attack clips chosen by rules, and a `hud` bar bound to `self.health` + drawn with `sceneProject` above the node. +* **Blood, flashes, shakes.** `emit` is a 3D emitter parented to the + instance with `emitterBurst`; `flash` is a full-overlay box faded by a + tween; `shake` offsets the view node for a moment. +* **Players, lives, credits.** `lives` wraps the score bezel + (`scoreBezelLives`, `scoreBezelCredits`, `SWITCH_COIN`), `players = 2` + gives two guns and the twin score. `submitScore` queues to the master + service through `Master.singe` and the game id in games.dat. +* **Over video** (7): the same description with a `disc` layer under the + scene; the rail's stops become `frameReached` stops on the disc instead of + time. One key on the track changes and nothing else does. + +The test: a scene with a rail of two stops, three zombies, and a civilian, +pointer injected through `authorPointer(x, y)` the way `scene52` injects keys, +asserting kills, the penalty, the rail moving on when the hall is clear, ammo +reaching zero and a reload, and a level end -- identical under +`--deterministic` and free-running, driven by what the game does rather than +by time (the lesson section 1 recorded). + +### The editor, for time and depth + +Two additions carry most of the catalogue; neither changes the split of +chrome in RmlUi and canvas on the overlay. + +* **The timeline.** A panel along the bottom: a ruler in seconds or disc + frames (the disc scrubs with `discSkipToFrame` as the cursor moves, so a + hitbox is drawn on the frame it belongs to), one row per track, keys as + marks, the selected key's fields typed the same way everything else is. + Draw a box on the canvas at the cursor and it is a key on the selected + hitbox track; step frames and draw again; the interpolation is shown + between. This is the light gun editor section 1 promised and the wave editor and + the cut-scene editor at no extra cost. +* **The 3D viewport.** When a description has a `scene3d` layer the canvas + is the scene, drawn by the engine's own renderer with an editor camera + (orbit with the pointer, fly with keys), and the chrome sits over it as + now. Picking is `sceneUnproject` + `physicsRaycast` against editor-only + trigger boxes on every entity; dragging moves along the ground plane + through the click; height and rotation are typed or nudged with keys. No + gizmos in the first version: typed values are exact and the canvas is for + seeing. Play-from-here starts the game with the camera where the editor's + is. +* **Types and assets.** A third panel mode (`Tab` cycles entities, types, + rules): types edited like entities, and the look's `file` field offers the + models, sprites, and sounds found under the game's directory with `lfs.dir`. +* **The picker grows a search**, since the vocabulary will be ten times the + size: type to filter. + +The document pointer question from section 1 is still open and still does not block +any of this: every new control is reachable by keys and by the canvas. + +### Engine work implied + +Small, and all of it useful outside Forge: + +* `physicsRaycast` reporting the bone or child node it hit on a ragdoll or a + compound (verify; add if not). +* A `sceneSetBackground` alpha of zero drawing the scene over the disc and the + under-overlay video, for 7 (verify). +* A tile look for grids (12, 13): a sprite sheet drawn per cell from a table, + in the overlay -- or `bump.lua`'s grid, already embedded, with sprites. +* MIDI input as switches for 19 (`onMidiMessage` exists; a mapping in + `controls.cfg` would do). +* Nothing for the rail shooter beyond the raycast check. + +### Milestones + +Each adds to the manifest, ships a scene, and -- section 1's rule -- proves itself on +two genres that share nothing, so the core stays genre-free. + +1. **Types, spawn, events, vars, states, expressions, groups, timers, sound, + HUD.** Genres: a 2D shoot-em-up (9) and a light gun over video (2) with + the hitbox track and the timeline in the editor. This is the milestone + that decides whether the tool is usable at all: expressions and events + are where a non-programmer either stays or leaves. +2. **`scene3d`, `model`/`mesh`/`light`/`billboard` looks, `animator`, + `character`, `camera` modes, `gun` in 3D, `seek`, `health`, `ragdoll`, + the rail.** Genres: the rail shooter (20) and a 3D platformer (23). The + editor gains the viewport. +3. **`agent`, `vehicle`, `thrust`, `trigger`, `joint`, `body`, `push`.** + Genres: racing (24) and tower defence (28). Nav mesh baking from the + level's solids (`navBuild`) as an editor action. +4. **`hud` bindings, `say`, inventory vars, `discBranch`, `saveSet`, + levels, results, `lives`, `submitScore`.** Genres: branching FMV (1) + and a point-and-click adventure (14). Game over, continues, and high + scores arrive here for everything before it. +5. **Editor: types panel, asset browser, picker search, play-from-here, + undo across the timeline.** No new genre; every scene from 1 to 4 is + rebuilt through the editor rather than from a hand-written description. +6. **Sprite frames and `hitboxes` by clip** (18), `grid` (12, 13), MIDI + (19), boats and water (25), soft bodies (27). The long tail, each a + page of manifest and a scene. + +The first two milestones are the ones the owner asked for; the rail shooter +is milestone 2 and everything in milestone 1 is on its path (a zombie is a +type, its bite is a timer, its death is an event, its health is a var). + +### Corrections to section 1 as written + +* `assets/Author.singe`, `AuthorEdit.singe` and `Singe/AuthorCompile.singe` + are `forge/Author.singe`, `Forge.singe` and + `Forge/AuthorCompile.singe`; nothing of Forge is in `Singe/`. +* "The keyframe point, worth more than most of the rest" was not built. It + is milestone 1 here. +* "The 2D looks draw with `overlayLine` per row" stays true for `box`; the + `sprite` look draws its image in the editor now and needs frames. +* `touching` assumed `look.w`; `authorSize` answers for every look now, and + the model above moves size into the look. +* "A rule is a flat AND" is fixed by `any` groups above; "`once` never + resets" by `per = "instance"`. +* The documentation for all of this is `docs/Forge.adoc`, not the manual. + +### Out of scope, restated + +A web view. A node-graph editor as the first UI. A separate desktop +application. Importing hand-written games. A modeller, a sprite painter or +a sound editor -- assets come from Blender, Aseprite, and Audacity as glTF, +PNG, and WAV. Networked multiplayer. VR. A scripting language of its own +beyond the expressions above: past them is Lua, on purpose. And the list +grows before the features do. + + +## 3. Revisions after review (written 2026-09-13) + +The owner read section 2 and changed two of its premises: **nothing has to be +backward compatible** -- Forge is new and unreleased, so the format and the +vocabulary are designed once and designed right, not grown around the first +slice -- and **the Sierra and LucasArts adventure has to be a first-class +target in both 2D and 3D**. He also asked whether Forge should have its own +planning document; it does now, and this is it. + +### Compatibility dropped: what section 2 hedged and this decides + +Section 2 kept every section 1 construction valid and added around it. Freed +of that, these are the decisions taken instead; each is one thing where there +were two. + +* **Everything is a type.** `types` is the only place a look, behaviours, and + vars are declared; an `entities` entry names its type and gives what + differs (position, id, a var). There is no anonymous entity with its own + look. One path through the compiler and the editor rather than two, and a + thing placed once is a thing that can be spawned later without rewriting it. +* **Positions are always three numbers**, and every look declares its own + size. 2D layers ignore `z` and the 2D editor never shows it. The + runtime's `authorSize` and the `w, h` scattered through section 1 go. +* **Rooms, not levels.** A game is `rooms`; a platformer's levels are rooms + visited once, an adventure's rooms are visited over and over and remember + themselves. Every room keeps its entity state when it is left (positions, + vars, what was taken) unless the room says `reset = true`. Vars declared on + the game (`score`, `flags`, inventory) live across rooms. A `hud`, `music`, + and camera can be set per room and default from the game. +* **Every rule has a trigger.** `on = "frame"` is written, not implied; the + editor's picker for a new rule opens on the event list. Polling every frame + is one choice among twenty, not the default a non-programmer falls into. +* **Every action declares whether it waits.** `say` waits until the line is + done, `walkTo` until the character arrives, `wait` for its seconds, + `playAnimation` optionally until the clip ends, `fade` until the screen is + black. A rule's actions run as a coroutine the runtime resumes each frame, + so a cut-scene, a dialogue, and a door opening are the same rule shape as + `addScore`. This is the piece adventures cannot exist without and the + piece section 2 lacked: it had events and actions, and no notion that an + action takes time. Rules on the same trigger for the same entity are + serialised; a new `on = "pressed"` while a sequence is running is dropped + unless the rule says `interrupt = true`. +* **`once` is per instance and per room** by default and resets with the + room; `once = "game"` is the old behaviour, opted into. +* **Tracks and dialogues are assets of the room**, edited in their own panels, + and referenced by name from behaviours and actions, rather than living + inside the rule that uses them. +* **`hitboxes` is the one keyframe mechanism** for disc guns, animation + hit/hurt boxes, and hotspots that move with a video: a track of shapes + keyed by frame, time, or clip frame. A shape is a rectangle, a circle, or a + polygon (`collidePointPolygon` exists), so a hotspot in an adventure is a + hitbox track with one key. +* **Two players is the general case:** `players = N`, and `keys`, `gun`, + score, and lives are indexed by player from the start rather than added. +* The section 1 samples (`platformer.forge`, `qte.forge`) and scenes 52 to 57 + are rewritten to the new format when milestone 1 lands; nothing loads the + old one. + +### Adventure games, worked + +Two lineages, one model. **Sierra** (King's Quest, Space Quest, Police Quest): +rooms are painted screens with a walk area and exits on the edges, the player +walks with the keys or a click, a text parser (AGI, SCI0) or an icon bar +(SCI1) says what to do, death is frequent and the score counts, and the +sentence "You can't do that here" is a rule. **LucasArts** (Maniac Mansion, +Monkey Island, Day of the Tentacle): a verb bar and a sentence line, a click +picks a hotspot, a dialogue tree with a choice list, no deaths, inventory as +objects with verbs, cut-scenes that take the controls away, and puzzles that +are state (`flags`) plus rules over it. **3D** (Grim Fandango, Escape from +Monkey Island, Gabriel Knight 3): 3D characters on a nav mesh, either over a +painted backdrop from a fixed camera per room or in a modelled room the +camera follows; otherwise the same nouns. + +What the model gives them, and what it adds for them: + +* **Rooms** (above): a painted `sprite` or `video` backdrop look drawn first, + or a `scene3d` room with a `camera` per room -- `fixed` (position, look-at, + fov, matched to a painting), `follow`, or `orbit`. `exits` are `trigger` + types at the edges or hotspots with a `goTo` action naming the room and the + arrival point; in 2D the runtime scrolls nothing (Sierra rooms are a + screen) unless the room is wider than the overlay, in which case a + `camera follow` in 2D pans it. +* **Walk areas.** A room declares `walk = { polygon... }` (several, with + holes); the runtime triangulates them (ear clipping, sixty lines of Lua), + builds a flat mesh with `meshNew`, and bakes a nav mesh with `navAddNode` + and `navBuild`, so a 2D room's walking is the same `agent` behaviour a 3D + room uses over its floor. Click-to-walk is `navAgentMoveTo` at the click + (2D: the overlay point; 3D: `sceneUnproject` + `physicsRaycast` onto the + floor). `walkTo` in a rule is the same call with `waits`. `onNavArrived` + is the `arrived` event both lineages script against. +* **Depth in a painted room.** `scaleBy = { { y = 300, scale = 0.5 }, { y = 460, scale = 1.0 } }` + on the room scales sprites with their `y` (`spriteScale` exists), and draw + order is by `y` so a character walks behind what is nearer the bottom of + the picture. `behind` looks -- a cut-out of the painting drawn after the + characters -- give a pillar to walk behind. In 3D over a painting the same + needs **occluder** meshes that write depth and no colour: **E**, a + material flag (`materialSetOccluder`), small, and nothing else in the 3D + lineage is missing from the engine. +* **Hotspots.** A `hotspot` type: a `hitboxes` shape (rectangle, circle, + polygon) with no picture, a `name` for the sentence line ("look at + *window*"), a `walkTo` point, and vars. `pointerIn` and `pressed` find it; + `on = "verb"` rules (below) act on it. A hotspot may move with a video + (a keyed track) or with an entity (parented). +* **Verbs and the sentence line.** The game declares `verbs = { "look", + "take", "use", "talk", "open", ... }` and the `hud` document shows them as + buttons (LucasArts) or an icon bar (SCI1); the runtime builds the sentence + from verb, hotspot, and, for `use`, a second hotspot or an inventory item, + and raises `on = "verb", verb = "use", item = "key", target = "door"`. A + rule matches on any subset (`verb` alone is "use anything on anything"); + the most specific rule wins and the room's default rule ("That doesn't + work") catches the rest. Right-click cycles verbs, the way the later + LucasArts games do, as a `hud` option. +* **The parser.** A `parser` layer for the Sierra lineage: `words = { + look = { "look", "examine", "l" }, door = { "door", "gate" } }` and + `on = "said", verb = "look", noun = "door"` rules; unknown words answer + from a table; `keyboardSetMode` text entry with a prompt line in the `hud`. + Both input styles raise the same `verb` event, so a room's rules do not + care which the game uses, and a game can offer both. +* **Inventory.** An `inventory` var on the player (a list of item types), + `give`/`take` actions, the `hud` bound to it as a scrolling list, items + usable as the `item` of a `verb` event, and `has(item)` in expressions. +* **Dialogue.** A `dialogues` asset per room or game: nodes of `say` lines + (who, text, optional clip, and portrait) and `choices`, each with `when` + conditions, `act` actions, `goto`, and `once`; a `talk` action starts one + and waits until it ends; the `hud` shows the choices; lines are `say` + actions with a duration from their length (or a voice file's) and a + `srt`-style subtitle placement over the speaker via `sceneProject` in 3D. + A dialogue is a tree in the editor's panel, edited as an outline. +* **Flags and puzzles.** Vars at game scope; `setVar`/`addVar`; `when` on + them; `once`. A puzzle is state and rules, which the model already is. +* **Cut-scenes.** A rule with `waits` actions and `controls = false` for its + duration: `walkTo`, `say`, `playAnimation`, `fade`, `cameraCut`, `wait`, + `goTo`. Tracks for anything timed to the frame. +* **Death and scoring** (Sierra): a `die` action with a message and + restore/restart/quit choices from the `hud`; `addScore` with `max` shown + as "12 of 300"; `saveGame`/`loadGame` actions capture the whole state -- + room, positions, vars, inventory, dialogue `once` flags -- through + `saveSetAll`, and a `hud` restore list. +* **3D specifics.** Characters are `model` types with `animator` (idle, + walk, talk, pick up) and `agent` on the room's nav mesh (baked from the + room's floor solids with `navBuild`); `camera fixed` per room with the + painting on a `video` or `sprite` backdrop look filling the view, or + `camera follow` in a modelled room; hotspots are `trigger` boxes or + polygons on the floor; `lookAt` turns the head or the body toward a + hotspot before a `say`; tank controls are `keys` mapping forward/turn onto + `character`; point-and-click is the same `navAgentMoveTo`. Occluders as + above. + +What the editor adds for them: polygon drawing on the canvas (walk areas, +hotspots, `behind` cut-outs), an outline panel for dialogues, the verb list +and parser words as tables typed like everything else, per-room camera set +from the viewport ("use this view"), and play-from-this-room with the +inventory and flags set as the editor has them. + +The test, in the spirit of section 1's two-genres rule: one description, two +rooms, one hotspot each, a door that needs a key taken in the other room, a +dialogue with a choice that sets the flag the door checks, a cut-scene on +opening it, and a score of two -- built once over a painted backdrop in 2D and +once in a modelled 3D room with a fixed camera, the rules identical between +the two. Driven by `authorPointer` and `authorSay("use key on door")` rather +than by time. + +### Where the adventure sits in the milestones + +Section 2's milestone 4 was "HUD bindings, `say`, inventory, `discBranch`, +saves, levels, results, lives, high scores" proved on branching FMV and a +point-and-click adventure. With the owner's emphasis the order changes: + +1. Types, events, vars, states, expressions, groups, timers, sound, HUD -- + and **sequences** (`waits`), since they are the substance of milestone 3 + and cheap to do here. Genres: the shoot-em-up and the light gun over + video, as before. +2. `scene3d`, models, `animator`, `character`, `camera` modes, the 3D gun, + `seek`, `health`, `ragdoll`, the rail. Genres: the rail shooter and the + 3D platformer. +3. **The adventure**: rooms, walk areas and 2D nav, hotspots, verbs and the + sentence line, the parser, inventory, dialogues, cut-scenes, saves, + scaling, occluders (the one engine change). Genres: the same adventure in + 2D and in 3D, as above. +4. `agent` crowds, `vehicle`, `thrust`, `joint`, `body`, `push`: racing and + tower defence. +5. Branching FMV, lives, continues, `submitScore`, results: the arcade + plumbing every earlier genre then inherits. +6. Editor: types panel, asset browser, picker search, timeline polish, + dialogue outline, polygon tools, play-from-here. +7. The long tail: sprite frames and clip hitboxes, grids, MIDI, boats and + water, soft bodies. + +### Out of scope, carried forward + +As section 2 states it, plus: a text parser that understands grammar beyond +verb, noun, and preposition (Sierra's did not either); lip sync; a +dialogue *writing* tool beyond the outline (a writer's tool is a different +program); and localisation, which is a table of strings and a milestone of +its own once there is something to translate. + + +## 4. Milestone 1, built (2026-09-14) + +Types, rooms, events, vars and states, expressions, groups, timers, sound, a +HUD, sequences, the hitbox track and the timeline. Proved on the shoot-em-up +(`testScripts/author/shmup.forge`, scene 58) and the light gun over video +(`gunvideo.forge`, scene 59); the first slice's platformer and QTE were +rewritten to the new format and still pass (scenes 52 and 53), and the editor +scenes (54 to 57) were adapted, with scene 60 for the timeline. + +**What was built, against section 3's decisions:** + +* `Author.singe` rewritten: `authorBegin(description)` builds the layers, + the vars, and the first room; `authorRules(list)` takes the compiled rules + by event; `authorFrame` runs timers, behaviours, sequences, the frame rules, + the type-pair collision test, the HUD bindings, and the draw. Instances are + made from types (`authorSpawn`, `authorDestroy`), rooms keep their instances + when left (`authorGoTo`), and every instance has `vars` with `state`. +* Events: `frame`, `roomStart`, `roomEnd`, `pressed`, `released`, + `collision` (type pairs, an edge), `hit`, `miss`, `death`, `spawn`, `timer`, + `frameReached`, `gameOver`. A rule names one with `on` and filters with the + event's own parameters; `each` scopes it to a type with `self` bound. +* Sequences: an action with `waits` (`say`, `wait`) yields from the rule's + coroutine through `authorWait`; a rule without one runs plainly. + `controls = false` takes the keys away for its duration; the same rule on + the same instance does not restart unless `interrupt`. +* Expressions: `AuthorCompile.singe` carries a recursive descent parser + (`authorExpression`) with a whitelist -- `self.var`, `other.var`, + `event.field`, `name.field`, game vars by name, `count`, `random`, + `distance`, `has`, `abs`, `min`, `max`, `floor`, `time`, `frame`. Every + `number` or `expression` parameter accepts one. `any` groups nest. +* The manifest's `emit` functions receive Lua fragments, not values: the + compiler resolves each parameter by its declared type. Behaviour + parameters, which travel as data, resolve key and switch names at run time. +* `authorCheck(description)` names undeclared types, looks, behaviours, + conditions, actions, and events, and expressions that do not parse. +* The editor: four panels (`Tab` cycles entities, types, rules, tracks), + rooms (`PgUp`/`PgDn`, `N` adds one), types typed field by field with looks + and behaviours from the manifest and renames that follow through the rules, + entities as type plus overrides, rules with `E` picking the event and its + filters typed, `O` for an `any` condition, and the timeline: the disc + layer's video scrubbed under the cursor, keys made at it (`K`), dragged on + the canvas, typed, and deleted. +* `docs/ForgeVocabulary.adoc` is generated from the manifest by + `util/forgeVocabulary.lua` (in `build-docs.sh` and the CMake rule), so the + manual's tables cannot drift. + +**Decisions taken while building, for the record:** + +* A behaviour's parameter cannot be called `type` -- the key names the + behaviour -- so `shooter` and `spawner` say `spawns`. +* A gun hits only instances with a `target` or `hitbox` behaviour. The first + version hit the score readout. +* File names in a description are relative to the game's directory, like + every other Singe path. +* A description's `size = { w, h }` sets the overlay; without it the + engine's default stands, which over a disc is the disc's own. +* The spawner's count is a var (`self.made`), since rules read vars and not + runtime fields. + +**Not yet, from the milestone's list:** sprite frame animation as an +`animator` (the `sprite` look takes `frames` and the var `frame`, but nothing +steps it yet); the `spawns` track type (waves are a `spawner` behaviour for +now); `chance`/`every` are in, `soundDone` is not. All are milestone 6 or +arrive with the genres that need them. + + +## 5. Milestone 2, built (2026-09-14) + +The scene: `scene3d`, the `model`, `mesh`, `light`, `billboard`, and `text3d` +looks, `character`, `camera` (fixed, follow, orbit, first, rail), `seek`, +`animator`, `target` with zones, `health` with ragdolls, `body`, `trigger`, +the gun's ray, and the rail. Proved on the rail shooter +(`testScripts/author/railshooter.forge`, scene 61: two stops, seven zombies +killed, seven headshots, the rail moving on when each stop is clear, the end +reached) and the 3D platformer (`platformer3d.forge`, scene 62: the +controller moving relative to a following camera, a jump onto a ledge, a +prize that is a trigger). The editor shows a 3D room as the scene under an +orbiting camera, picks and drags by projection, and puts rail points where +the camera stands (scene 63). + +**How the pieces went, against section 2's design:** + +* Every node an instance owns -- its own, a model's whole subtree, the body + box, the zone boxes -- is registered in `AUTHOR_BY_NODE` when it is made, so + a ray or a contact maps to its instance with a table lookup. The first + version walked `nodeGetParent`, and physics reported a node a rule had just + deleted (a prize destroyed as it was entered); asking the engine about a + dead node ends the game, so the walk went. +* `target` in 3D is a kinematic trigger box on a child node raised by half + the look's height, since a model stands on its origin. Zones are boxes + parented to named bones (`b_Head_05` on the Fox). The ray meets the body + box first, so zones are found by how close the ray passes to each zone's + centre -- no second cast, and no engine change: the raycast-hits-bone + question from section 2 is answered without it. +* The rail is `authorRailStep`: points by time, position and look-at + interpolated, a `stop` held until `pathNext`, `stopped` and `railEnd` + events. `shake` offsets the rail camera for a moment. +* `character` moves relative to the camera's ground axes + (`authorCameraAxes`), which come from a `first`/`orbit` camera's yaw or a + `follow` camera's bearing to its target. +* Ragdolls: `health` with `ragdoll = true` makes the ragdoll at attach and + activates it on death, keeps the instance for `linger` seconds through a + named timer, and takes it off the targets. +* The editor's preview reuses the runtime's look `load` functions on a fake + instance, rebuilt when what the entities *are* changes (a stamp of types + and looks) and moved every frame; the camera orbits the selection; drags + cross the plane of the entity's own height. + +**Not yet:** `first` and `orbit` cameras are in the manifest and untested +(the FPS is milestone 4's territory); `billboard` and `text3d` likewise; +`animationDone` is polled from `animationIsPlaying`; the timeline's +`spawns` track is still a `spawner` behaviour and a rule per stop. + + +## 6. Milestone 3, built (2026-09-14): the adventure + +Rooms that remember themselves, walk areas baked to a navigation mesh (2D +polygons or 3D floors), the `walker`, hotspots with polygons, verbs and the +sentence line with most-specific-wins, the parser and `said`, the inventory, +dialogue trees compiled with their conditions and actions, cut-scenes across +rooms, fades, depth sorting and scaling, feet anchors, `saveGame`/`loadGame`, +and `die`. Proved on `adventure2d.forge` (scene 64) and `adventure3d.forge` +(scene 65), which takes the 2D game's rules, dialogue, verbs, and parser +unchanged and supplies only types and rooms -- the test section 3 asked for. +The editor types a room's own fields and draws its walk areas. + +**How the pieces went:** + +* Walking in 2D: the polygons are ear-clipped in Lua into a flat mesh + scaled by a hundredth (`NAV_SCALE`), baked with `navNew`/`navAddNode`/ + `navBuild`, and an agent drives a separate navigation node whose x and z + are copied back to the instance's overlay x and y every frame. In 3D the + agent drives the instance's node. **Engine fix:** `navAgentNew` read the + node's world position as of the last drawn frame, so an agent made in a + game that never draws the scene sat at the origin, off the mesh; it + refreshes the transforms first now, as `navAddNode` already did. +* Exclusive events: rules are tiered by the parameters they name (two each) + plus one for having conditions, and the highest tier whose conditions hold + acts; a rule's compiled function answers whether its conditions held, and + a sequence that yielded has passed them. This is what lets "I do not know + the word" (guarded, no parameters) beat "You cannot do that here" and fall + through to it when there is no unknown word. +* Sequences survive a room change: the cut-scene that walks through the + door is the one changing rooms and has a fade-in still to do. They are + cleared only by a restart or a load. +* `goTo` with `at` names an entity in the destination room to arrive at, + which is how one rule serves both worlds. +* The dialogue's `next` was first `goto`, which Lua reserves. +* Physics contacts now reach `collision` rules, so the rules' `a` and `b` + types are matched (the first version matched neither, and a platformer's + hero destroyed its own ground). + +**Not yet:** occluders for a 3D character walking behind a painted pillar +(the `materialSetOccluder` engine change section 3 named); the editor's +polygon drawing and dialogue outline (milestone 6); a sprite `animator` for +walk cycles (`state` and `facing` are set, nothing steps frames yet). + + +## 7. Milestone 4, built (2026-09-14): vehicles, crowds, turrets, joints + +`vehicle` (car, motorcycle, tank, boat over `vehicleNew`, wheels hung from +the look's corners, driven by the `keys` vars, `self.speed`), `racer` (a +vehicle steering itself round a track of points from its yaw), `walker` +`follow` (repathing after a target), `patrol` with `patrolEnd`, `turret` +(nearest target in range, a projectile aimed by velocity overrides at spawn), +`spawnAtPointer`, `thrust`, and `joint` (hinge, ball, slider, to another body +or the world, made on the first frame so the other body exists). Proved on +`racing.forge` (scene 66: the car reaches 22 units a second, passes two +checkpoint triggers for a lap, the rival drives its track, the hovercraft +moves under thrust, the hinged bar hangs) and `towerdefence.forge` (scene 67: +ten creeps in waves along a patrol, two turrets placed by clicks projected +onto the floor, ten kills, gold and lives kept by rules). Both worked on the +first run, which says the model from the earlier milestones held. + +**Decided on the way:** the vehicle's forward is -Z, as the engine's is, so +the throttle is `-dy`; the racer's steering is the signed angle between the +chassis' heading (from `nodeGetRotation`) and the next point, clamped; a +turret without a projectile type does its damage directly. + + +## 8. Milestone 5, built (2026-09-14): the arcade plumbing + +The `branches` track and the `branching` behaviour (`branchOpen`, +`branchTaken`, `branchMissed`; the disc sent to the success or the fail +frame), the `bezel` layer mirroring `score`, `lives`, `credits` (and a second +player's) to the arcade panel, `credit`, `gameOver` with the results card and +the best score kept in a save, and `submitScore` loading `Singe/Net.singe` +and `Singe/Master.singe` on first use and pumping them after. Proved on +`fmv.forge` (scene 68): a window answered in time, three missed, the lives +spent, the score sent once (the master client stubbed by the scene), a coin, +and a continue that starts over with three lives. The point-and-click +adventure this milestone was to share with had already landed in milestone 3. + + +## 9. Milestone 6, built (2026-09-14): the editor + +Typing narrows every picker; a field that names a file (by the manifest's +type) opens a picker of the files under the game's directory, two levels +deep, in place of the prompt; `V` draws a walk area or a hotspot's outline +corner by corner on the 2D canvas; the dialogues are a fifth panel, an +outline of dialogues, nodes, choices, and a choice's actions, with `A` +adding at the level selected and `T` picking an action; and `P` plays from +the room on screen by building a copy with that room first. The types panel, +the asset list, and undo across the timeline had landed with the earlier +milestones. Scene 69 drives all of it and checks what reached the +description and the compiled game. + +The one thing the milestone planned and this does not do is rebuild every +earlier scene through the editor rather than from a hand-written description; +scenes 54, 55, 57, 60, 63, and 69 between them exercise every editing path +those descriptions use, so the rebuild was not worth its cost in scenes. + + +## 10. Milestone 7, built (2026-09-14): the long tail + +The `frames` behaviour stepping a sprite sheet by state, the `grid` look +drawing a tile map from a sheet with `spriteDrawGrid`, the `midi` event from +`onMidiMessage` (its filter is `pitch`, because `note` is what a rule's +comment is called -- the first version tried to compile every rule's comment +as a MIDI note), the `water` behaviour over `bodySetWater`, `buoyancy` on +`body`, boats with `thrust` through the `vehicle` behaviour, and `soft` over +`softNew`. Proved on `tail.forge` (scene 70: five frames of a walk cycle +seen, a 5 by 3 grid measuring 160 by 96, middle C scoring and a second note +counted) and `pool.forge` (scene 71: a ball that floats at the surface, a +boat that travels nineteen units under its propeller, a balloon that +inflates). + +With this every kind of game in section 2's catalogue has its vocabulary in +the manifest, and every milestone of section 3 is built. What remains is +listed where it was found: occluders for 3D over a painting (section 6), +first-person and orbit cameras untested (section 5), the editor rebuilding +every scene (section 9), and the light gun cabinet checks that need a person +at a real machine (section 1). + +*Closed 2026-09-14:* occluders came with section 12; first and orbit +cameras are driven by injected mouse motion and asserted on in scenes 72 +(fps.forge) and 75 (thirdperson.forge), so "untested" was stale; `billboard` +and `text3d` looks now stand in those samples (puffs at the rail shooter's +stops, a sign in the arena) and are checked; the `spawns` track is built +(a waves track: `authorWavesAtStop`, `updateWaves`, `W` and `K` in the +editor, scenes 94 and 95, sample waves.forge); and `soundDone` arrived +(`authorPlaySound` remembers the channel's file and owner, the compiled +game binds `onSoundCompleted` to `authorSoundDone`). Only the cabinet +checks remain. + + +## 11. The rest of the catalogue (2026-09-14) + +With the milestones built, the remaining types of game in section 2's +catalogue were written as descriptions to find what the vocabulary still +lacked. Each is a sample in `testScripts/author/` with a scene: + +* **First-person shooter** (`fps.forge`, scene 72): `camera first` on the + hero's eyes turned by the mouse, `keys` moving relative to it, a `gun` with + `aim = "centre"`, zombies on `walker follow`. Found: the gun's ray met the + hero's own body first; it now passes through anything that is not a target + (a few casts on from each hit) before counting a miss. +* **Breakout** (`breakout.forge`, scene 73): `solid` with `moving` (a + kinematic paddle that follows the `mover`), a `body` ball with bounce 1, + `push` on `roomStart`. Found: events raised while the first room is built + -- `roomStart`, every placed entity's `spawn` -- came before the compiled + game handed over its rules, and were lost; they are kept and replayed by + `authorRules` now. +* **Maze** (`maze.forge`, scene 74): a `grid` look for the walls, corridors + as walk areas, a ghost on `walker follow`, dots eaten by collision. +* **Third person** (`thirdperson.forge`, scene 75): `camera orbit` under the + mouse, `keys` relative to it, a sword swing as `on = "pressed"` with `each = + "enemy"` and a distance test. Found: `each` on an event with no instance + of its own (a key, the disc, a room) now fans the rule out over every + instance of the type, as a frame rule does; `distance` takes ids as well as + instances and measures through the scene in 3D. +* **Space shooter** (`space.forge`, scene 76): `thrust` with no gravity, a + `shooter` firing 3D projectiles, rocks as slow projectiles with `health`. +* **Rhythm** (`rhythm.forge`, scene 77): a `music` layer, notes as instances + falling to a marker, a press scored by where the note is. +* **Bowling** (`bowling.forge`, scene 78): a heavy `body` ball shoved by + `push`, pins as bodies counted by how far they lean, a `trigger` at the end + of the lane. + + +## 12. Occluders and gizmos (2026-09-14) + +**Occluders**, the engine change section 3 named: a `PIPELINE_OCCLUDER` +variant in `scene.c` whose colour target has its write mask cleared, so the +mesh goes into the depth buffer and nowhere else; `materialSetOccluder` sets +it, an occluder casts no shadow (the painting already has one), and the GLES +shim already honoured the mask. Forge's `mesh` look takes `occluder = true`; +the editor's preview marks its instances so they stay visible for placing. +`painted.forge` (scene 79) is a picture on a flat mesh, an occluder pillar, and +the Fox crossing behind it -- and found that `materialSetTexture` takes a +KTX2 file or a sprite handle, so the look loads any other picture as a sprite +first. + +**Gizmos** in the 3D viewport: three arms from the selected entity along the +world axes, projected with `sceneProject`, each with a handle; a press on a +handle starts a drag along that axis, the pointer's travel along the arm as a +fraction of the arm being the distance moved. Scene 80 raises a wall by an +arm's length on Y, brings it half an arm back on X, and undoes both. + +**Rotation and facing** (the two "minor" items): entities carry `rx`, +`ry`, `rz` (applied to the instance's node by `make`, shown in the 3D preview, +typed in the entities panel), and the gizmo has a fourth arm, yellow, the way +the entity faces, whose handle dragged round the entity turns it about Y +(scene 80, a quarter turn; scene 79 checks the rotation reaches the node). +Sprite facing was data at first -- a `frames` range named `walkLeft` used +while `facing` is left -- and is now an engine flip as well: `spriteFlip` +mirrors a sprite for every draw, so a `sprite` look mirrors itself while +the instance faces away from its art (`faces`, right unless said), and a +`Left` or `Right` range is only for sheets drawn both ways; scene 70 holds +LEFT and sees only the drawn frames, scene 81 sees the mirror. A per-entity +`scale` (typed; no handle) scales the node -- look, body, and counted size +-- with bodies given the look's unscaled size, since Jolt takes the node's +scale itself (scene 79). + +**Sprite rotation and the rest of the sweep** (2026-09-14): a `sprite` turns +with the var `angle` (seeded from the entity's `rz` in a 2D room; a turning +instance gets an image of its own so the shared one is not rebuilt for every +angle in the room), a `spin` behaviour turns the angle or the node about Y, +a `projectile` with `aim` points its sprite along its flight, a `particles` +look is a started emitter, `stopMusic` stops the track, and `authorCheck` +names any field a look, a behaviour, or an event does not take. Found on +the way: a 2D emitter draws nothing until `emitterDraw` is called each frame +-- the shoot-em-up's explosions had never appeared -- so the frame draws every +live instance's emitter in a 2D room. Scene 70 covers the spinner, the +fire, and three deliberate misspellings. + +**Mirrors, turned sheets, clip volumes, the 2D turn handle, and thumbnails** +(2026-09-14): the engine gained `spriteFlip` (a mirror applied to every draw +of a sprite, frames included), and a `sprite` look mirrors itself while the +instance faces away from its art (`faces`); a sheet entity with an angle +draws its frame turned through `spriteRotateFrame`; `soundPlay` takes a +volume and `soundSetChannelVolume` changes a channel's own, under the master +(which used to be applied through the mixer's tag gain, overwriting the +distance fade of positioned channels -- it is per track now), so the +`playSound` action and the `sound` behaviour take a `volume` of 0 to 100; +the 2D canvas has the yellow turn handle the scene had, dragged round the +entity to set `rz`, and a sheet entity on the canvas shows its first frame +turned; and the types panel shows each type's picture -- the sprite through +RmlUi's ``, which reads the vfs, or a swatch of the box's colour -- +beside its name. Scenes 81 (runtime) and 82 (editor). Still not doable +here: the hardware checks (RmlUi's document pointer path on a real desktop, +light gun coordinates, off-screen reload, and two guns). + +## 13. Tutorials (planned 2026-09-14) + +The reference chapters say what Forge is; nothing yet says what to *do* +first. The tutorials go at the front of `docs/Forge.adoc`, as a part of +their own (`== Tutorials`) between *About Forge* and *Describing a Game +Instead of Writing One*, so the book opens with a game being made and only +then explains the pieces. They stay out of the Singe manual, like all Forge +documentation. + +### 13.1 Shape + +Every tutorial is one sitting and ends with a game that plays. Each has the +same skeleton, so the tenth reads like the first: + +1. **What you will make** -- one sentence and one picture of the finished + game. +2. **What you need** -- the tutorial before it (or nothing), and which files + of the kit (13.3) it uses. +3. **Steps** -- numbered, each one key or one form: *press `A` -- a new + type appears at the top of the list*, with a picture wherever the screen + changes in a way words would fumble. Never two ideas in one step. +4. **What happened** -- the concept the steps just used (an event, a + behaviour, a track), in a paragraph, with a link to the reference chapter + that owns it. This is where the tutorial teaches; the steps only do. +5. **Try this** -- two or three variations that need no new steps (change + the jump, add a second coin, move the door), so the reader leaves the rails + on purpose. +6. **The description** -- the finished `.forge` text, in full for the short + ones and as a diff against the previous tutorial for the long ones, so a + reader who lost their way can compare, and a reader who prefers a text + editor can skip the steps entirely. + +Pictures are block images (`image::tutorials/01-chooser.png[]`) under +`docs/images/`, PNG, embedded in Forge.pdf by asciidoctor-pdf. A full 720 +by 480 frame of the editor reads well at page width; cropping is a later +nicety, not a requirement. + +### 13.2 The tutorials are tests + +The pictures are not taken by hand. Each tutorial has a scene +(`testScripts/scene83.singe` upward, one per tutorial) that drives the editor +through exactly the keys the text names -- `forgeKey`, `forgePress`, +`forgeDrag`, the typed forms -- shooting a frame at each pictured step, and +at the end asserts two things: that the description on screen equals the +tutorial's shipped `.forge` (the round trip scene 54 already relies on), and +that the built game runs a few seconds without a `FAIL`. A change to the +editor that breaks a tutorial breaks its scene; a change that moves a panel +regenerates the pictures. The refs sweep runs them like every other scene. + +Two small engine and editor pieces make that clean: + +- `singeScreenshot(name)` -- an optional file name, so a scene writes + `01-chooser.png` rather than `singe007.png` for a rename step to guess at. + Manual entry and CHANGELOG line. +- a harness step (not in the repo, like the rest of the harness) that copies + a tutorial scene's shots into `docs/images/tutorials/`. The images *are* + in the repo, since the docs build reads them; the screenshots folder stays + scratch. + +### 13.3 The kit + +A tutorial cannot say "find a picture of a hero": the pictures must be +there. `forge/kit/` ships in Forge.game and the first run copies it +out beside Forge.pdf, where *New game* already writes descriptions, so every +tutorial's file names are the same on every machine (`kit/hero.png`). What +is in it, all ASCII-named, all small, all ours or CC0: + +- `hero.png` -- a sprite sheet, 8 columns: idle, a 4-frame walk facing + right, a jump, a fall, a hit. Drawn once, mirrored by `faces`. +- `coin.png`, `crate.png`, `door.png`, `spike.png` -- stills. +- `tiles.png` -- a 4-column tile sheet for `grid` looks. +- `enemy.png` -- a 4-column sheet for the shoot-em-up and the gun game. +- `room.png`, `room.jpg` -- a painted adventure room, with a floor to walk + and a pillar to walk behind (the 3D adventure reuses it under occluders). +- `fox.glb` -- the Fox model (already used by seven samples; its licence + goes in `kit/LICENSE`), `crate.glb` a box with a texture. +- `jump.wav`, `coin.wav`, `shot.wav`, `hit.wav`, `click.wav`, and a short + `loop.ogg` for a music layer. +- `clip.mkv` -- twenty seconds of video with three obvious moments in it + (a door opens, a target rises, a hand reaches), for the FMV, QTE, and gun + tutorials. `Singe/menuBackground.mkv` will do until it is made. + +Two consequences for the editor, both gaps today: + +- `forgeExport` copies the runtime and nothing else; it must copy every + file the description names (looks, sounds, music, video, models, `hud`) + into the export folder, rewriting the names to be relative to it, or a + packed game refers to a kit that is not there. +- the file picker lists the game's directory; it should list `kit/` under + it too, which it will once the kit is extracted beside the descriptions. + +### 13.4 The tutorials + +Ten, in an order where each needs only what came before. The sample each +grows into already exists and is named; the tutorial's own shipped +description is a smaller, cleaner cousin of it (`Forge/tutorials/NN-name.forge`, +packed and listed by the chooser under a *Tutorials* heading). + +| # | Title | Makes | Teaches | Grows into | Scene | +|---|-------|-------|---------|------------|-------| +| 1 | **Ten minutes** | The *New game* starter, played and changed | the chooser, the four panels, `Enter` forms, `P`, `S`, `Esc`, where Forge.pdf and the kit are | -- | 83 | +| 2 | **A platformer** | coins to collect, spikes that kill, a door to a second room | types and instances, the `collision` event, `each`, `addScore`, `destroy`, rooms and `nextRoom`, the readout | platformer.forge | 84 | +| 3 | **Sprites and sound** | the same game with the kit's hero sheet, mirrored facing, a jump sound, music | `sprite` with `frames` and `faces`, the `frames` behaviour and states, `setState` rules, the `sound` behaviour with `volume`, the `music` layer, `rz` and the turn handle | platformer.forge | 85 | +| 4 | **A shoot-em-up** | waves from a spawner, a ship that fires, explosions, lives, game over | `world2d` without gravity, `spawner`, `projectile` with `aim`, `hp`, `emit` and `particles`, `lives`, `gameOver`, the results screen | shmup.forge | 86 | +| 5 | **A quick-time event** | a clip with three prompts and a penalty for missing one | the `fmv` layer, tracks, the timeline (`,` `.` `[` `]` `K`), time windows, `prompt`, `disc` conditions, branching to a second clip | qte.forge, fmv.forge | 87 | +| 6 | **A light gun game** | targets that rise from the clip, ammo, reload off screen, a score | the `gun` behaviour, target boxes on the timeline, `hit` and `miss`, `pointerOffscreen`, two players, what a cabinet needs (the light gun section) | gunvideo.forge | 88 | +| 7 | **A point-and-click adventure** | two painted rooms, a walker, a locked door and a key, a conversation | walk areas (`V`), `depthSort` and `scaleBy`, hotspots and verbs, inventory, `said`, dialogues (the fifth panel), `fade`, `save` | adventure2d.forge | 89 | +| 8 | **Into 3D** | the platformer again, in a scene: a fox on boxes with a camera behind it | `scene3d`, `model` and `mesh` looks, `light`, the 3D viewport keys, gizmos, `character`, `camera` follow, `ry` | platformer3d.forge | 90 | +| 9 | **A rail shooter** | the camera rides a rail through a painted room; targets rise at stops and fall when shot | rail tracks with stops, `spawnAt`, the gun's ray and body parts, `ragdoll`, `occluder` meshes over a painting, `navFrom` | railshooter.forge, painted.forge | 91 | +| 10 | **Releasing your game** | the platformer as a `.game` in the menu, with a high score board | `forgeExport`, `--pack`, `games.dat`, the bezel, `submitScore`, what the machine at the arcade needs | -- | 92 | + +Not tutorials, but a closing **Recipes** section, one paragraph each with a +sample to open: racing (`racing.forge`), tower defence, pool and water, +bowling, the maze, third person, space, rhythm and MIDI, the 3D adventure. +A recipe names the behaviours and the sample; it does not walk the keys. + +### 13.5 Order of work + +- **A. Foundations** -- `singeScreenshot(name)`; the kit drawn and packed, + extracted on first run, listed by the file picker; `forgeExport` copies + named files; the chooser's *Tutorials* heading; `== Tutorials` in + `Forge.adoc` with `:imagesdir:` and the first picture in the PDF to prove + the build embeds it. +- **B. Tutorials 1 to 3** and their scenes. These settle the voice and the + skeleton; the rest copy them. +- **C. Tutorials 4 to 7.** +- **D. Tutorials 8 to 10** and the recipes. +- **E. A read-through** of the reference chapters against the tutorials, + so every concept a tutorial teaches links to the paragraph that owns it, + and nothing is explained twice in two ways. + +Each stage ends with `./build-docs.sh`, a look at Forge.pdf's pages, and the +refs sweep. The pictures are regenerated whenever the editor's chrome +changes, by running the tutorial scenes; a stale picture is a scene that +was not rerun, and the sweep catches a tutorial whose keys no longer do what +its text says. + +### 13.6 Built (2026-09-14) + +All ten tutorials and the recipes are in `docs/Forge.adoc`, with their +scenes 83 to 92 and the shipped descriptions in `forge/tutorials/`; +the kit is drawn and synthesised by `util/forgeKit.py` into +`forge/kit/` (the clip is 720 by 480, the overlay's size; a smaller +disc was thought to draw nothing, but that was the editor's opaque ground +leaking into the game, fixed since -- a 480 by 320 disc under a 720 by 480 +overlay draws and scales as it should, checked 2026-09-14). What the tutorials forced into being, because +a reader could not otherwise do what the text said: + +- `singeScreenshot(name)` in the engine. +- The kit stays inside Forge.game and is named `Forge/kit/...`; nothing is + extracted. The file picker lists it beside the game's directory. +- `forgeExport` (and `X`) copies every file a description names, at the + same relative name; the compiled script sets `AUTHOR_DIR` and the runtime + resolves names beside the game first (`authorFile`). The editor resolves + beside the description (`forgeFile`). +- The game's own form when nothing is selected: title, players, vars, + verbs, layers, and each layer's parameters, with tables typed as + `r=1, g=2` or `1,2; 3,4`. A scene3d layer typed in starts the viewport. +- `1` to `5` for the panels; `Delete` empties a field; a passed-over field + keeps its value (it used to be emptied); behaviours and layers typed away + and back keep their values (a stash), since the setter runs per keystroke; + new parts and behaviours fill in only what must be there (self, a key, a + switch), leaving numbers to their runtime defaults; the file picker starts + on the file already named or on `(none)`; exact picker matches sort + first (`miss` before `branchMissed`); the panel hides while a polygon is + drawn; in a 3D room `A` places on the floor under the viewport's middle. +- The runtime: the platformer sets `facing` and `state`; a flat game with + no disc paints its own dark ground; `scaleBy` scales only what stands on + the floor (anchor feet); text looks sort in front under `depthSort`; the + chooser lives in the library (`forgeChooser*`) and lists `Forge/tutorials/`. +- The compiler emits dialogue nodes in a fixed order, so the same + description compiles to the same text every time. + +The pictures go into the book through `util/forgeShots.py`, which scales +them to 768 wide and reduces them to a palette (a full-window PNG per step +made Forge.pdf 7.5 MB; this keeps it small). + +**Renamed** (2026-09-14, the owner's call): what an entity is made from +is a *type*, not a kind -- `types = { hero = ... }`, `type = "hero"` on an +entity and in an event's filter, the Types panel, `forgeType*` in the +editor -- everywhere at once, with no compatibility kept. `kind` stays as +the discriminator on a look, a behaviour, a layer, and a rule part +(`{ kind = "sprite" }`), which is the one thing it no longer collides with. +The manifest's parameter type is `type` too. + +**Lists of records** (2026-09-14): a table parameter typed as text now +takes records (`name=head, bone=b_Head_05, w=0.5; name=body, ...`, one per +semicolon), maps of lists (`look=look|examine`), and lists of words, so +`target.zones`, `parser.words`, and `parser.ignore` are typed in the form +like everything else, and read back the same way. + +**The sunk hero** (2026-09-14): two things. Every flat body was half the +size its look was drawn at -- the 2D branches of `solid`, `body`, and +`trigger` handed `bodyNew` halves, which takes full sizes -- so the ground's +top was ten pixels under the drawn top; and a player stands on its node +(the node is the feet, as `playerNew` says) while the look was drawn +centred on it, so the box sat half its height into the floor. Bodies are +full size now and a platformer draws with anchor feet; the starter's hero +says so and starts on the ground. + +Scene 92 packs the release with `--pack` when the harness hands it the +engine's path in `SINGE_BIN`, and says so when it cannot. Packing found +that the export's `games.dat` named the script by its absolute path, which +`--pack` refuses: names in it are now the folder's own name first +(`CoinRun/CoinRun.singe`), the folder is named for the game, and the chooser +tells a packed `.game` from a description by the database header. + +**The first desktop run** (2026-09-14, the owner's): entities select and +the panel drags, so the RmlUi pointer path is proven -- the open hardware +check from section 1 is closed. What it found: no key but the switch +ones reached the editor -- SPACE opened a game in the chooser because +controls.cfg maps it to a button, and ENTER and TAB did nothing -- because +Forge's launcher never asked for `MODE_FULL`, in which every key reaches +`onKeyPressed`; it does now (the runtime always did). Beside that, the +engine now hands TAB to a GUI document only while a text field is being +typed in, since RmlUi takes it for focus and keeps it; the drawn pointer +went under the panel (it is drawn after the GUI now); and a readout's box +was centred on its point while its text hangs from it (the editor's bounds +for a text look are its top left now, as the game draws it). The owner +runs Forge from a loose copy in the test library, which no build refreshes; +it was a day stale. + +**The second desktop run** (2026-09-14): TAB worked; the mouse could no +longer select, and the cross was still under the panel. The mouse: in +`MODE_FULL` the engine handed a mouse or pad button to `onInputPressed` as a +keysym of 0 rather than as its switch, which no script could use; a button +cannot be typed, so it now arrives as its `SWITCH_*` value in either mode, +as the manual always said. The cross: the engine draws every GUI over the +overlay after the frame, so nothing drawn on the overlay can sit over the +panel; the cross is an element of Forge's own document now, moved to the +pointer each frame, and draws with the chrome and over it. + +**Overlay layers round guiDraw** (2026-09-14, the owner's call): the +document element was the wrong answer -- anyone writing a mouse or gun game +would have had to fight the same thing -- so the engine now layers the +overlay round each `guiDraw`: what a script draws after the call goes on a +layer composited over that document, up to sixteen a frame, uploaded only +when drawn on. Forge's pointer is two lines drawn after `guiDraw` again, +and the manual's `guiDraw` entry and scene 93 (a box under a document, a +cross and a second document over it, a box over that) cover it. + +**Rows and fields take clicks** (2026-09-14, the owner asked why they did +not): only the grip had a handler. Every row the panel lists is a div with +an id now, wired after the list is set to one dispatcher, `forgeRowClick`: +a row selects what it names, a picker's row takes it, and a field in the box +below starts typing it (`forgeEditField`). The keyboard stays complete, so +a cabinet loses nothing; scene 82 drives the dispatcher. + +**Clicking a field, then typing** (2026-09-14): the owner could click a +field and nothing happened. The engine's `guiSetHandler` kept one listener +per id and never moved it: once a list was rebuilt through `inner_rml`, the +listener sat on an element that no longer existed, so every row of every +rebuilt list was deaf -- the entity rows included, from the first refresh +on. A handler follows its id now (the engine re-attaches when the element +behind the id has changed), scene 82 clicks rows through RmlUi's own +`DispatchEvent` rather than the dispatcher, and the "panel at" and "moving +the panel" messages are gone. A wrong turn on the way: `focus: none` on +the rows, meant to keep RmlUi from turning ENTER on a focused row into a +click, stopped every click instead -- RmlUi delivers a click to the nearest +focusable element under the pointer, so an unfocusable row cannot be +clicked at all. Rows are focusable; ENTER only becomes a click on an +element whose `tab-index` is auto, which no row's is. Proven with RmlUi's +own mouse processing driven from a scene: a click on an entity row selects +it, a click on a field starts typing it, and the typed text lands. Labels +in the box carry a colon, `id` shows as `ID`, and editable rows light up +under the pointer. + +**Typing in place, and SHIFT** (2026-09-14): a value is typed in its own +row now, with a caret, rather than echoed on the message line; and SHIFT +did nothing because a keysym is the unshifted key -- the engine gained +`onTextInput` (SDL text input runs while a script has `MODE_FULL`), the +launcher feeds it to `forgeText`, and while typed text arrives the keys' +own characters are not typed as well (`FORGE.textInput`). The scenes still +type through keysyms, which is why they need no shift. + +**Editing in place, and the way back** (2026-09-14): a value now starts +selected whole when its field is entered, so typing replaces it, and the +arrows, Home, and End put a caret into it to edit it (`forgeEditingOf`, +`forgeEditInsert`, `forgeEditBackspace`, `forgeEditDelete`, +`forgeEditCaret`); DELETE on the selected value empties it, which is what +the tutorials' "empty the entity" means. And the game's own form was out +of reach once anything was selected: the game's name at the top of the +panel and a press on an empty spot of the canvas select nothing now. + +**Pickers for every fixed-set field** (2026-09-14): the owner: "Editing +items that have a fixed list of options does not display a list of options +-- you're expected to know what you're allowed to type. This is bad. +Dropdowns exist for a reason." `forgeFieldKind` says what a field is from +the manifest (its parameter type, or the words a `choices` table on the +manifest entry lists -- `anchor`, `faces`, a camera's `mode`, a light's +`type`, a body's `shape`, ...) and from the editor's own fields (a type's +look, a rule's event, its flags, an entity's type, a track's key, a +dialogue's nodes); `forgeFieldChoices` turns that into picker items and +`forgeFieldList` opens the picker, on the value the field has. Free lists +(type, room, entity, state, track, node) offer the typed text first, so a +name made later can still be typed, and a value the list lacks is offered "as it +is", so walking past the field with ENTER keeps it (the first sweep lost +`goTo.room = "cave"` that way, the room not being made yet). 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 one ENTER per field; a click takes it and +stays. Choosing the value a field already has does nothing (choosing a +type's look again used to empty its parameters, and an event again its +filter). `tutorialDrive.form` reaches a field with a while rather than +a repeat, and leaves a list and then the field with ESC. A long list +windows `PICK_ROWS` rows around the chosen one. The owner then found the +list taking the panel's top list over confusing ("Isn't there a regular +drop down?"): a field's picker (`FORGE.picker.field`) now unfolds under +its own row in the fields box as a `.drop` element, absolutely positioned +with no top or left so it takes its static place and floats over the rows +beneath (`dropdown()` and `pickerRows()` in Forge.singe, `DROP_ROWS` 6); +only the manifest pickers (C, T, E) still take the list over. Then +"The dropdown should open upward when there's no room below. And what +about scrollbars?": the panel is a flex column whose list and fields box +share the height and scroll (`overflow-y: auto`, styled `scrollbarvertical`), +and `forgePlace` -- run at the start of every draw, on the first draw +after the frame a refresh happened in, since a key or click is handled +before that frame's draw and new elements measure nothing until the +engine's guiUpdate has laid them out -- scrolls the chosen row and the +edited field into view (`scrollTo`, nearest) and lifts a dropdown above +its row with a negative top margin when its bottom would pass the panel's. +The dropdown is hidden until placed, so it never flashes below first. The +RmlUi trap under all of it: the document body had no height, so the +panel's `height: 100%` was zero and everything in it was overflow -- which +is why `overflow-y: auto` on the panel "made it vanish" and a percentage +`max-height` collapsed; `body { width: 100%; height: 100%; }` fixed both. +A scrolling box is an offset parent, so a row's `offset_top` is relative to +its box (`offsetWithin` sums the chain). The vocabulary tables print the words instead of +"string" for a parameter with choices. + +**The build owns Forge.game** (2026-09-14, the owner's rule): the release +build's `forge` target repacks `.builddir/Forge.game` whenever anything +under `forge` changes -- scripts, kit, tutorials -- and Forge.pdf is +rendered again whenever the book, its vocabulary, a tutorial's picture, or a +shipped description changes; both globs are re-run on every build, so a +new file counts too. Nothing about Forge is copied anywhere by hand. + +Not done: cropping the pictures (full frames read fine at page width); +the light gun checks stand as before. + +## 14. Porting the library (planned 2026-09-14) + +The owner: "I think a really good test of this would be to port some +existing games to Forge projects." Then: "Ideally, we would be able to +port EVERY game to Forge and they'd play identically to the current +implementations." That is the goal of this section: every game in +~/claude/singetest (48 directories, 58 launcher entries) as a Forge +description that plays the same, proved rather than eyeballed. + +### 14.1 What "identically" means, and how it is proved + +A port and its original are run headless, on the deterministic clock, with +the same scripted inputs -- pointer positions, trigger pulls, moves, coins -- +at the same disc frames, and a trace of what matters is compared: the disc +frame path (every search), the state or room each frame lands in, and the +score, lives, hits, and misses. The original is driven by calling its own +callbacks (onMouseMoved, onInputPressed) from a wrapper script that dofiles +it; the port by authorSetPointer and authorSwitchDown. A port passes when +the traces agree. Pixel-exact drawing is not the bar: positions come out +of the same numbers, but a text measured by a different font path may sit a +pixel off, and that is fine. + +The comparison scenes live in testScripts/ports/, one pair per family, +and need the library beside the repo; they are not part of the reference +sweep. + +### 14.2 The families + +The library is not 48 different programs. It is four families and a few +one-offs, and a family is ported by a converter that reads the original's +own data tables and writes descriptions, so the port is mechanical and the +vocabulary Forge lacks shows up as a list, once per family. + +1. **ActionMax** (5 entries, one Emulator.singe, 558 lines): a light gun + over VHS video. A shot is judged by comparing the brightness under the + gun with a sensor spot on the picture, sampled across two frames. Forge + lacked the sensor; it gains a `lightSensor` behaviour, a `pointer` + behaviour (a sprite that follows the player's pointer, hidden when the + engine says a real gun needs no crosshair), fonts on the text look, + anchors on it, sprites drawn in copies (a row of bullets), discPlay and + discPause actions, fire and miss clips on the sound behaviour, and + pointerX and pointerY in expressions. Converter: util/forgePortActionMax.py + reads each game's parameter file and the sprites' sizes and writes the + five descriptions beside the originals. First, because it is the + smallest complete game and every gap it finds is general. + +2. **KarisFramework** (17 entries under KarisFramework/ plus the older + per-game copies of the same author's "LUA SINGE 1.1" in the Italian + Singe 2 games -- DragonTrainer, Conan, Daitarn3, SamuraiJack, + PussInBoots, DragonsLairTvShow, SuperDonQuixote -- 25 or so in all): + QTE laserdisc games. Data: Level[] tables (name, frames, death and + resume frames), Death[] clips, Cfg/s*.cfg move lines (one encoded line + per scene: frame, moves, mash and skip flags), menus at video frames, + difficulty penalties, tiers and random order, map mode, trophies, hints, + high scores, saves. Forge has the branching track, discTo, lives, + credits, the results board, and submitScore; it will need the move + line's extras (several moves in one window, mash, skip), difficulty as a + var the windows read, an order of levels (sequence, tiers, random) as a + track of rooms, and menus driven by video frames. Converter: + util/forgePortKaris.py, reading the game's settings file and Cfg/ and + writing one description; the framework's menu and service screens are + Forge's own. The largest family and the largest payoff: one converter, + twenty-five games. + +3. **American Laser Games** (Mad Dog McCree 1 and 2, Crime Patrol 1 and 2, + Space Pirates, Who Shot Johnny Rock, The Last Bounty Hunter; each in a + Singe 2 and an HD edition, 14 entries; Platoon by the same hand): + light gun over video with hitbox tables per scene + (Script/hitbox-*.singe: boxes by frame), a scene graph of success and + failure clips, service menus, high scores. Forge has hitbox tracks over + video and the gun; it will need the scene graph as rooms with rules on + frameReached, and whatever the service and manage scripts do that a + player can see. Converter: util/forgePortALG.py, reading the hitbox + files and the scene tables. + +4. **One-offs**: Rollercoaster (a text adventure over the disc: the + parser layer, rooms, discTo), Hologram Time Traveler (gun and branching, + 6k lines), LINEA (MazescaterFramework), BatMaker (a tool that writes + launchers, not a game: not ported). Each by hand, after the families, + using what the families built. + +### 14.3 Order of work + +ActionMax first (this section's first entry below), then Karis, then ALG, +then the one-offs. Each family: read the original; list the vocabulary +missing; add it to the manifest with a scene of its own; write the +converter; run the comparison; fix what differs; note what was learned +here. A family's converter is kept in util/ and re-run whenever the +manifest changes, so the ports never drift from the editor. + +**The extension** (2026-09-14, the owner: "You're making a lot of +Forge-related files with the extension '.game' that are just ASCII text. +We're already using '.game' for our packed releases."): a description is +a `.forge` file from here on -- the tutorials, the samples, the ports, what +Forge saves, and the copy a release carries -- and `.game` is the packed +release alone, so the chooser no longer sniffs a database header to tell +them apart. Earlier sections say `.game` where they meant a description; +read `.forge`. + +### 14.4 ActionMax, ported (2026-09-14) + +Done: util/forgePortActionMax.py writes the five descriptions beside the +originals in the library (38AmbushAlley.forge and the rest), and +testScripts/ports/actionMaxOriginal.singe and actionMaxPort.singe run the +emulator and the port on the inputs in actionMaxInputs.singe -- a pull on +the title, a pull on the menu's left half, forty shots at three spots the +picture flickers on and a few elsewhere -- and print the same trace: every +state change with its disc frame, and at every input the state, the disc +frame, and the shots, good hits, and bad hits. .38 Ambush Alley's traces +agree line for line (one good hit among the forty), and Blue Thunder's. + +What the port taught, all of it general: + +* **The overlay size is part of "identically".** The emulator lays itself + out on overlayGetWidth(), which is the engine's default over a disc -- + half the video, 360x240 -- since a launcher entry's RESOLUTION keys size + the window, not the overlay. Every number in the game (the sensor spot, + the corner shots are not judged in) assumes 360x240; a port declares + `size = { w = 360, h = 240 }`, and the original's wrapper must not set + the overlay to anything else. The first comparison, at 720x480, found a + "good hit" that was the sensor reading the wrong place in both. +* **An event belongs to the room it happened in.** A press on the title + that goes to the menu was also a press in the menu, in the same dispatch, + and started a game; matches() now compares the rule's room with the room + the event was raised in, and a frame's rules run for the room the frame + began in (frameReached too). +* **A built game finds files beside its description.** AUTHOR_DIR is the + built script's directory, which is the data directory for a description + compiled where it lives; the compiler now also writes AUTHOR_SOURCE_DIR + and authorFile looks there second. +* **Vocabulary:** lightSensor (the two-frame judgment, raising hit, miss, + and fire; moved into the games' own Vocabulary.singe on 2026-09-15, see + 14.7), pointer (a crosshair that follows the pointer, hidden when + singeWantsCrosshairs says a real gun needs none), the text look's font, + size, and anchor (centre, feet, top), the sprite look's copies (the var) + and step, discPlay and discPause, fire and miss clips on sound, and + pointerX and pointerY in expressions. Scene 96 (overlayBits.forge) covers + the general ones from the repo alone. +* **The wrapper for an original** sets what its launcher entry would have + (SINGE_LEGACY_SPRITE_ARGS) and answers singeGetScriptPath with the + original's own path while it loads, so MYDIR is right. +* The two-frame sampling judges few shots: in forty aimed at flickering + spots, one. That is the emulator's own behaviour and the port's. +* **The editor opens a port**, and scrubs a frame file's video: the disc + layer of an ActionMax game names frame_.txt, which lists videos by + first frame; `frameFileVideo` picks the one with the longest run (the + game's) and `FORGE.videoStart` puts the cursor's disc frame on the right + picture. Not done: the editor's canvas stays 720x480 whatever the + game's `size`, so a 360x240 game is drawn at its own coordinates in the + top left quarter -- right, but small. +* **Esc on a passed-over field wrote nothing back after all** -- it used + to "put back" an equal value, which for a type's vars turned nothing + into an empty table, and the shipped tutorial descriptions carried those + tables. The sprite look's new `step` parameter moved which field the + tutorial's Esc landed on, tutorial 3 stopped matching, and both ends + were fixed: `forgeEditCancel` sets only what the typing changed, and + the compiler leaves a type's empty vars table out, so a description with + one and one without compile the same. + +### 14.5 KarisFramework, ported (2026-09-15) + +Done: all seventeen entries (Time Gal, Dragon's Lair Enhanced, Dragon's +Lair II Enhanced, Space Ace Enhanced, Asterix, Cliff Hanger, The Elder +Scrolls, Oeil pour Oeil, Altered Carbon, Tron, Friday the 13th, Mononoke, +Ninja Hayate HD, Titan A.E., Sucker Punch, Fire and Ice v1 and v2, +Chantze's Stone and Triad Stone) play as their originals do under the same +driver: testScripts/ports/karisDrive.singe presses START on the attract +loop, answers every move three frames into its window -- the right answer, +or a wrong one for every fifth move -- takes the one continue, and traces +every disc seek and every change of screen, step, level, scene, move, +score, and lives. All seventeen traces agree line for line, including the +life-bar game type (Tron), the in-game difficulty screen (Space Ace, Tron), +the level maps with a cursor (Sucker Punch, Friday the 13th), the random +level orders (tiers in Time Gal, Dragon's Lair's shuffled last levels), +the mirrored scenes, and each game's own scoring add-ons. + +How: + +* **The converter runs the game's own script.** util/forgePortKaris.lua + loads a game's settings script with the engine stubbed out, so every + table it declares (Level, Death, Tiers, PlayOrder, the offsets, the + scores, the dips from Cfg/game.cfg or default.cfg, the high score board) + is read as data, and setupMoves is called for every level and scene at + every difficulty for a snapshot the editor can show. The description + (`.forge`, beside the game) carries the snapshot under `qte` and + names the script and Script/addons.singe. +* **The runtime is the framework's loop, with the framework's names.** + The `qte` behaviour in Author.singe keeps its state in one table under + the framework's own variable names (iScore, iLives, currentMove, move[], + stage[], SCOREMOVE, offsetGetReady, dip_Difficulty...) and plays the + framework's states with the framework's numbers (levelNormal = 103, + branch02 = 11...). A shim environment whose globals are that table runs + the game's script and add-ons verbatim, so Space Ace's setupMoves reading + which levels are beaten, Dragon's Lair's startConf shuffling the last + levels and its per-move specialScore, Sucker Punch's map, and Space Ace's + random game-over and death clips all behave as written. The framework's + own functions the add-ons call (timerON, setupClip, soundPlay, spriteDraw, + joyDelayDue...) are shim helpers. +* **The random stream is shared.** The loop calls math.random exactly where + the framework does (the level order, each scene's mirror, a random death, + a choice's shuffle on its first drawing) and seeds from KARIS_SEED when a + comparison sets it, so both runs draw the same numbers. +* **A game hears the switches.** An event about nothing in particular (a + press, a release) now reaches behaviours that set instance.hearsAll, which + the loop does to read the framework's p1 flags. +* **A built script must not be the game's own script.** The loop loads the + game's files from beside the description first: building Timegal.forge + to Timegal.singe in the same place loaded the built game inside itself + and overflowed the C stack. +* **The framework never calls discPlay.** A seek resumes a paused disc, and + a discPlay a frame before the seek moved two games' traces by one frame. +* **The frame clock and the CPU clock.** The framework's timers are + os.clock; the port's are the game clock. The traces compare seeks and + states, not times, and the driver acts on disc frames, so the difference + never shows -- except that a screen left "after 120 seconds" costs two + real minutes in the original and two virtual ones in the port. + +Not done: the high score board's name entry (the port plays its clips and +enters nothing), the service menu, saves and loads, two-player mode, and +the drawing -- the port draws no arrows, LCD text, or figures yet; it +exposes score, lives, credits, level, and prompt as vars. The Italian +Singe 2 games (DragonTrainer, Conan, Daitarn3, SamuraiJack, PussInBoots, +DragonsLairTvShow, SuperDonQuixote) are the same author's earlier +framework with per-game copies and a different level structure +(createLevelNN, segments); they are the next family. + +### 14.6 What the rest of the library is, and a decision to make (2026-09-15) + +The two families ported so far were data-driven: a script full of tables, +one shared loop. The rest is not: + +* **The Italian Singe 2 QTE games** (Dragon Trainer, Puss in Boots, Samurai + Jack, Daitarn 3, Conan, Dragon's Lair TV Show, Super Don Quixote) each + carry a *copy* of the older KarisFramework (LUA SINGE 1.1) beside their + data, and the copies are edited per game: Daitarn's by 29 lines, Conan's + by 560, the TV Show's by 1,787, Super Don Quixote's by 7,907 -- the + framework is part of the game. The loop is a simpler ancestor of the + one now in Author.singe (segments, per-level offsets, a 2,000-line + setupDeathClip that maps each death to its offsets by hand). +* **The American Laser Games titles** (Mad Dog McCree I and II, Crime + Patrol I and II, Space Pirates, Who Shot Johnny Rock, The Last Bounty + Hunter, each in two editions, and Platoon) share a skeleton (move tables + with hitbox ranges, hitmap arrays by frame, bullets and reloads, an + undertaker) but every scene is a hand-written function -- doLevelSaloon, + doLevelBank, twenty to forty per game -- with its own branches, items, + and clips. There is no table to convert; the scene logic is the game. +* **The one-offs** (Rollercoaster, Hologram Time Traveler, LINEA) are + bespoke scripts. + +Two ways to make these Forge games that play identically: + +1. **Reimplement each loop and rewrite each scene** as Forge rules and + tracks. Faithful only if every quirk of every hand-written scene is + carried over by hand; for the ALG family that is some two hundred scene + functions, each to be compared move for move. Months. +2. **Host the game's own Lua** inside a Forge behaviour, as the qte + behaviour already hosts a Karis game's script and add-ons: the + description carries what Forge understands (the disc layer, the gun, + hitbox tracks converted from the hitmap arrays so the editor can show + and edit them, the release path), and a `hosted` behaviour runs the + game's scripts in a shim with the engine's callbacks routed through it. + Identical by construction; the port's value is Forge's structure + around the game, not a rewrite of it. Days. + +The owner should say which of these counts as a port. Until then the +order stands as written: the Italian family next (its base loop is small +enough to reimplement, and its per-game copies could be hosted for the +differences), then ALG. + +### 14.7 A game's own vocabulary (2026-09-15) + +The owner asked whether a game's author can extend the kinds, looks, and +behaviours without putting one game's hand-written piece into every game's +Forge; the light sensor was the case in point. Yes, now: + +* A description may say `vocabulary = "Vocabulary.singe"`, a file of Lua + beside it that adds entries to `AUTHOR` exactly as Author.singe does (a + `bob` behaviour is `AUTHOR.behaviours.bob = { params, attach, step }`; + a condition or action gives `emit`). The file may define any globals + its entries emit or call. +* `authorVocabulary(name, folder)` in Author.singe loads it -- beside the + description first, then beside the built script, through the shared + `authorBeside(name)` that the qte loop's script loading now uses too -- + and records what it added; `authorVocabularyForget()` takes that out + again. `authorKeyValue` and `authorSwitchValue` are public now so a + vocabulary's behaviour can resolve a key or switch name. +* The compiler loads it before `authorCheck` (so the checker knows the + words) and emits `authorVocabulary("...")` after `AUTHOR_SOURCE_DIR` in + the built script, so the game loads it wherever it runs. +* The editor loads it in `forgeBegin` (forgetting the last game's), forgets + in `forgeClose`, has a `vocabulary` field (kind file, `.singe` listed) on + the game's form that reloads as it is set, and `forgeFilesNamed` takes + the file so a release carries it. +* The light sensor and its pixel reader left Author.singe for + `~/claude/singetest/ActionMax/Vocabulary.singe`; its trigger is a + `pressed` event heard through `hearsAll` and read in `on`, so the switch + handler in the core no longer knows about sensors. The converter emits + the `vocabulary` line; all five ActionMax compares still agree. +* Scene 97 (`testScripts/author/vocab.forge` + `vocab.singe`) covers it + from the repo alone: the editor knows `bob`, `bobbing`, and `setBob` + while the game is open and not after, the built script names the + vocabulary, and the game plays by it. The book has "Your own + vocabulary" under The Vocabulary. + +### 14.8 The ports in a project folder of their own (2026-09-15) + +The owner asked for the ported games in their own folders under a project +folder parallel to the library: `~/claude/singePorts/`. `util/forgePorts.py` +assembles it from the library and builds it, and is the thing to rerun when +a converter, a description, or Forge changes: + +* One folder per game, named as the library names it (the two-game folders + ChantzesStone and FireAndIce keep both games); the five ActionMax games, + which share one folder in the library, get one each with their own video, + frame file, and cabinet art plus the family's sprites, sounds, fonts, and + Vocabulary.singe. +* A KarisFramework game's folder travels as it is, less the framework copy + and the original games.dat; the game's own script comes because the qte + behaviour plays it. The ActionMax emulator and original scripts do not. +* Video is hard-linked, not copied (the library's videos are never edited + and copying them is some forty gigabytes); everything else is a copy. +* Each folder gets the compiled port as `.port.singe` (never the + game's own name -- the built script would load the game inside itself) + beside a copy of Author.singe, compiled by the engine running + AuthorCompile from a temporary directory with Forge beside Singe, and a + games.dat made from the original's entry: the port as SCRIPT, the + folder as DATA, LEGACY_SPRITE_ARGS off (the port speaks the engine's + own API through the shim). +* The README in the folder says all this in the owner's terms. + +Every port launched from its games.dat headless for twenty-five seconds +without an error (`testScripts/ports/launch.sh`: Xvfb at 1920x1200, since +SDL's offscreen desktop is smaller than the Karis games ask for, and the +deterministic clock). The originals in the +library are untouched; the descriptions beside them are what the tool +copies, so the converters still write there. + +### 14.9 The Hypseus library (2026-09-15) + +The owner asked about the Hypseus games, with the end in view: port as many +as possible so the originals can eventually go. `~/claude/singetest/hypseus/` +holds 60 title folders (169 GB, the upstream zip-ROM layout: +`/singe/<Title>/` for the scripts, a framefile and `Video/` beside). +Fifty-six have scripts; four are video-only alternates (two Hayates, two +Time Gals). They are seven families: + +1. **Karis Framework 3.32b** (11): Arcade Xperience 1, 2, and 3, Astroboy, + Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's + Aurunmilla, Badlands Lite. The library's copy is 3.31c with the + owner's patches; 3.32b adds a language dip (an audio suffix), a + hold-to-loop dip the loop already knew, a fourth-button secret + combination, and a free-play guard on the two-player start. **Ported + today:** the converter finds the framework where a game keeps it + (`KarisFramework/Script`, `Structure`, or `../Framework`) and writes its + version into the description; the loop reads the version for the + secret combination and maps the fourth button; `compare.sh hypseus + <Title>` stages a run directory with the title's `singe/` beside it + (the scripts hardcode `BASEDIR = "singe"`); `forgePorts.py` flattens a + title into `singePorts/<Title>/` with its framefile and video and + writes the games.dat it never had. The comparison found an engine + bug rather than a port bug: Freddy, Starship Troopers, and Sugar Rush + died, original and port alike, at their map screen's + `musicStop(sndHandle)`, because Singe's `musicPlay` answered nothing + where Hypseus's answers the handle; it does now, and `musicStop` + takes a nil handle as none. The earlier Hypseus sweep ran forty + seconds a game and never reached the map. With the fix, all eleven + traces agree with their originals, and every one of the thirty-five + ports in singePorts launches from its games.dat. +2. **RDG's LUA SINGE 1.0 and 1.1, the map-mode lineage** (4 here, 7 in the + library): Freedom Fighter, Starblazers, Jeeg, HQ Time Gal here, and + the Italian Singe 2 games there (Dragon Trainer, Puss in Boots, Samurai + Jack, Daitarn 3, Conan, Dragon's Lair TV Show, Super Don Quixote). One + ancestor loop (`main.singe` of some 4,000 to 6,500 lines with + `map.singe`), copied per game and edited a little in most and a lot in + some. Future Boy and Road Blaster carry later Karis versions (3.31c + with a map, and 2.2) in `Script/`, closer to this lineage than to the + 3.32b loop. +3. **Kimmy Script Engine 4.0** (7): Brain Dead 13, Future Boy Conan + (Kimmy), Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Hayate 1080. + Karis's 2024 successor to the framework: a 5,900-line `main.singe` + and a 4,900-line `moves.singe` (combos, skins, an LED panel), three + versions across the set. Data-driven like the framework it grew from. +4. **American Laser Games, two-player HD editions** (11): Crime Patrol, + Drug Wars, Last Bounty Hunter, Mad Dog I and II, Space Pirates, Who Shot + Johnny Rock (twice), Johnny Rock Noir (twice), Mad Dog II Typing + Edition. The same skeleton as the library's ALG family, with the + hitbox files reworked for two players. +5. **Singe 1 monoliths** (7): Time Gal, Ninja Hayate (twice), Esh's + Aurunmilla (the SBC port), Scrat -- each a 12,000-to-16,000-line + `main.singe` of its own, no two alike -- and Cracke and Triad, an + Italian 476-line framework plus a game. +6. **Widge's and Luthergond's games** (11): Cops, Fast Draw Showdown, + Gallagher's Gallery, Marbella Vice, Platoon, Tierras Salvajes (the + "multiplayer editions": a 35-line launcher and a `.bin.luac`, which + `singetest/tools/decompile` turned back into source), Captain Power + (four sub-games), Minesweeper, Rick's Revenge, Space Rocks, and SEGA + Video Driver (seven sub-games on one engine). Not laserdisc QTEs: + overlay games that happen to use the disc, each its own program. +7. **Data only** (3): Esh's extras, Road Blaster's screens, the Video + Driver batch files. + +What this means for the end goal. Family 1 is done by the existing loop. +Families 2 and 3 are the framework kind: a converter reading each game's +tables and one more loop per lineage in Author.singe, as the qte loop was +built -- days each, and they cover eleven and seven games. Families 4, 5, +and 6 are hand-written programs (twenty-nine here, and the library's ALG +and one-offs make it forty or so): no table to convert, so the choice in +14.6 decides them. Hosting the game's own Lua inside a Forge behaviour +still lets the original folder go -- the script travels with the port, as +the Karis games' scripts already do -- and it is the only route that is +not months. The owner's answer was the goal "complete the Hypseus ports, +starting with the Kimmy Script ones", and 14.10 is how: on a closer look +the Kimmy engine went the hosted way too, because its three versions +across the seven games are twenty thousand lines each and differ from one +another, and a transcription of one would play the other six wrong. + +### 14.10 Hosting, built and withdrawn (2026-09-15 to 2026-09-16) + +A `hosted` behaviour was built that ran a game's own Lua whole, framework +and all, inside a shim, and fifty Hypseus titles were "ported" with it and +shown to play as their originals. The owner's verdict on seeing what it +was: "a game that we can't really edit that carries the burden of +another framework." Right: a wrapper, not a port -- nothing in the +description to edit, the framework and scripts still in every folder. It +was withdrawn the next day: the behaviour, its draw hook and fan-outs, +the disc layer's `paused` flag, the converter, the drive, scene 98, the +book section, and the fifty descriptions are gone, and those originals +are back in singetest/hypseus. What stays from the episode: `authorFan` +(one fan-out for the instance-less events), the Karis converter's +framework search and version, `musicPlay` answering its handle, and the +Singe framework lines added to the Captain Power and Video Driver scripts +and Sintel's framework copy (library fixes, not port artefacts). + +The direction now, the owner's choice: drop hosting; port the two +framework families properly (a converter reading each game's tables and a +loop of Forge's own per lineage, as the Karis family was done); then +rewrite the hand-written games too, one by one, in Forge's terms, each +with a comparison harness of its own. + +### 14.11 The Kimmy Script Engine, properly (2026-09-16) + +**What it is.** Karis's 2024 successor to the framework the qte loop +plays: the same state machine (gameflow, lvlState with the same branch +numbers, the same level screens, Level[] and Death[] tables, setupMoves +per scene, Cfg/game.cfg dips, a Script/addons.singe), with a new move +system in a file of its own, moves.singe. A move is judged by a +seven-bit mask of what is held (acombo, from the input handlers) against +the mask the move asks for (gcombo, from fillMove), which gives combos +(two inputs at once), diagonals, and a second accepted answer; mash has +up/down, left/right, and button-pair variants with counters scaled by +difficulty and window length; hold wants the combo held for a computed +length; multi and loop are sequences (loop draws a wheel sprite chosen +by the sequence's shape); path branches by direction with jumps to a +later move (iPathAend/iPathAjmp) or a death (iPath > 1000); yes/no and +timed branch too; way, wayout, toscene, and tolevel are jumps without an +input; anything and nothing are what they say. Around that: tilt (a +count of presses outside a window that kills at a threshold, with a +warning), a life bar for game type 1, rewind modes 0/2/3 on death, a +"next" hint of the coming move, hints on death, 2P alternation, a level +select screen (dip_PlayStyle 4), difficulty 0 simplifications and +MashtoRun/HoldtoLoop rewrites at setup (setupFramesMoves), and mirroring +(bFlip) of every move kind. Seven games; their frameworks are three +versions (BD13's; FBC's; mazinga/Fuma/Sonic's) that differ by ten lines +of main and eighty-four of service from one another, plus hayate_1080's +Structure copy (a later 4.0 with an LED panel and a Menu folder, 358 +lines of main and 577 of service apart), plus FBC's and Fuma's own edited +copies of main/service/toolbox in their Script folders (6/49/1 and +39/20/0 lines). The differences are catalogued below as they are read. + +**The design.** A `kimmy` behaviour beside `qte` in Author.singe, +sharing everything of the Karis loop that Kimmy left alone (the Q +helpers: timers, sounds, the shim, the script and add-on loading, the +scoring and beat-game tests, the mixes and orderings, the continue, game +over, and difficulty screens, the high-score board) and carrying its own +K functions for what Kimmy changed: K.doLevel, K.doMove and the checks, +K.fillMove, K.setupFramesMoves and K.getShortcuts, K.resultMove and +K.resetVar, K.startGame, K.doIntro, K.doFinish, K.levelReplay, +K.nextLevel, the level select and save screens, the input handlers with +the combo masks, and the drawing (in Forge's terms: text and boxes, as +the qte loop draws, not the framework's sprites). The converter grows a +Kimmy mode: it finds the framework at ../FrameworkKimmy or Structure, +stubs the Hypseus calls the framework makes at load, snapshots the +same tables plus the path/timed/multi ones, and writes +`kimmy = { ... framework = "4.0" ... }`. Per-version and per-game +differences that a player can see go under version flags in the loop +where they are a line or two, and into the game's own vocabulary file +where they are a game's own thing (FBC's and Fuma's edits, hayate_1080's +LED panel). The comparison drive grows a Kimmy policy that answers +every move kind: hold for the computed length, mash at the counter's +rate, the sequence for multi and loop, a direction for path, the button +for yes/no, the timed input in its window. + +**Order.** Brain Dead 13 first (the reference framework, the fullest +game), then mazinga, Fuma, Sonic (the second version), FBC (the third, +with its own edits), Sintel, then hayate_1080. + +**Built (2026-09-16).** The `kimmy` behaviour is in Author.singe after the +qte loop: the KIMMY constants, the masks, the tests (K.checkBasic, +checkCombo, checkHold, checkLet, checkMash and its up/down, left/right, +and button-pair variants, checkMulti, checkAny, checkNo, checkSkip), +K.doMove with the path, yes/no, timed, and skip branches, K.getShortcuts +(the shorthand kinds and the loop, multi, path, and yes/no texts read into +tables), K.setupFramesMoves (game type 5, difficulty 0, MashtoRun, +HoldtoLoop, mirroring, the play frames), K.setupFrames and setupLevel, +K.addPoints (nothing for die-and-retry), K.newScore (deaths against the +difficulty's record), K.setupDeathClip (no life lost in die-and-retry, +the 2P bookkeeping), K.nextLevel (MazeGame keeps the level), K.levelReplay +and onToNextLevel (play style 4 goes to the level select), K.doLevel with +K.failed, afterDeath, sceneDone, enterScene, and resumePlay, and the +screens: K.startGame, doIntro (fifteen-second stills, the alternate +rankings still, START to the new-game menu in free play), doNG and +updateNG over the description's snapshot of the save slots (K.loadSave, +startSave, startMap), doLvlSelect and moveFrameLevel, doClear, doFinish, +doContinue, doGameOver, doHighScore, doDiffSelect, and doExit. What is +not played: the movie player (game type 6), the two-player screens +(START2), the service and save menus, drawing. Q.doChoose is shared +(it takes the loop's own addPoints). The converter finds +FrameworkKimmy (and a Fuma-style Script/globals.singe), marks the +description's behaviour `kimmy`, and snapshots the die-and-retry +records and the six save slots; the drive plans a window's presses by +kind (a tap, a pair, a hold to the window's end, a hold let go for +LETGO, taps every other frame for a mash, the sequence for multi and +loop, a path's first direction, the button for yes/no, the timed +input in its window) and answers the new-game and level-select menus; +`compare.sh` puts a game's Cfg folder back after the runs, since the +originals autosave into it. Brain Dead 13's new-game menu runs under a +level number the framework never defined (`levelNG` is nil in Kimmy's +globals), which the port reproduces in its trace. + +**Native mash and hold (2026-09-16).** The owner's aside: "People love +Mash for some reason. Should be native to moves available in Forge." +So they are: a branch on Forge's own `branches` track may say +`mash = 6` (the move that many times before the window closes) or +`hold = 20` (the move held that many frames of the window), and +`branchOpen` carries `event.mash` and `event.hold`. Scene 98 +(`testScripts/author/mash.forge`) covers both. The Karis and Kimmy +loops keep their own mash arithmetic, since a port must match its +original's; a game written fresh in Forge gets the native kinds. + +**The Hayate 1080 variant (2026-09-16).** Its Structure copy of the engine +(an LED panel, an arcade mode, a menu folder) differs from Brain Dead 13's +in things a player can see, gated in the loop by `q.hayate` (its settings +declare ArcadeMode): START sets the start level and scene to one in +arcade mode; a level's first scene forces rewind mode 0; the "nothing" +move with a second answer scores nothing; the coin prompt sound is not +played from the level (that copy plays a prompt sound from its drawing +instead, under its show-action setting); a level's end always clears the +scene's death count; a continue restarts the stages at scene 0, move 1; +the extra life comes regardless of a second player; credits count to 99. + +**Results (2026-09-16).** Brain Dead 13 plays as its original for the full +sixty thousand frames of the comparison (1,662 trace lines: the new-game +menu, the intro clips, play across the maze's levels in die-and-retry +mode). Mazinga Z and Sonic 1996 agree at six thousand frames; Future +Boy Conan (FBC) and Ninja Hayate 1080 agree through a whole game -- lives +spent, the continue taken, back to the attract loop -- which is the +drive's own end. Fuma Conspiracy's first run sat on its attract loop: it +is not free play, and the drive had only ever pressed START (the Karis +3.32b titles were all free play); the drive now inserts a coin first +when the game wants one. Sintel's framefile is beside its script rather +than at the top of its folder, which the harness now allows; and its +script was the one Kimmy game that never loaded Singe/Framework.singe, so +its original died on a Hypseus call -- it has the two lines the other +games got from an earlier library fix. With those, Fuma Conspiracy and +Sintel agree through a whole game too. Mazinga Z and Sonic 1996 then +agreed through a whole game as well. All seven Kimmy titles play as +their originals do; they moved to singetest/ported/hypseus, and +singePorts carries them: 42 ports in 40 folders now (24 from the +library, 11 Karis 3.32b, 7 Kimmy). The library Karis family and the +3.32b family were regressed over the shared changes (the choose screen +and the drive) and still agree. + +### 14.12 The RDG map-mode lineage (surveyed 2026-09-16) + +**What it is.** RDG's "LUA SINGE 1.0/1.1", the ancestor of the Karis +framework: the same gameflow and lvlState machine (lvlSetup, lvlRunning, +lvlEnd, lvlPlayDeath, lvlPlayRest, branch01 to branch10), the same +screens (intro, continue, game over, high score, service, movie, finish, +save), and a doLevel of some seven hundred lines. A level is a stage of +segments: `createLevelNN` fills `stage[]` and `segment[]` (a segment's +id, done flag, and title), `setupLevelNN(segment)` sets the segment's +frames and its `move[]` table (a move is `{ start, end, kind, death, 0, +0 }`, the kinds the eight directions and buttons, MASH, the four ACT +combos, and SKIP), `SetupFramesLevel` adds the level's disc offset and a +difficulty shift, `NextLevel` walks a fixed order or goes to the map, +and `map.singe` is the level select over a still with a cursor. A game +is its own copy of all of it: the level functions live in main.singe, +not in a game script. The copies drift: freedomfighter is starblazers +with 149 lines changed (a mouse-driven variant); jeeg 1,106; hq-timegal +is another structure again (hq-*.singe); in the library, Dragon Trainer, +Puss in Boots, and Samurai Jack share one copy exactly, Daitarn 3 is 28 +lines off it, Conan 315, the TV Show 881, Super Don Quixote 4,425. +Starblazers adds a mouse-picked level jump (choiceMode). + +**The design.** The same shape as the Karis and Kimmy ports, with one +more thing run in the shim. Where a Karis game's script declares tables +and a setupMoves, a map-mode game's main.singe declares its data as +functions: createLevelNN (the segments), setupLevelNN(segment) (the +moves, some chosen by a random draw), SetupFramesLevel (the level's disc +offset and the difficulty shift), getIntroClip (the intro clip per +level), setupDeathClip (the death number to its clip, two thousand lines +of it in the Italian copies), NextLevel (the fixed order and a game's +own jumps), initStages, and the map's setCursor and moveCursor (the +layout). Those run in the shim as they are, over the loop's state, as +the Karis games' setupMoves and add-ons do -- the loop calls them where +the framework calls them -- and the loop itself (doLevel, the judging, +the screens, the input) is Forge's `rdg` behaviour, a transcription of +the ancestor's, with the copies' differences gated by what the game's +script declares. The converter loads the game's files with the engine +stubbed, records the settings and the dips, and names the script and +its files; the moves are not snapshotted, since a random draw picks +some of them at play time and the game's own functions must make that +draw. The random stream is the game's: the scripts seed it from +os.clock at load, so both drivers pin os.clock to the frame count (the +hosted drive did), and the loop makes the same draws in the same order. +The eleven games go in order of drift: starblazers, freedomfighter (a +light-gun variant with checkZone and getPixelState), jeeg, hq-timegal +(a different structure again, to be read); then the library's Dragon +Trainer, Puss in Boots, and Samurai Jack (one copy), Daitarn 3, Conan, +Dragon's Lair TV Show; Super Don Quixote is a hybrid of this lineage +and Karis 3.x (checkHold, checkLet, doClear, doDiffSelect, getRes) and +comes last, gated or with a loop of its own. + +**Built (2026-09-16).** The `rdg` behaviour is in Author.singe after the +kimmy loop: R.doLevel (the ancestor's fourteen states), R.doIntro, +startGame, doContinue, doGameOver, doFinish, doHighScore, startMovie, +checkMash, checkSkip, scanInput (which lets every input go, as the +ancestor's does), and the pair test for the button-with-direction and +two-direction kinds. The shim gained two switches the behaviour sets: +`loadsFiles` makes its dofile load the game's own files beside the +description (globals, main, map, hscore, service, toolbox), and +`namedSounds` makes its soundLoad answer the file's name, so the game's +`sndright = soundLoad(MYDIR .. "right.wav")` becomes a handle the loop +plays by name. The converter's rdg mode (a globals.singe beside the +script and no framework folder) reads the dips and the board from the +game's game.cfg and snapshots no levels, since the game's own functions +make them at play. Library fixes on the way: the three Hypseus titles +play a prompt sound they never load, which Hypseus let pass and Singe +reports, so their scripts let a nil handle pass; the flat titles reach +their scripts through a symlink the harness now follows. starblazers, +freedomfighter, and jeeg have descriptions; the first comparison is +running. On the way: a map-mode +script sets `MYDIR = "singe/starblazers/"` and joins file names straight +onto it, and the shim had been answering MYDIR with Forge's own directory +without a slash; it keeps the game's value apart now and answers with the +slash the game's ended in. hq-timegal is a variant of its own (a +chooseLevel with tiers, a time-stop, setupLevel by difficulty arrays, no +segments or map), to be read once the three others agree. starblazers' port then played as its original for +six thousand frames on the first run that reached play (after the move +table's absence at attach and the unnumbered finish screen, which the +lineage never defined either, were seen to), and then for the full run. +jeeg followed once the converter read the right file: a title's dips and +board live in the file its service.singe opens (game.cfg, or the game's +own name, jeeg.cfg), which the converter now takes from that line; and a +title without a rewind dip (jeeg has none) rewinds as the ancestor's +zero. (The Italian scripts carry Latin-1 bytes, which ugrep takes for +binary and skips silently: grep them with -a.) freedomfighter is the +light-gun variant: its original drew the crosshair at coordinates the +mouse had never set, which Hypseus let pass and Singe reports, so the +library copy starts them at zero; a down action whose row carries a +zone (columns 7 to 10) is a shot, right when the button is down with the +pointer inside the zone, silent, and the disc skips to the window's end; +the loop hears the pointer as the ancestor's onMouseMoved did (Author +fans "pointer" to the loops that hear everything, and the rdg loop keeps +mouseX and mouseY by the game's ratios). What a title does differently +without declaring it -- freedomfighter's right move sounds sndshot and +its command screen holds fifteen seconds -- the converter names in a +per-title `tweaks` table the description carries and the loop reads; the +harness views hand the drive the zone and the ratios, and it puts the +pointer in the zone (or well outside it for a wrong one) the frame before +the button. All three then agree for six thousand frames and for the full +runs, and moved to ported/hypseus (forgePorts assembles a flat title from +its own folder, less what Hypseus put there). + +**The later copies (2026-09-16).** Dragon Trainer's generation (Puss in +Boots and Samurai Jack share its main.singe exactly, Daitarn 3 is 29 +lines off, Conan 560, the TV Show 1,787) declares a level order +(LvlOrder), and the loop takes that as the mark of the generation +(`q.later`): a game type dip orders the levels in sequence, at random, +or by tiers (the game's own doMixSEQ, doMixRND, doMixTIE, run in the +shim), from the map, or from the dips' level and segment; clip ends are +tested "at or past"; the states after a win, a death, and the finish are +numbered differently (the extended play waits in branch07, an extra +death clip ends in branch08, a "get up" clip in branch09, the finish +counts from branch01); a rewind resumes inline; the intro holds its +command screen twelve seconds and its rankings eight, shows a filler +after them, has no quit combination, and ends its secret in RIGHT; a +CHOOSE move (only the TV Show has any) is a choice among three, R.doChoose; +the offsets are copied under short names every title-clip frame +(saveOffset); a difficulty penalty (iPenal) shifts the windows; and the +finish screen is the game's own 900, which the loop uses when the game +names one. The shim answers MYDIR with the game's own value when the +game took it from the script's path (the files sit in Script/ under the +game's folder), and the converter finds the dips in Cfg/ beside Script/, +through the cfgReadPath its service.singe uses; forgePorts takes such a +game's whole folder, and compare.sh puts its Cfg/ back from there. +Dragon Trainer then agreed for six thousand frames and the full run, Puss +in Boots, Conan, and the TV Show for six thousand; on the way, the port +makes the game's save and config functions do nothing (setupLevel +autosaves, through lfs and io the shim now stubs), and gives the map +screen (the game's own doLevelSelect, which Samurai Jack and Daitarn 3 +start from by their game type) the sprite table its drawing indexes. + +**The drive's coverage (2026-09-16, the owner asked).** The drive had +answered every move right but every fifth, which it answered wrong (a +stray input; a wrong pair; a shot outside the zone), so the wrong-move +death, the life lost, the rewind or the segment restart, the continue, +the game over, and the board were walked, but not a missed window, a +wrong choice, or a broken mash. Now every seventh move (not a fifth) is +left unanswered, a choice or time-stop on a fifth move takes a wrong +option, a mash on a fifth gets a stray direction, and a move met again +after a death is answered right (the later copies rewind to it, and a +retry answered wrong again had been ending games at five deaths). Every +port gets a sweep with the wider drive: testScripts/ports/sweep.sh runs +every pair, two at a time. The first sweep (2026-09-16 evening, six +thousand frames): 51 of 52 pairs agree -- the ActionMax five, the +nineteen KarisFramework descriptions, the six map-mode games, and the +twenty-two Hypseus titles -- and the one that did not (Daitarn 3) +produced no port trace at all: compare.sh now refreshes Forge into its +run directory at every start, and two pairs sharing one directory had +one pull Forge from under the other. sweep.sh gives each pair a +directory of its own; Daitarn 3, rerun, agrees, as does hq-timegal's +full-length run (a game over ends it after 6,336 frames). singePorts is +reassembled with the seven new titles (52 ports), launched, and the +release repacked. + +**hq-timegal (2026-09-16).** The `timegal` behaviour follows the rdg one +in Author.singe: T.doLevel (checkpoints, the tally, the resurrect and +death clips by frame number, as the game hardcodes them), T.doTimeStop +(three options, shuffled on their first drawing as drawOptions does), +T.doIntro (the two intro clips by dip, the filler, the credits clip, the +rankings still, the half-minute "press start" on a coin), T.startGame +(the three level orders through the game's own doMixLevels functions), +T.addPoints (the 1-up quota), the continue, the game over, and the board +with the name unentered; T.setupClip is the game's toolbox one (no skip +when the disc is a frame short of the clip). chooseLevel, setupLevel +(with its mirror draw), fetchIntroVideo, doFillerFrame, and the board +functions are the game's own. The converter finds hq-globals.singe +before the Singe 1 globals.singe the title keeps beside it and reads the +dips and board from hq-timegal.cfg through hq-service.singe; the views +read Time Gal's row (clip start, window, clip end, death clip, kind) and +its options at branch03. Its first comparison is running. It agreed for six thousand frames on +the first run that reached play (levels, deaths, a time-stop, the second +level) and moved to ported/hypseus with the six Italian titles (Samurai +Jack and Daitarn 3 agreed once the map screen had its sprite table); +their launchers went with them. + +**Super Don Quixote, read (2026-09-16).** The one hybrid: map-mode data +(createLevelNN, setupLevelNN, SetupFramesLevel, getIntroClip, +setupDeathClip, NextLevel, doLevelSelect, stage and scene tables, iCurPos +and iSegPointer, a level order by game type) under a Karis 3.31c level +loop (branch11 the right state, branch02 a wrong one with the hints dip's +frame in branch03, HOLD through checkHold with a length by difficulty, +LETGO, the three mashes with unMash and a counter by difficulty, DOUBLE, +the timer-tested pairs, PATH and YESNO with the path table's jumps +(iPath, iPathAend, iPathAjmp) and deaths over 1000, SKIP, a rewind that +skips a LETGO), with its own additions: a difficulty select screen +(doDiffSelect), a clear screen (doClear), trophies (trophy.singe, +doTrophy), a display dip (dip_Res, drawing only), a practice game type +(4: a wrong move costs nothing), a "get ready" clip in branch08 and an +extra death clip in branch07, thirty-one levels, and addons.singe. Its +doLevel is some 680 normalised lines off every Karis copy and the +map-mode ones alike, so it is a loop of its own: the plan is the qte +loop's judging (Q's 3.31c paths) over the rdg loop's data loading and +screens, in a `sdq`-gated section, after the Hypseus goal is closed +(release, docs, memory) -- it is a library title, not a Hypseus one. + +**Super Don Quixote, built (2026-09-16).** Its 311 move rows use only the +five plain kinds, so the exotic judging is transcribed but unreached. +The `sdq` behaviour follows the timegal one in Author.singe: S.doLevel +(the game's states, including branch11 the right move, branch02 the +wrong one, the hints frame, the practice game type, the two "get ready" +clips, the rewind that skips a LETGO, the path jumps), S.doChoose, +S.startGame (with the difficulty select), S.doIntro (eight stops), +S.doDiffSelect and S.moveFrameDiff, S.doFinish then S.doClear every frame +of the level's end as the ancestor runs them over one state, S.doContinue, +and the judging helpers under the game's names; its attach is the rdg +one plus the game's own state, the extra clips by the Extravid dip's +table of eight, hints a boolean, the action and movie dips numbers (the +ancestor compares the movie dip with booleans, so neither start holds). +The converter marks the loop `sdq` when a map-mode globals names the +Karis kinds (HOLDUP), reads the two next-level tables out of doLevel's +text as data (`sdq.beaten`, `sdq.unfinished`), and carries the per-level +percent records after the board (`percents`), reading the config the +service opens through cfgReadPath before any MYDIR fallback; R.doHighScore +ends without the two-second hold for it. The port agrees with the +original on its first comparison (82 trace lines: play, wrong moves, +deaths, rewinds). The last library title; moved to ported/ with its +launcher, singePorts reassembled (53 ports). + +**hq-timegal, read (2026-09-16).** RDG2010's "Time Gal (Singe Edition)", +the HD remake of the Singe 1 Time Gal: its own compact loop, not the +map-mode one. A move is `{ inputStart, inputEnd, a second pair, deathStart, +deathEnd, kind }` with the kinds UP, DOWN, LEFT, RIGHT, ACTION, and +TIMESTOP; a level's moves come from arrayEasy, arrayNormal, or arrayHard +by difficulty, mirrored by a random draw (an offset added to every frame, +LEFT and RIGHT swapped); play has checkpoints (sections reached, resumed +from after a death), a time-stop choice of three (opt[] with a right one +and a death clip per wrong one, shuffled), a tally with a bonus by deaths, +a 1-up quota, and a level order by tiers (doMixLevels), at random, or +sequential, with chooseLevel picking the next unbeaten; the intro +alternates two clips and a still per filler. About seventeen hundred +lines with the drawing. A fourth behaviour, `timegal`, after the three +map-mode titles agree; the Singe 1 `timegal` monolith in the library is +its ancestor and may share it. + +### 14.13 The American Laser Games family (surveyed 2026-09-16) + +**What it is.** Eleven Hypseus titles (Mad Dog McCree, Mad Dog II, Crime +Patrol, Drug Wars, Last Bounty Hunter, Space Pirates, Who Shot Johnny Rock +twice, Johnny Rock Noir twice, Mad Dog II Typing) and twelve of the +library's (six originals, six HD), each a hand-written program: 15 to 27 +`doLevelXxx` functions of a few hundred lines each, 36,000 to 82,000 +lines a title with the hitbox files. Nothing is shared but the shape: +the "skeleton" (doIntro, doContinue, doGameOver, the input handlers) +differs completely between Mad Dog and Crime Patrol, and the library's +HD Mad Dog is 5,000 normalised lines off the Hypseus one. A level's data +is mechanical: `move` rows `{ shootStart, shootEnd, clipStart, clipEnd, +hitmapStart, endFrmRandom }`, a `hitmap` per frame `{ frame, hitboxIndex, +count, bonusIndex, civilianIndex, civilianCount }` over `hitbox`, +`civilian`, and `powerup` rectangle arrays (`GetBankArray` and its kin, +12,000-line files), scaled by `ResHMx`/`ResHMy` to the overlay. A +level's flow is code: states (lvlSetup, lvlRunning, lvlPlayRest, +lvlPauseAction, lvl2ndChance, lvlPlayDeath, lvlUndertaker, lvlPlayAdvice, +...) with hardcoded frame triggers (`currentFrame == offsetlvlBank + +530`), random approaches, "first time" resumes, bullseye windows, a +second-chance delay, and the undertaker's death clips by gender and +lives. Two players share the loop: `p1BUTTON3`/`p2BUTTON3` the +triggers, two pointers, two ammo counts (six, a reload by pointing +offscreen or at the borders by dip, or immediate), two scores, one lives +count; a town select by pointer quadrant; continues; a name entry. + +**The design.** The owner's choice (14.10) is a rewrite per game in +Forge's terms, and Forge's rail-shooter model (section 2, "the keyframed +hitbox") was drawn for exactly this: a `hitboxes` track keyed by disc +frame is the hitmap and its rectangles; a `gun` per player with ammo and +a reload gesture; `hit` events; a branching track of windows. So: +1. A converter, `util/forgePortAlg.py`, lifts what is mechanical: the + move rows and the hitbox files into `hitboxes` tracks (bad guys, + civilians, bonuses, each a rectangle keyed by the frame it belongs + to), per level, with the level's disc offset applied. +2. An `alg` behaviour for what every title shares in kind if not in + code: two guns, ammo and reload by dip, scores, lives, credits, + continues, the death clip chosen by what was shot and how many lives + remain, the level select, the intro, the name entry -- written once, + with the per-title numbers (frames, sounds, clip lists) in the + description. +3. Each level's flow rewritten as data: a `windows` list per level + (`shoot` windows naming their hitmap range and what a hit skips to; + `bonus` windows; `resume` points for a second visit; `trigger` + frames that jump the move; `random` approaches; an `advice` clip), + read by hand from its doLevel and written into the description. + This is the per-game work, a level at a time, 15 to 27 per title. +4. A drive that shoots: the harness views hand the drive the current + window's first hitbox (the pointer to its centre, the trigger on the + window's third frame), a miss well outside it on every fifth, a + civilian on every seventh where the frame has one, no shot on some, + and the reload gesture when ammo is out; compare.sh gets an `alg` + family for the Hypseus layout and the library one. +The level vocabulary, read from Mad Dog's nineteen: a level is a run of +`move` rows played in order; each row a `shoot` window (frameShootStart +to frameShootEnd) over a hitmap range, a hit scoring SCORE_BADGUY and +skipping to the window's end, the window's end going to a second chance +(a pause of iDelay seconds still taking a shot) and then the row's death +clip (frameDeathStart to frameDeathEnd), a life gone and the undertaker's +clip by what happened (NORMAL, SHOTGUY, SHOTWOMAN, NOGENDER, GOODGUY, or +by lives left), or to a rest (played to endFrmRandom, then a three-second +pause before the next row's window); a civilian hit anywhere is a life +and the undertaker; bullseye windows by frame with a bonus rectangle +(ammo to twelve); play-clip states with hardcoded frames and skips; a +random draw for an approach or a mirror; first-visit resumes; advice +and hint stills; and the level's end deciding the map, the lives-left +still, the maze, or the showdown. Mad Dog II and the others add their +own (the typing edition types). One thing the survey settled against +the converter lifting the data: a level's rows are shuffled at setup by +a random draw (setupLevelBank's fRndPopOrder), so the port must draw at +play as the map-mode ports do -- the game's own setupLevel and hitbox +files run through the shim, from a vocabulary behaviour beside the game +(as the ActionMax light sensor is), which judges shots against the live +hitmap and answers the description's rules with conditions and actions +of its own (shotHit, shotCivilian, bonusHit, undertaker); the level +flows stay rules in the description, editable, over Forge's frameReached, +timer, discTo, and the rest. Author gained what a two-gun game needs: +a press carries its device, so the pressed event names the player and +only that player's gun fires, and a hitbox track with step = true keeps +a box only on the frames it keys. The pilot is Mad Dog McCree (Hypseus, +19 levels, the smallest of the HD set): the converter and the behaviour built against it, its levels +rewritten one by one against the comparison, then the other ten and the +library's twelve, each its own descriptions over the shared behaviour. + +**The pilot, begun (2026-09-16, evening).** The converter's `alg` mode +runs the title's script and its files under the stubs (its hitbox tables, +its board, its service) and reads the dips from the file its service opens +through pcall; the description names `Vocabulary.singe` beside it. The +vocabulary (`~/claude/singetest/hypseus/maddog-hd/singe/maddog-hd/`) is +Mad Dog's loop over the game's own files loaded through the shim +(authorShim and authorRunScript are the public doors): the start, the +player select, the attract loop, the town select, the tutorial and the +first level, the continue, the lives-left still, the game over, and the +board so far, with the two players' twin state reached by number so each +piece is written once. The harness: algDrive.singe (one gun joins by +START, a coin and START begin the game, shots at the first hitbox of the +window's frame three frames in, a miss on every fifth move, none on every +seventh, a reload by pointing offscreen, the first unbeaten quadrant at +the town select), algOriginal.singe and algPort.singe (the pointer put +where the game's onMouseMoved would compute it, presses with their device, +both drivers pinning os.time for the game's seed), and compare.sh's `alg` +family. Two engine changes on the way: a mouse or gun switch now reaches +onInputPressed with its device index, as Hypseus passes it (the original +needs it to tell its players apart), and a sprite scaled to nothing draws +as a pixel instead of ending the engine (the original's bullet shrinks to +zero after a hit, which Hypseus let pass). The port agreed with the +original through the player select, the attract loop, the tutorial, and +the first level, and then level by level as each was transcribed: the +Corral, the showdown, the Saloon, the Sheriff, the Bank (271 trace lines +over twenty thousand frames agree), then the signpost, the fuse, the +mine, the bottles, the maze and its signs, the pond, the plateau, the +cliff, the canyon, the hideout, the house, and Mad Dog himself, with the +tie's countdown; the vocabulary is some three thousand lines against +the original's forty-four thousand, the two players' twin state reached +by number and the shared shapes (a shot judged against the hitmap, the +bonus, and the civilians; the death, undertaker, and end states; the +wilderness levels' one shape) written once. The drive shoots the +signpost's sign, the mine's lantern and item, and the maze's signs from +targets the views hand it. What is not yet transcribed: the two-player +tie shootout and the second player's board (a one-gun run never reaches +them), the service menu, and the drawing (the port draws nothing yet). +A forty-thousand-frame comparison with the first eleven levels in +agreed (325 trace lines), and one with every level in agreed the same +(the drive's deliberate misses keep a run in the four town levels, the +showdown, the continue, the board, and the game over); the drive gained +PORT_PERFECT=1, no wrong or missed shot, and a sixty-thousand-frame +perfect run agreed too (567 trace lines): the town levels, the +signpost, the fuse, the mine, the bottles, the maze and its signs, a +showdown, the continue, the board, and the game over; the hideout, the +house, and Mad Dog himself lie past where the drive's lives ran out. +One trap on the way, worth knowing for the whole family: the original +writes its board into the .cfg beside its script at every board entry, +so harness runs had changed the shipped file and a reconversion had +carried a run's scores into the description; the file is restored, +compare.sh's alg family keeps and puts back the .cfg files beside the +script, and compare.sh's per-side timeout is an hour. Mad Dog moved to +ported/hypseus, singePorts reassembled (54 ports), the release repacked. The +tie shootout and the second player's board are transcribed too, untried +by a one-gun run. The library's single-player Mad Dog (8,600 lines) is +the same disc with the same offsets but an older, simpler flow per level +-- half to all of each level's lines differ even with the second +player's stripped -- so it is a transcription of its own over the same +shapes, as each of the other twenty-one titles will be. + +**Mad Dog II: The Lost Gold (2026-09-17, small hours).** The second +title, the same way: the converter's `alg` mode read its script (9,200 +lines and its globals, hitbox, and setup files) into a description with +its dips and board, and a vocabulary beside it +(`~/claude/singetest/hypseus/maddog2-hd/singe/maddog2-hd/Vocabulary.singe`, +2,700 lines) transcribes its loop over the game's own files: the start, +the player select, the attract loop, the guide menu (three panels, the +first unfinished guide when the twenty seconds run out), the first level +with its bonus bottles and the monk's turn, the three guides' levels and +finales (Beaver's four sections and random item, Bonnie's woman who must +not be shot and her gun emptied at the second section, the Professor's +two levels with his own second hit test), the two item levels with the +guide's clip, the prospector's two (his skull from four at random), the +two town levels, Mad Dog himself with the ending earned by game type and +continues, the showdown, the continue, the lives-left still, the game +over, both boards, and the tie-break. Its shapes differ from Mad Dog's +enough to be its own transcription (a `finishLevel` end that goes back +to the level, or the menu in game type 1; a draw lost that still runs +the hit test for the record; three kinds of undertaker clip by whom the +civilian was) but the vocabulary's helpers are the same in kind, and +three of the game's habits are kept as they are because the trace must +agree: it indexes its move rows by `hitmapStart`, a name it never +defines (its hit tests take the range from the row themselves), it +spares the attract loop's ammo by comparing with `lvlIntro`, also never +defined, and its second item test names `thisItem`, likewise, so any of +the three items ends the level. The harness views learned its tables +(the hitmap range is `hitboxStart`, the move row has no shoot window so +the move's own frames stand in, its levels are 1 to 15 and 104) and the +drive shoots its guide panels. The first comparison (the drive's +misses, fifteen thousand frames) agreed to the line: the player select, +the attract loop, the first level, the menu, Beaver's level, the +continue, the board, and the game over. With every level in, the views +handing the drive the item and the skull to shoot and the drive putting +a coin in before it takes the continue, a sixty-thousand-frame perfect +run agreed (541 trace lines: the first level, the menu, Beaver's level +and finale, a showdown, both item levels, the prospector's two, both +town levels -- where the drive, shooting each move's first box, shoots +the women and runs out of lives -- the continue, the board, and the game +over) and a forty-thousand-frame run with the drive's misses agreed +too (475 lines). Mad Dog himself lies past where the drive's lives run +out; the tie-break and the second player's board are transcribed but +untried by a one-gun run. + +**Crime Patrol and Drug Wars (2026-09-17, small hours).** The third and +fourth titles are a template of their own (the same authors' port, the +one program twice with different frames): a start and player select +like Mad Dog II's, an attract loop, four mission menus of three or four +panels each (shot by the game's own `panelHit` over line arrays, the +first open panel when the clip runs out, a closing clip when a mission +is done), twenty levels (Crime Patrol) or seventeen and a practice range +(Drug Wars), a lives-left still, a continue, both boards, and a +tie-break. A level here holds the disc at a move's last frame for the +difficulty's delay instead of pausing at it, a death is a mission's +"nag" clip chosen by the game's own function (which takes the life), a +good guy passes by himself and shooting him is the friendly-killed nag +or, at a few moves, a silent loss of the life, and most levels are the +one shape with a table of what differs (an opening the trigger skips, a +stretch it skips to a frame, moves whose ends play out as clips, the +civilian rule, the bystander rule, a section to resume at, a closing +clip); the ones that are not (Crime Patrol's practice range with its +five-target still, its strip club, its hangar and nuclear plant runs; +Drug Wars's boat, night vision, town, and final level with its last +stand) are written out. Crime Patrol's vocabulary is 2,600 lines +against the original's 8,700 and its hitbox files, Drug Wars's 1,900 +against 8,700. Kept as the games have them: `lvlIntro` and, in Drug +Wars's South America menu, `MENU_DELTA` and the hangar levels, names +from Crime Patrol it never defines; the lives-left still's four +resumable levels that neither game has. The shim gained stubs for +sprite frame loads and colour keys (both games reset them at load). +The harness views learned the menus' panels, Crime Patrol's practice +still (in the game's own pointer coordinates) and cutouts, and Drug +Wars's last stand. Crime Patrol agreed for fifteen thousand frames with +the drive's misses (365 trace lines), sixty thousand of perfect play +(545), and forty thousand with misses (499): the practice range, the +store, the gang, and the warehouse, the continue, the board, and the +game over. Drug Wars agreed for fifteen thousand (333), sixty thousand +perfect (458), and forty thousand with misses (380): the Sierra intro +and menu, the practice range, the bar, the continue, the board, and the +game over. Neither run got past its first mission: the drive shot each +move's first box, and in these games a move can be a good guy's, whose +shooting is a death, so the views now hand the drive no window on a +good guy's move. Two of the game's habits found on the way: Drug Wars +names no `levelIntro` at all (its attract loop runs with the level nil, +and the port dispatches on that), and its practice range counts as done +however it ended. +Both originals then moved into `ported/hypseus/`, `forgePorts.py` built +fifty-seven ports, and the launch check ran them all headless (one run, +38AmbushAlley's, lost its X server mid-way and was rerun clean). + +**The Last Bounty Hunter (2026-09-17).** The fifth title is a program +of its own again: the army base first, then a signpost still (the +practice range, the wanted-poster menu, or a bonus to shoot), a menu of +four outlaws whose panels read at large, captured, or dead, twenty-three +outlaw levels in four chains (Cactus Kid's six, Dan's three, Harry's +five, Loco's four with a coin-toss alternate), each outlaw's own move +where a hit kills him and a civilian hit captures him alive, an advice +clip once a game after a kill, a bonus level once a game by the game's +own clock, a quick-draw showdown drawn at random after a menu choice +(the first always), two final levels where the outlaws taken alive are +met again (Harry's bribe, the Cactus Kid's showdown with the gun +holstered), a shotgun of eighteen shells from bonus hits, a kill streak +with its own bonus, and both boards with a girl's clip when every outlaw +was taken alive. The vocabulary +(`~/claude/singetest/hypseus/lbh-hd/singe/lbh-hd/Vocabulary.singe`, +2,700 lines against the original's 9,700) is the common shape once more +with the hooks the levels need (the civilian rule while the disc is +held, what a shot between the moves does, the outlaw's move, the level's +own draw, the clip after a clip, the closing clip, the next level or a +replay), and the practice range, the signs, the menu, the bonus level, +the showdown, and the second final level written out; the game's own +reload, scoring, streak, and bonus functions are reached by player. +Its first comparison found the original itself dying at the army base's +board: the game selects a font it never loaded (`fontscore1` for +`fontScore1`), which Hypseus lets pass and Singe ended the script on; +fontSelect now keeps the current font and warns. The rerun then showed +the port a thousand points short after a miss: the game pays its kill +streak from the pointer's splat animation in the frame loop (a hit +starts the animation, and each of its frames that is not a hit's calls +the miss handler), which the port had left with the drawing; the +vocabulary now carries that bookkeeping (`blink` on a shot, `splat` for +each gun after the level). One more: the Cactus Kid's first level +names its intro clip's state differently from Harry's and Loco's +(`lvlPlayClip1`, not `lvlPlayClip3`), which the traces compare, so the +level table now names the state. +The Last Bounty Hunter then agreed for fifteen thousand frames with the +drive's misses (407 trace lines: the army base, the signs, the menu, +Cactus Kid's and Loco's levels, Harry's first, the continue). + +**Space Pirates (2026-09-17).** The sixth title: an asteroid range, +three doors (the second with a band of colour to shoot and the moves +swapped when the other band is shot first, the third with the commander +saved by a shot at Ursula's colour, then the crystal order and the +cannon's whereabouts), a planet menu of four quadrants, four planets in +eleven levels (Fellina's skull and arrow puzzles, the Mountain, the +Reaper's counted laughs, the Scrapyard's move not to shoot first) with a +crystal shown after each planet's first win and the cannon practice on +whichever planet holds it, a crystal build against a ten-second clock, +a ship battle of cannon shots judged on the next frame, Tallon, and the +continue, the lives-left still, both boards, and the tie-break. The +vocabulary +(`~/claude/singetest/hypseus/spacepirates-hd/singe/spacepirates-hd/Vocabulary.singe`, +2,700 lines against the original's 11,500 and its hitbox files) is the common shape with the +hooks these levels need (an intro clip or a frame to wait for, the +level's own clip advance, a hit's own skip, the civilian rule, the +closing, the level's own death clip, states of its own), a second shape +for the object ranges (Fellina's, the Mountain's, and the Reaper's +second halves), and the doors, Fellina's puzzles, the Reaper's laughs, +the Scrapyard, the cannon practice, the ship battle, Tallon, and the +crystal build written out; the crystal after a win is a state of the +level (the game's `doShowCrystal`, which also runs the cannon practice +from inside it). The game's own setups, hit tests (`shooterHit`, +`civilianHit`, `bandHit`, `ursulaHit`, `fellinaHit`, `socketHit`), death +clips by kind, laugh counts, crystal order, and menu clips run through +the shim. Two habits kept: a press on an empty gun is left standing on +the asteroid and object ranges (the flag clears only with ammo), and the +Reaper's first level does nothing at all when its second section is +reached (the original stays in its setup). The harness views learned +the planet menu's quadrants (pointer coordinates scaled by the game's +`UnX`/`UnY`), the band within its frames, Ursula within her colour's +frames, the skull and the arrow, and the sockets in the order shown. +Space Pirates agreed for fifteen thousand frames with the drive's misses +(397 trace lines: the range, the first two doors with deaths, the +continue, the board, the game over). +Its perfect run then died at the second door's fourth move every time: +the drive aims at the first hitbox of the frame it sees, but the press +it makes is judged on the next frame, and this target moves enough that +the middle of one frame's box lies outside every box of the next. The +views now check that, and aim at the next frame's first box when the +current one's middle would miss it (the slow targets of the earlier +titles are aimed at as before). The same run found a hole in The Last +Bounty Hunter's port (the advice clip's end hook called without the +game's state, an error the misses run never reached) and a port view +that indexed the game's band and line tables by unqualified names, so +the port never shot the band the original hit. +The rerun then showed the original itself stuck at the second door's +colour hint: the game changes state on the clip's last frame and +expects to read that same frame once more in the new state, which a +game paced for an overlay faster than its disc can, and which the +harness's deterministic clock never allowed, since `--deterministic` +stepped the disc one video frame per engine frame whatever its step. +The engine's deterministic mode now steps the disc by the virtual clock +like everything else, and a second fault came out with it: the frame +loop's overlay throttle compared the clock strictly, and on a virtual +clock that moves exactly one step an iteration that ran the script +every other iteration, a disc frame apart. With both put right each +disc frame stays on for the two or three engine frames its time is +worth at the default step, which is what real play does (a probe of the +disc's frame against the clock's ticks confirmed it); the manual and +the changelog say so, and every comparison below this point ran on that +timing. The same perfect run reached Harry's +last level in The Last Bounty Hunter and found its finished step missing +from the port's table (the original goes back to the menu, the port +replayed the level). +On the corrected timing every port was run again with the drive's +misses for fifteen thousand engine frames, and all agreed: Mad Dog +McCree (174 trace lines), Mad Dog II (114), Crime Patrol (268), Drug +Wars (198), The Last Bounty Hunter (183), Space Pirates (241), and both +Johnny Rocks (106 and 111); The Last Bounty Hunter's perfect run agreed +for a hundred and thirty thousand (483). +Space Pirates's perfect run then walked the whole game and agreed for a +hundred and thirty thousand (736 trace lines: the range, the three +doors, Fellina's two levels with the puzzles, the Reaper's three with +the laughs, the Scrapyard's first, the cannon practice, the Mountain's +two, the crystal build, the ship battle, Tallon, the board, the game +over). + +**Who Shot Johnny Rock? (2026-09-17).** The seventh title, the noir +multiplayer edition, is the detective game: money in place of lives +(the doctor's bill at every death, the undertaker when it runs out, and +more bought with a credit or at the store), Johnny's office first, then +a city map with a hint before it (the game's own rules for which), +four places to clear for their clues (the casino, the garage, the pool +hall, the warehouse's three parts), a store, random encounters on the +way to a place (one of fourteen scenes), bonus puzzles opened by hints +and expiring after a visit (roulette, cards, the pool balls still and +rolling, the license plates, the elevator panel, the cans, each paying +on the lucky number's target), the mansion in four parts (the moves, +the four clue items in the order found, the bomb, the safe's dial by +the combination), the killer's reveal, and a finale of four random +scenes and the killer's own, then the boards and the tie-break. The +guns fire from the frame loop against a real-time gun delay (`os.clock` +and ninety milliseconds), the trigger flag standing until the release. +The vocabulary +(`~/claude/singetest/hypseus/wsjr-hd/singe/wsjr-hd/Vocabulary.singe`, +2,500 lines against the original's 13,300 and its hitbox files) is one +common shape with two variants (a hit moves on at once, or its clip +plays out with the civilian test live under it) and hooks for the +intro, the ending clip's skip, the clue, the closing, and the level's +own hit and running rules; the office, the map with its hint, clue, +and kill-clip sub-machines, the store, the mansion's items and safe, +the random scenes (shared by the encounters and the finale's four), the +killer's scene, the seven puzzles (two shapes: moves, and panels on a +still), the continue, the boards, and the tie-break are written out. +The game's own setups (fourteen random scenes included), hit tests, +nags, undertakers, hints' clips, safe digits, and boards run through the +shim. Its first comparison diverged at the casino's move order: every +level's setup spins the shot mark's sprite by a random angle, a draw +the picture made that the generator counts, and the port now draws it +too. The harness views learned the city map (the killer's place once +revealed, the mansion once the clues are had, else the first place +open), the clue items in the game's second scale, and the safe's dial +when it shows the next digit; money stands in for lives in the trace. +The second run agreed apart from the hints' timing, and that was the +description's doing: the game's config reader keeps the showdown dip a +number, and the game tests it against `false`, which a 0 never is, so +its hints play with the dip off; the port had made it a boolean. Who +Shot Johnny Rock then agreed for fifteen thousand frames with the +drive's misses (203 trace lines: the office, the map with its hints, +the casino three times over, the pool hall, the warehouse). + +**Who Shot Johnny Rock? (Singe edition, 2026-09-17).** The eighth +title is the older one-player cut of the seventh, and its vocabulary +(`~/claude/singetest/hypseus/johnnyrocknoir/singe/johnnyrocknoir/Vocabulary.singe`, +2,100 lines against the original's 9,900) is the multiplayer one with +the twin state gone and every difference the diff of the two scripts +showed: no player select (the disc's wake goes straight to the attract +loop, which the drive now takes as joined), a move's end passed rather +than reached (the disc put back on its last frame for the draw), the +casino's, the mansion's, and the encounters' draws without a civilian +test, the office's civilian at the draw passing the move by, the move +orders drawn from fixed tables rather than shuffled, the one encounter +with two bad guys at once (the second reached through the civilian +test, a moment's pause for both, a death by whoever is left), other +clip frames throughout, a single board that waits in a state the game +names but never defines (and matches on all the same), and a gun of its +own: a held trigger rolls for a jam once a frame (one chance in three +hundred and ninety-one, a draw the generator counts) and otherwise arms +a delay of its own clock, the puzzles' shots counting only between +shots. The dips are typed as its config reader types them (the +undertaker and the showdown booleans here). +It agreed at its first comparison, fifteen thousand frames with the +drive's misses (244 trace lines: the office, the map, the casino, two +random encounters, the pool hall). + +**Mad Dog II, the typing edition (2026-09-17).** The ninth title is +Mad Dog II played on a keyboard: every move a word (a letter on the +train, a sum in the second item level, a phrase in town, six or +eighteen letters sprinkled over the picture at the showdown and at Mad +Dog) typed as it runs, the disc held at the move's end for the +difficulty's seconds, the word untyped there a death, the trails ending +in a thirty-second rush at three times the disc's speed with a tally +after, and a title screen, a guide menu, and a board driven by the +return key. The vocabulary +(`~/claude/singetest/hypseus/typing-md2/singe/typing-md2/Vocabulary.singe`, +2,200 lines against the original's 19,000, most of them its word lists) +is one typed shape with hooks (the word's field, a phrase or a moving +letter, good guys whose words cost or kill, the clip's end, the draw +missed, the level's own clips), a second for the three rushes, a third +for the two sprinkled levels, and the office (its own three clips and +sections) written out; the game's own setups with their word draws, +its word and phrase lists, the train's hit tables, the undertaker's +clips by the lives left, and the board run through the shim. The keys +reach the port as the runtime hands them over, the keysym as a switch +(the return and backspace keys, which the runtime keeps apart, named +back from their scancodes), and the game reads one at a time from +`iKey` as its own script does. The Johnny Rocks' perfect runs (the +casino, the pool hall, the warehouse, the garage, the mansion's four +parts, the finale) turned up two more faults: a level's unfinished +hook called without the game's state in four of the vocabularies (an +error the misses runs never reached), and a drift in the originals' +own config files, which the games write at every board and which a +comparison killed part-way never put back, so the multiplayer edition's +safe combination came from a later entry of the pool than the +description's; the files were restored from the descriptions, and +compare.sh now puts them back however it ends. With both mended the +two Johnny Rocks agreed for a hundred and thirty thousand frames of +perfect play (848 and 715 trace lines: the office, the map, the +casino, the pool hall, the warehouse's three parts, random encounters, +the garage, the mansion's four parts, and the finale's random scenes, +where the drive's luck runs out). The harness learned to +type: the views name the key the game waits for (the next letter of the word, a +sprinkled letter not yet typed, the magic word's, the return key at the +title, the menu, and the board), the drive releases it (a wrong letter +first on every fifth move and nothing at all on every seventh with +misses on), and the converter recognises the edition by its globals +file and reads its dips from the config its service file names. +Its first comparisons found the views calling Mad Dog II's hitbox +scaler at the guide menu (a game with no hitboxes has none), the +converter keeping a frame rate the game never names (a stub function, +written into the description), the drive waiting for a key slot the +game starts at zero rather than at none, and, once the office was +typed, the port's first word running longer than the original's: the +picture flickers each letter already typed in a colour drawn three +times a frame, draws the generator counts and the words are drawn +from the same generator, so the port draws them too. +With those mended the typing edition agreed for fifteen thousand +frames with the drive's misses (263 trace lines: the title, the +office, the continue), and its perfect run typed its way through the +office, the guide menu, Beaver's trail, and the rush before the views' +item branch (Mad Dog II's, another scaler call) ended both runs alike. +With that branch kept to the games that scale, the perfect run agreed +for a hundred and thirty thousand frames (the office, the menu, +Beaver's trail and rush, both item levels, the prospector's two, the +town, the continue, and the board, where the drive's return key had +seeked the name's clip back every frame until the run ended -- pressed +once now). +All five then closed together: their originals into `ported/hypseus/`, +`forgePorts.py` built sixty-two ports, and the launch check ran them +all headless. The typing edition alone failed it: its script names +its files by their place in the Hypseus tree (`singe/typing-md2/...`), +which the shim's file resolution only stripped when the name began +with the port's own directory; it now strips the tree's prefix too, +and the ports were rebuilt and launched again. + +**The two Johnny Rock copies left (2026-09-17).** The library's +`johnnyrocknoir__hypseus_singe_johnnyrocknoir_RGB` is the one-player +edition's script to the line (only the launcher's header differs), a +video-only alternate that stays in `hypseus/` unported. `WSJR_HD` is +the same one-player edition in the HD layout (its files under Script/, +its config under Cfg/, its hitbox scale 360 wide rather than 320), and +every difference from `johnnyrocknoir` is drawing or paths, so it is +ported with the one-player vocabulary copied beside it unchanged; the +converter learned to load a title's files from a Script/ folder and to +find its service file there. It agreed at once, both with the drive's +misses for fifteen thousand frames (111 trace lines) and for a hundred +and thirty thousand of perfect play (758: to the finale, as its +twin), and closed with the others -- once `forgePorts.py` learned to +keep a hand-written title's Fonts folder, which it leaves out of every +framework title's port (those draw their own text) and which this one +loads its board's face from. With that, sixty-three ports are built and +the launch check runs every one of them clean. That is every hand-written +American Laser Games title of the Hypseus library ported; the +library's own twelve remain. + +**The library's own editions (2026-09-21).** The fourteen left are the +older one-player Singe editions the library keeps at its root (six +titles and the HD copy of each: Who Shot Johnny Rock?, Space Pirates, +Mad Dog McCree, Mad Dog II, The Last Bounty Hunter, Crime Patrol, Drug +Wars), laid out like the map-mode copies (the script and its files in +`Script/`, the config in `Cfg/`, the video and its framefile in +`Video/`), and each is a transcription of its own again: the HD Mad +Dog is not the Hypseus one, and the one-player cut of every game is an +older, plainer loop than its two-player edition (no player select, the +pointer raw, a single trigger flag the levels spend, `iBullets > 0` +tests, a level's rest and pause states with their own frame rules). +The two Johnny Rocks went first, with the vocabulary the Hypseus +one-player edition already had, and agreed at once (110 trace lines +with the drive's misses, 757 of perfect play). The other ten each got +a `Script/Vocabulary.singe` written from the game's own loop (the HD +copy the same file, since its differences are scale and paths: Space +Pirates's common level and object range shapes with a `clipDone` step +and the game's own `doShowCrystal`, its START mirroring the seed the +game draws from the clock; Mad Dog's wilderness shape with the +per-level reset on a hit, its showdown by flag, its mute delay, and a +board that waits in the game's own state; Mad Dog II's finale shape +for Beaver and Bonnie, the menu that leaves the trigger standing, the +board that waits in a state the game names as a frame number, and +game type 2 keeping the panels; The Last Bounty Hunter's `bonusTry` +over boxes and ranges and a level table of some twenty hooks, its +showdown by flag; Crime Patrol's practice range of six cutouts, its +menus, and the common shape with the run-up the trigger skips; Drug +Wars's the same over its own levels, the final level without the last +stand the two-player edition added). Crime Patrol's and Drug Wars's +HD copies are not the same program as their originals, so their +vocabularies tell the editions apart by the resolution the HD globals +take from the disc: the HD copies take the bullet on the press (a +free one first where a level grants it) and reload by the second +button or, on the trigger's release, by the dip with the borders +measured from the resolution, start at the part their dip names, run +a different attract loop (frames of their own on timers), floor the +score at the board, and skip to a move's clip only when the disc is +not there already; the library editions take the bullet on the +release, reload by the second button alone, and Crime Patrol's +alternates two cuts of its story by a draw at start. What the family +needed on the way: the engine hooks a bare `os.time()` under +`--deterministic` (Space Pirates seeds its generator from it when a +game starts, so the two sides drew different sequences); the shim's +`io` reads a real file (Mad Dog and Mad Dog II read their config at +load, and a stub that answered nothing ended the load at `setShootDelay`); +the converter stubs `require("lfs")`, looks for a config under `Cfg/` +beside `Script/`, and sets `MYDIR` to the title's folder; the +assembler writes a `games.dat` entry from `Video/*.mp4` when no +launcher names a title (the plain Space Pirates has none); the harness +views scale the hitmap's coordinates only where the game does (the +one-player editions aim in the hitmap's own), aim at Ursula from the +game's own numbers before its hit test builds her table, and report +two things the drive needed: the press of these editions takes the +bullet before the level judges the shot, so the last one is never +judged and the views name it a `spare` the drive keeps back (a rule +the one-player Crime Patrol and Drug Wars turn off, since theirs judge +first and take the bullet on the release), and `reloadSwitch` names +what reloads (the second button for those two). Two faults of the +ports' own: The Last Bounty Hunter's shared clip state ran before a +level's own states, and Dan's third level and the first final keep a +clip state past the last move (the level's own states now run first); +and the scaling helper's first draft called itself where the game +scales, which spun both HD Crime Patrols for an hour before the hook +the view now offers (`PORT_HOOK=1` names the line every ten million +instructions) found it. One false alarm: Mad Dog's first perfect run +diverged at the Corral on an engine built before the clock hook, and +agreed to the line once rebuilt. Results, each with the drive's +misses for fifteen thousand frames and perfect play for a hundred and +thirty thousand: Space Pirates 235 and 763 (the whole game: the +range, the doors, every planet, the crystal build, the ship battle, +Tallon, the board -- once the views aimed at Ursula, whose table the +one-player edition builds only inside its hit test; the first run had +ended at the third door for want of that shot); Mad Dog McCree 162 and the whole game (the +town levels, the signpost, the fuse, the mine, the bottles, the maze, +the hideout, the house, Mad Dog himself, and the board); Mad Dog II +122 and 498 (every guide, both item levels, the prospector, the town, +Mad Dog, the board); The Last Bounty Hunter 198 and 734 (all four +outlaws' chains, both finals, the board); Crime Patrol 302 and 1,208 +(all nineteen levels to the board); Drug Wars 288 and 1,080 (every +mission to the base, then night vision, where the drive's lives run +out); and the two HD copies that are programs of their own, Crime +Patrol HD 249 and 1,031 (the whole game) and Drug Wars HD 254 and 929 +(the same path as its original). The other HD copies, with the same +vocabularies as their originals, agreed the same way: Space Pirates HD +235 and 763, Mad Dog HD 162 and the whole game, Mad Dog II HD 123 and +498, The Last Bounty Hunter HD 189 with the drive's misses (its scale +changes which shots land, so the count differs from its original's) and +753 of perfect play (every chain, both finals, the board). +All twelve then closed: their folders and launchers into `ported/`, +`forgePorts.py` built seventy-seven ports (a title with no launcher of +its own, the plain Space Pirates, gets a `games.dat` entry from its +video), the launch check ran every one clean one at a time (the owner +asked for a single engine from here on), and the release was repacked. +With that every American Laser Games title in the library is ported. + +### 14.14 The one-offs (2026-09-21) + +Four bespoke scripts remain in the library's root after the families: +Adventures in Videoland: Rollercoaster, Hologram Time Traveler, LINEA +(the MazescaterFramework's one game), and Platoon (an American Laser +Games shape by the same hand as the Singe 2 editions), each ported by +hand, one at a time; BatMaker writes launchers and is not a game (14.2), +and the menu is the engine's own. + +**Rollercoaster (2026-09-21).** David Lubar's 1982 text adventure over +the laserdisc, in Scott Duensing's Singe port of Scott Lawrence's web +one: eighteen rooms with exits, a name and description, and a Pioneer +command each (a frame to park on as a still shipped as a picture, or a +clip to play to its end), twelve objects and three pieces of furniture +by location, a two-word parser of some thirty rules tried in order +(several may apply to one line, and `GO` and `READ TICKET` re-enter it), +five rooms with a scene of their own (the restaurant's meal, the +dancer's tent and the bear, the top of the coaster, the guard and the +uniform, the dark path), a prize to name, a question of where to put a +thing, a bomb on a hundred and fifty turns, a play-again, and the win. +The port keeps the split the plan drew for the family: the converter's +`videoland` mode (told by the script's own state names) runs the script +under the stubs and writes the rooms, objects, furniture, directions, +and the turn count into the description as data, and `Vocabulary.singe` +beside it carries what the description cannot say -- a `console` look +(the Apple II screen of lines the game has printed, word-wrapped as the +original wraps them by the font's own metrics, the prompt with its +blinking cursor while the keyboard is on, and the still the disc is +parked on) and the `videoland` behaviour, the game's states transcribed +over the description's tables. The parser layer (14.7) was not used: +the game reads the whole keyboard itself (a letter or a space into a +line of forty, return to take it, backspace, and any key at all where +it waits for one), drops what is typed while a clip plays, and splits a +line at its first space with the noun echoed in its answers, so the +behaviour hears the keys as the runtime hands them over (a keysym on +the switch channel, the return and backspace keys by their scancodes) +and keeps the game's own rules. The harness gained a `videoland` +family (compare.sh, videolandOriginal/Port/Drive.singe): the drive +types a walkthrough a character a frame whenever the game waits for a +line (a solution of forty-five lines; with the drive's misses on, a +prologue of unknown words, inventory play, the restaurant's death, and +the coaster's car, each answered with a yes to play again), presses a +key where the game waits for one, and traces the state, the room, the +turns, the keyboard, the delays, the still, the clip's end, the count +of lines printed, and the newest line of the screen. Two of the +original's habits shaped the drive: the keyboard is still on for the +frame a wait for any key begins in, so a space pressed there goes into +the next line (the drive presses a full stop), and it stays on through +the bomb's delay, where a line typed is taken before the play-again +prompt and leaves the game in no state at all (the drive waits for the +delay). Both runs agreed to the line, each to the win: 218 trace +lines of the solution, 341 with the detours. The assembler keeps a +hand-written library title's fonts, as it does a Hypseus one's, and +Rollercoaster moved into `ported/` with its launcher. + +**Hologram Time Traveler (2026-09-21).** RDG2010's Singe edition of +Sega's 1991 hologram game, converted for Singe 2 by POIU2020: a +tutorial and eight time periods, each a stage of segments shuffled at +the game's start (a random order and a random count, or every one by +dip), each segment a run of move rows (an input window, the move +wanted -- a direction, the action button, or a turn and then a shot -- +a rest to the row's end, a death clip, an alternate death after a turn, +and the wizard's advice by kind), a death's clip chosen by the level, +the segment, the move, and what was pressed (a table of special cases +in code), the wizard after a death, reversal cubes that rewind the disc +frame by frame to the move's start, a map of the periods with a cursor +on a fifth-second delay and six seconds to choose, a trader who sells +cubes for coins, a hellgate whose lever rolls for lives, a continue +with a limit by dip, a game over, an LCD of scrolling lines on the +attract loop, and a board typed by the stick over the game's letters. +The port is the family's shape: the converter reads the title by its +own banner and runs its script under the stubs (its dips from the +config its service file names under `Cfg/`, its board from the same +file), and `Vocabulary.singe` beside the script transcribes the loop +(the start, the attract loop, a game's start, the level with its +windows, turns and shots, rests, deaths, cubes, and wizard, the map +and its cursor, the trader, the hellgate, the continue, the game over, +and the board) over the game's own files loaded through the shim: its +globals, its segment shuffles, its level setups and move rows, its +death-clip table, its intro and filler and advice clips, its clip and +timer helpers, its letters and board. The port draws nothing yet, as +the American Laser Games ports do not, and one habit is kept: the +hellgate names a roll the game never defines. The harness gained a +`timetraveler` family (compare.sh, timetravelerOriginal/Port/Drive): +the drive puts a coin in and presses START, answers each window three +frames in with the row's move (a turn, then the shot two frames on), +a wrong move on every fifth and none on every seventh with misses on, +skips the tutorial from the sign post with the right in perfect play +and plays it otherwise, spends a cube against the first death, visits +the trader once and buys cubes with coins, pulls the hellgate's lever +(with misses on: a roll can take every life), takes the continue, and +types a letter at the board; the trace carries the flow, the screen, +the state, the period, the segment, the move, the score, the lives, +the cubes, and the credits, but not the LCD's own state, which the +original's drawing steps while the attract loop shows. Both runs +agreed to the line: 488 trace lines with misses over forty thousand +frames (the tutorial, the first two periods, the trader, a death +rewound by a cube, the continue) and 1,494 of perfect play over a +hundred and thirty thousand: the tutorial and all eight periods with +the map between them, beaten without a death, the extended play the +game grants for that through all eight again, the board, and the game +over. (The first perfect run had pulled the hellgate's lever and lost +every life to the roll at the second period, so the perfect drive now +lets that clip pass.) Hologram Time Traveler moved into `ported/` +with its launcher, its port assembled (seventy-nine ports) and launched +clean. + +**LINEA (2026-09-21).** BLADESCATER's 2023 fan game over a video, the +one title on the MazescaterFramework: Karis 3.31c (fifteen thousand +lines, 2,701 of them changed) with compound move kinds numbered 149 to +239 -- thirty-six sequences of two or three inputs in turn (UL, DLB, +DLU, and their kin), eight circles from any of four starts either way +(LOOPLEFTU and the rest), mashes of one direction or button (MASHLEFT, +MASHB2, MASHB3, each with a MIN and a MAX), MASH3 (BUTTON1 and BUTTON3 +in turn), and a direction with BUTTON2 or BUTTON3 or two directions +with a button (ACT2UP, ACT3DOWNLEFT, ACTUPLEFT) -- each judged by a +check function of its own through the framework's step counter, its +mash flags, or its pressed flags; besides those, a longer look at the +hints (four seconds for two), MASH2 at six presses a second of the +window for nine, every direction and the third button counted for +either side of a mash, and DOUBLE numbered after MULTI (34 for 26). +The kinds are data with three shapes, so the qte loop gained them as +tables rather than branches: their numbers in QTE, MAZE_SEQ (each +sequence's inputs in turn; a circle is five, back to its start) judged +by one Q.checkSequence, MAZE_MASH (the input or pair a mash takes, its +count per second, its MIN and MAX) by one Q.checkMashOf, and ACT_WANTS +(the flags a combination wants together, the Karis pairs included) by +the branch that judged the pairs; the four other differences are gated +on the description's framework mark ("Mazescater 1.00", which the +converter writes when the framework folder beside the game is the +MazescaterFramework), and the assembler leaves that folder behind as it +does the Karis copies. The Karis drive had answered single inputs only +(a compound move timed out, which the earlier titles' runs allowed +for); it now plans the compound kinds of both frameworks -- a hold +pressed through the window, a let-go held from before it, a mash tapped +every other frame (a run, MASH2, and MASH3 alternating their two), a +sequence or circle a step every other frame, a multi as many times as +its row asks, a combination together -- and takes PORT_WRONG_EVERY and +PORT_MISS_EVERY for a run that goes deeper into a game with few lives. +Both runs agreed to the line: 166 trace lines under the drive's usual +policy (LINEA's five lives and one continue end at the thirtieth move) +and 870 over sixty thousand frames with a wrong answer every fiftieth +move and a miss every seventieth, through the fifth level's fifty-ninth +move, every one of the fifty-six kinds the game uses met and passed. +LINEA moved into `ported/` with its launcher, its port assembled (eighty +ports) and launched clean, and the release repacked. + +**Platoon (2026-09-22).** POIU's 2020 prototype in the American Laser +Games shape, from the same hand as the library's Singe 2 editions: six +patrols (Charlie, Delta, Tango, Lima, Zulu, Bravo) of eight to twenty +moves each, every move a window to hit one man in, judged by the +editions' hit test against a hitmap of the frame whose boxes are, as +often as not, thin lines given back to front; a first press of the +trigger while a patrol's opening plays jumps past it, whatever the +state; a miss holds the disc at the window's end for the draw the +difficulty allows, then the move's death clip and the sergeant's after +it; the continue, the lives-left still, the board with an opening clip +of its own when Tango was beaten, and an attract loop the game files +under a level it never defined (its levelIntro is nil, and nil equals +nil). The port is the family's shape: the converter's alg mode reads +the dips and the board out of Script/platoon.cfg, and +`Script/Vocabulary.singe` (575 lines) carries the loop -- the six +patrols as one function over a table of what differs (the length, the +opening's skip, what follows), the intro, the continue, the lives left, +the board, and the gun's input, with the game's own level setups, +hitbox tables, hit test, and board functions run through the shim as +they are; the nil level is kept as the original has it, since the trace +must show it. The harness learnt three things from the title: the +alg family looks for the framefile under Script/Video too; the drivers +give the drive a target while the opening waits for its skip and name +the reload button (the second, as the Crime Patrol family) with no +spare bullet, since the release takes the bullet after the shot is +judged; and the drive's aim skips a box given back to front (the +game's own test can never hit one) and answers a move again in the game +the continue starts over. Both runs agreed to the line: 305 trace +lines with misses over sixty thousand frames (Charlie lost thrice and +beaten after the continue, Delta, Tango, the board, the game over) and +336 of perfect play over ninety thousand (all six patrols without a +death, the board with its Tango clip, the game over, the attract loop). +Platoon moved into `ported/` with its launcher, its port assembled +(eighty-one ports) and launched clean, and the release repacked. With +it the one-offs are done, and every game of the library the plan set +out to port has its port. + +### 14.15 The HUDs and the service menus (2026-09-22) + +The owner's verdict on 14.14's close: "Not porting the service menus or +HUDs means the ports are NOT COMPLETE. Finish your work!" Right: a +port that plays the loop but shows no score, no lives, no credits, no +prompt for the move, no board, and has no service screen to set its +dips on is a port of the loop, not of the game. What every family draws +and configures, and how each port gets it: + +* **The KarisFramework** (twenty-eight ports and LINEA): the score in + the framework's number sprites (or rendered), the lives (sprites or a + count), the credits or INSERT COIN or FREE PLAY blinking, the level + title or scene number, the disk-slot and autosave marks, the get-ready + and skip prompts, the LCD ticker of the attract loop (typed out a + character at a time), the pause, the move prompt (`drawAction`, 534 + lines: arrows that slide in from the edges, the buttons, the hold and + mash gauges, the loops, the combinations, the timed and multi kinds; + LINEA's framework adds its compound kinds and their hint texts), the + choice menu of a CHOOSE move, the board (the name entered on a grid of + forty-four cells, the rankings of the three modes, the percents, the + trophies), and five service screens (the options, the graphics, the + performance settings, the load/save slots, the exit) of 4,700 lines, + with the config file they write. The framework's copies do not travel + with the ports (14.8), and its loop was rewritten into Author.singe as + the qte behaviour rather than run, so its drawing and its screens are + rewritten the same way: a `qteHud` look in Author.singe that draws + from the state the behaviour keeps, and the screens as states of the + loop (`Q.doServiceMenu` and its kin), both as data where the framework + repeats itself (the option tables: a label, the values it cycles, what + gates it). The art travels: the assembler ships a title's Overlay/ + (with Lores/) and Fonts/, and the framework's default font as + `Fonts/default.ttf`, since a look needs them and they are art, not + program. +* **The Kimmy Script Engine** (seven ports): its own HUD over a skin + (`FrameworkKimmy/Skin/DEFAULT`), the combo prompt (`drawAction`, 1,671 + lines), the next-move preview, the life bar, the tilt, the warning, its + board, and its five screens (the new-game menu is already a state of + the kimmy loop). The same way as Karis: a `kimmyHud` look, screens as + loop states, the skin shipped with the port. +* **The map-mode lineage** (starblazers, freedomfighter, jeeg, hq-timegal, + Super Don Quixote): their `main.singe` already runs through the shim + for their levels, and holds their drawing and their one service screen; + an `rdgHud` look calls those functions as they are, under the shim's + sprite, font, and colour calls made real. +* **The American Laser Games titles** (thirty-two ports, seventeen + vocabularies): bullets, lives (hats, money), the score, the credits, + the crosshair and its recoil, the reload animation, the board's letters + and table, and the one service screen with the mouse as its cursor -- + all in the games' own files, which the vocabularies already run through + the shim. Each vocabulary gains an `algHud` look that calls the game's + draw functions in the game's order (its `onOverlayUpdate` tail), and a + `levelService` state that runs the game's `doServiceMenu`; the SERVICE + switch, let go until now, opens it. +* **Hologram Time Traveler**: its LCD, action, lives, cubes, score, and + credits, and its service screen, from its own script, the same way as + the ALG titles. **Rollercoaster** draws its console already and has no + service screen. **ActionMax** shows its LED readouts through text and + sprite looks the converter writes, and the console had no service + screen. + +Three things the runtime gains for all of them: + +* **The shim's drawing calls are real.** `spriteLoad`, `spriteLoadFrames`, + `fontToSprite`, `spriteGetWidth`, `spriteGetHeight`, `spriteUnload`, + `spriteDrawFrame`, `colorForeground`, `fontSelect`, `fontQuality`, and + `getMiddle` do what the engine does, on files found where the port + carries them (`Q.gameFile`); a file that is not there gives -1, as a + stub did, so a game's load never ends on missing art. `overlayClear` + stays a no-op (Forge clears), and a look that drew with the game's font + puts Forge's back. +* **Dips persist.** A service screen's changes are written through the + engine's save store (`saveSet("forge.cfg", ...)`) and read at attach + over the description's dips, which remain the shipped defaults (what + "Default" restores). The Karis save slots go the same way. The + harness runs in a fresh data directory, so a comparison starts from the + description as before. +* **Two checks.** The drives gain a service walk (`PORT_SERVICE=1`: the + SERVICE switch at the attract loop, every option stepped, the exit), so + the screens' logic is traced against the original like the loop; and + `testScripts/ports/shots.sh` runs an original and its port to the same + moments (the attract loop, a prompt, the board, each service screen) + and screenshots both into `screenshots/ports/`, since what a HUD shows + is a picture, and a trace cannot say it is right. + +The order: the runtime's three gains and the ALG family first (the +least new code: the drawing exists, the shim runs it), then Karis (the +most, and the pattern Kimmy follows), then Kimmy, the map-mode titles, +and Time Traveler; each family closed with its traces still agreeing +and its screenshots beside the original's. + +**Parked (2026-09-22).** The first step of that order was taken and +then stopped: the shim's drawing calls are real (queued through the +behaviour's step and run by a look, with one sprite handle per file), +dips and boards persist through the save store, the twenty-six ALG and +Time Traveler vocabularies gained a `hud` look that calls the game's own +draw functions and a service state that runs the game's own screen, the +drives gained a service walk and screenshot moments, and shots.sh +gathers the pictures. None of it was verified past a syntax check, and +the owner's look at the sources ended it: "Most seem like a thin layer +of Forge bolted on to a whole hell of a lot of existing code. That's +not really meeting the stated goal of Forge." He is right, and +Rollercoaster is the sharpest case: its description has an empty +`rules` table and the whole game is 985 lines of Lua in its vocabulary. +The ALG family is the same at scale, 54,627 lines of vocabulary Lua +across twenty-six files, steering a shim that runs the games' own +setups, hit tests, boards, and (as of this step) drawing and screens. +By the measure that matters -- how much of the game lives in the +description and how much in Lua -- only the ActionMax five and the +framework loops meet the goal, and even Karis and Kimmy run the game's +script at attach. The rule that should have governed section 14, for +whenever this is taken up again: a port's vocabulary may add only +general-purpose kinds; a game's logic and data live in its description; +a family with mechanics Forge lacks gets them as runtime behaviours and +looks driven by data, never as the game's code and never as the game's +code rewritten by hand. The redo, in order: Rollercoaster (the +smallest, and the only one that exercises the authoring side -- rooms, +the parser layer, rules, sequences, vars, a console look -- so its gaps +say what Forge is missing for adventures), then the ALG family (one +generic behaviour and look, the converter extracting hitboxes, frames, +boards, dips, and service options, the vocabularies gone), then Time +Traveler, the map-mode five, and the Karis and Kimmy attach. The work +is filed here; the owner's word was "get back to actually working on +Singe." + +### 14.16 The pixel reading, into the framework (2026-09-23) + +The owner asked whether the pixel reading could be added to Forge itself +rather than sitting beside five games, and it could: it was general all +along. `AUTHOR.behaviours.lightSensor` is in the manifest now, in the gun +cluster after `gun`, `pointer`, and `target`, and the read behind it is one +runtime function, `authorPictureState(x, y, high, low)`, in the section about +judging shots over the picture. Nothing about it was ActionMax's: the spot, +the corner it may not be shot in, the two thresholds, the trigger, and the +player are parameters, and the numbers live in each description as they +always did. + +`AUTHOR.conditions.pictureAt` came with it, so a rule can ask what the +picture is at a point without a sensor at all -- a flash hidden in a game's +own video, a colour cue, a lamp in a scene. The two thresholds are +`PICTURE_BRIGHT` and `PICTURE_DARK` beside the other tuning constants, named +once and read by both. A game with no disc reads dark everywhere, since +`vldpGetPixel` answers zeroes when no video is playing. + +`util/forgePortActionMax.py` no longer writes a `vocabulary` line, the five +descriptions are regenerated without one, and the file they used is kept +aside as `Vocabulary.singe.old`. All five comparisons still agree with their +originals. So the zero-vocabulary count is 54 of the 81 descriptions, and +the ActionMax five are now ports in the full sense: a description, the +framework, and nothing of their own. `docs/ForgeVocabulary.adoc` is +regenerated from the manifest, and the book's "Your Own Vocabulary" now uses +the test scene's `bob` as its example, with the rule that a word two games +would want belongs in the framework. + +Scene 100 (`testScripts/author/picture.forge`) covers both words from the +repository alone, since the ActionMax five are the only other thing that +exercises them and they live outside it: the description compiles, the built +game calls `authorPictureIs`, the framework has the behaviour, and the three +answers add up to the frames drawn. Run plain, every frame must read dark, +because no video is playing; run with `-v Singe/menuBackground.mkv`, the read +must see something other than those zeroes. Which of bright and neither it +sees is the video's business: the menu's backdrop is mid-tone, so it reads +neither on 119 frames of 120 and never bright. Both runs are clean. + +### 14.17 How much of a QTE port is really in its description (measured 2026-09-23) + +The owner put the obvious question to 14.5: the ports are called ports, yet +the loop calls the game's own hooks. So: how much of each port would still +play with the game's Lua taken away? It is a one-line experiment, since the +`qte` behaviour only loads what the description names -- delete its `script` +and `addons` lines and the loop has nothing but the snapshot the converter +wrote. Five titles, each compared against its original as 14.1 compares them: + +| Title | Hooks it defines | Without its Lua | +|---|---|---| +| Time Gal | setupMoves, swapScene, doLevelSelect | plays as the original, 186 trace lines | +| Asterix | setupMoves, swapScene, doLevelSelect | plays as the original, 218 trace lines | +| Tron | setupMoves, swapScene | diverges at level 1 scene 5 | +| Dragon's Lair Enhanced | setupMoves, startConf, swapLevel, specialScore | errors in `Q.doMixTIE` | +| Space Ace Enhanced | setupMoves, startConf, swapLevel, swapDeath | diverges | + +So the hosting is not what makes a port play: for two of the five the +description already holds everything, and `Q.loadMoves` reading the snapshot +when no `setupMoves` exists is not a fallback nobody uses -- it is the whole +game. Where it is load-bearing, what is missing is nameable. Dragon's Lair +Enhanced fails on `q.Tiers[0]`: the tier tables that decide which levels are +mixed into a run are declared by the game's script and the converter does not +snapshot them. Tron's divergence is its `swapScene`, a per-difficulty choice +of scene that the snapshot does not carry either. Neither is a mechanic Forge +lacks; both are data the converter has not been taught to lift. + +The next step for 14.5, then, is not a redesign: teach `forgePortKaris.lua` to +write `Tiers`, `PlayOrder`, and the swap decisions into the description, give +the loop a `mix` of its own to read them, and re-run the comparison with the +hooks gone. Every title that then plays without its Lua is a title whose port +is a description, which is the measure 14.15 set. Thirty-eight of the +seventy-five descriptions define `setupMoves`, so that is the size of the prize. + +### 14.18 What "complete" means for a port, and where each one stands (2026-09-24) + +The owner asked for a port to be called complete only against something +checkable, after this session called ports "playing as the original does" on the +strength of a trace comparison that never looks at the screen. That phrase is +the harness's and it means one row of the table below. So: a port is complete +when every row holds for the same inputs, each row has a check of its own, and a +claim names the row and the check rather than the word. + +| Row | What it means | The check | Where the framework ports stand | +|---|---|---|---| +| State | The same moves judged at the same frames, and the same score, lives, and level | `testScripts/ports/compare.sh` compares traces | 19 titles agree with none of the game's Lua; see the matrix | +| Picture | The same screen: arrows, life bar, score, prompts | Screenshots diffed at fixed frames. Not built | Nothing of the game is drawn at all | +| Screens | Service menu, save menu, name entry, two-player, movie player, each reachable and behaving | A drive into each. Not built | Not played: the loops send those states back to the attract loop | +| Sound | The same clips at the same moments | Not built | The framework's eight sounds play, and the game's own play through the shim; unverified | +| Persistence | Dips, saves, and boards survive a restart | Not built | Unknown | +| Controls | Every switch the original honours, the service key included | Not built | Partial | +| Independence | The port runs with none of the game's own files | `util/forgeStandalone.py` | 19 of 36 titles | + +Three rules go with it, and they bind the writing as much as the work: + +* **Name the check, not the conclusion.** "The traces agree" is a fact. "Plays + as the original" is a claim about rows nobody tested. +* **A row with no check is unknown, not done.** Sound, persistence, and controls + above are unknown, and saying nothing about them is not the same as covering + them. +* **Complete means every row.** Anything less is named by its rows. These ports + are state-complete and, for 19 titles, independent. Nothing more. + +Why the drawing is missing rather than broken: the shim collects every drawing +call a game makes into a queue (`Q.queueDraw`), and a look is supposed to flush +it. `authorFlushDraws` is there and nothing calls it, because the look that +would have belonged to the drawing work parked in 14.15. A description's game +entity therefore takes the `none` look, and the queue is dropped every frame. + +**The matrix.** "Without its Lua" is the standalone sweep of 2026-09-24, run +after the converter was fixed to snapshot the tier tables and the scene swaps +(14.16 is the pixel reading; the swaps are in this session's converter work). +"As it ships" is the same comparison with the game's own files where they are, +which is only interesting for a title that needs them. + +| Title | Family | Without its Lua | As it ships | +|---|---|---|---| +| AlteredCarbon | karis | agrees | agrees (ships without it) | +| Arcade_Xperience_Vol1 | hypseus | agrees | agrees (ships without it) | +| Arcade_Xperience_Vol2 | hypseus | differs | DIFFERS | +| Arcade_Xperience_Vol3 | hypseus | differs | DIFFERS | +| Asterix | karis | agrees | agrees (ships without it) | +| Astroboy | hypseus | differs | DIFFERS | +| badlands_lite | hypseus | agrees | agrees (ships without it) | +| BD13 | hypseus | differs | agrees | +| ChantzesStone | karis | agrees | agrees (ships without it) | +| CliffHanger | karis | agrees | agrees (ships without it) | +| Danmachi | hypseus | differs | DIFFERS | +| DragonsLair2Enhanced | karis | agrees | agrees (ships without it) | +| DragonsLairEnhanced | karis | differs | not checked | +| Esh_Aurunmilla | hypseus | differs | agrees | +| FBC | hypseus | differs | DIFFERS | +| FireAndIce | karis | agrees | agrees (ships without it) | +| Freddy | hypseus | differs | DIFFERS | +| FridayThe13th | karis | differs | DIFFERS | +| FumaConspiracy | hypseus | differs | DIFFERS | +| hayate_1080 | hypseus | differs | agrees | +| Linea | karis | agrees | agrees (ships without it) | +| mazinga | hypseus | agrees | agrees (ships without it) | +| Mononoke | karis | differs | DIFFERS | +| NinjaHayateHD | karis | agrees | agrees (ships without it) | +| OeilPourOeil | karis | agrees | agrees (ships without it) | +| sintel | hypseus | differs | agrees | +| Sonic_the_Hedgehog_1996 | hypseus | agrees | agrees (ships without it) | +| SpaceAceEnhanced | karis | differs | not checked | +| StarshipTroopers | hypseus | differs | agrees | +| SuckerPunch | karis | agrees | agrees (ships without it) | +| Sugar_Rush | hypseus | differs | DIFFERS | +| Survival | hypseus | agrees | agrees (ships without it) | +| TheElderScrolls | karis | agrees | agrees (ships without it) | +| TimeGal | karis | agrees | agrees (ships without it) | +| TitanAE | karis | agrees | agrees (ships without it) | +| Tron | karis | agrees | agrees (ships without it) | + +Ten ports differ as they ship, which is the finding this table exists for: +Arcade Xperience Volumes 2 and 3, Astroboy, Danmachi, Future Boy Conan, +Freddy, Friday the 13th, The Fuma Conspiracy, Mononoke, and Sugar Rush. Every +one of them was bisected and every one predates this session: each was run +with its own pre-session description and the pre-session runtime (721a0d120, +the 13,628-line Author.singe, before the scene swaps) and differed there too. +So nothing in 14.16 or 14.17 caused them, and the cause of each is still +unknown -- what is known is that a port that differs as it ships is not +state-complete, and none of the ten should be offered as playable until it is. + +A trap for whoever bisects next: do not take HEAD for the pre-session +runtime. The owner commits while work is in progress, so a session's own +changes can already be in HEAD -- 25aa909c3 carries the scene swaps -- and the +first bisection here compared the session's runtime against itself and proved +nothing. Name the commit. + +Seven ports keep their Lua and agree: Dragon's Lair Enhanced, Space Ace +Enhanced, Sintel, Esh's Aurunmilla, Starship Troopers, Brain Dead 13, and +Ninja Hayate 1080. Those are state-complete but not independent. With the +nineteen that stand alone, that is twenty-six of thirty-six titles +state-complete and ten not. + +### 14.19 The ten that differed, diagnosed (2026-09-26) + +All ten ports that differed as they shipped (14.18) now agree: the same +traces as their originals under `testScripts/ports/compare.sh`. That is the +State row and nothing more; the other rows of 14.18 are as they were. Three +causes, all in the runtime rather than in any description, and none of them +new: each predates 721a0d120, as 14.18's bisection already said. + +**How it was run.** A binary of its own, so another session could keep +working on the one in `.builddir`: the inner project configured directly +(zig, Release, the default preset's installed libraries, `SINGE_SUPERBUILD` +off) into `~/claude/portwork/build`, built once from the working tree on +2026-09-26 and left frozen for every comparison below. Configured that way +nothing copies the binary into `.builddir`. The diagnosis used copies of the +drivers outside the repository with two additions: a per-frame line of the +game's flow, screen, step, and disc frame after each update, and a line for +every `disc*` call with the frame it was made on. Diffing those two runs +frame by frame finds the first frame the port parts from the original, which +the traces cannot: a trace records disc frames, so a port that is one +overlay frame behind the original agrees on every line but the last. + +**1. The countdowns' arithmetic** (Astroboy, Friday the 13th, Mononoke, +Sugar Rush). Astroboy's traces differed only in their last line, 30400 +frames against 30401. The per-frame diff put the parting at frame 2783: +the difficulty screen, whose `timerON(30)` ends it after thirty seconds, let +go a frame later in the port and the port stayed a frame behind from there +to the end. Thirty seconds on the deterministic clock's 15 ms step is +exactly 2000 frames, so the frame the timer is due is decided by rounding. +The framework sums `os.clock()` deltas, `iSecs = iSecs + thisSeconds - +lastSeconds`, which Lua evaluates as `(iSecs + thisSeconds) - lastSeconds`, +and compares the sum; the port compared `authorTime() - from` against the +limit. Both are the same ticks, but the two sums round differently, and on +the frame that matters one reaches 30 and the other falls a hair short. +Every copy of `timerDue` in the library (148) and of `joyDelayDue` (95) does +the framework's arithmetic, so the port's timer and joystick delay now share +one countdown that does exactly that, with `os.clock()` as the clock, and +holds while the game is paused as the framework's does. + +**2. The language** (FBC, Fuma Conspiracy, Danmachi's start). FBC's traces +differed on one line: the first screen of a game was traced at disc 328 in +the original and 329 in the port, and everything after agreed. The +per-frame diff showed the same state on every frame, so the difference was +between frames: the call log showed the original calling `discAudioSuffix` +on the frame the game starts and the port calling nothing. That call is the +framework's `setLang`, run by `readConfig` from `initJob`, and it holds the +disc for the frame. `readConfig` only calls it when the saved config has a +`dip_Lang` line; Astroboy's and Arcade Xperience's configs have none, which +is why they agreed. More than a frame: Fuma Conspiracy's config says +`dip_Lang = 1`, and `Video/fuma-it.ogg` is there, so the original plays in +Italian and the port played the default track. With the fix, both runs call +`discAudioSuffix("-it")` on frame 677 (checked from the call log; nobody has +listened to it). The converter records a dip only when a config file names +it, so the snapshot having `dip_Lang` is exactly the condition, and the +KarisFramework and Kimmy loops now call the framework's `setLang` on it at +init. + +**3. The game over after a continue** (Arcade Xperience 2 and 3, Danmachi, +Freddy). At game over the original seeks to the game-over clip and the port +did not. The port's `doGameOver` came from 3.31c, which plays on from the +continue clip when the disc is at its end; 3.32b always seeks. All the +test library's own ports are 3.31c; the Hypseus library's KarisFramework +titles are 3.32b. The shortcut now applies to 3.31c only. + +| Title | Family | Now | Cause | +|---|---|---|---| +| Arcade_Xperience_Vol2 | hypseus | agrees, 188 lines | 3 | +| Arcade_Xperience_Vol3 | hypseus | agrees, 224 lines | 3 | +| Astroboy | hypseus | agrees, 226 lines | 1 | +| Danmachi | hypseus | agrees, 83 lines | 2, 3 | +| FBC | hypseus | agrees, 97 lines | 2 | +| Freddy | hypseus | agrees, 165 lines | 3 | +| FridayThe13th | karis | agrees, 344 lines | 1 | +| FumaConspiracy | hypseus | agrees, 94 lines | 2 | +| Mononoke | karis | agrees, 369 lines | 1 | +| Sugar_Rush | hypseus | agrees, 289 lines | 1 | + +**Then shown.** Causes 1 and 2 change what every KarisFramework and Kimmy +port does, including the twenty-six that already agreed, so they were not +"fixed" until the whole sweep agreed again on the same binary; it did (the +sweep, below). Also noted and not changed: the Kimmy +loop's end of the attract clip leaves `bPause` and `lvlState` alone where the +framework resets both, which shows in the per-frame state for one frame and +in no trace. + +**The sweep, on the same binary (2026-09-26).** Every one of the 36 titles +of 14.18's matrix agrees on State with causes 1 and 2 in place: the 26 that +agreed before still do, and the ten above. So do the map-mode titles outside +the matrix (Conan, Daitarn 3, the TV Show, Dragon Trainer, Puss in Boots, +Samurai Jack, Super Don Quixote, Freedom Fighter, Time Gal HD, Star Blazers) +and the rest of the Hypseus titles (Mazinga, Sintel, Sonic, Starship Troopers, +Sugar Rush, Survival). That is State only, as before. + +**What the sweep itself got wrong.** `sweep.sh` ran every description under a +library `Script/` folder with the KarisFramework driver, whatever its `kind`, +so the gun games, the time-travel game, and the text adventure each produced +a trace of one line and "differed" for that alone. It now picks the driver +from the description's `kind` (`familyOf`), as its Hypseus loop already did. + +**Three defects the gun games had, found on the way.** The shim's +`Q.loadSprite` called `spriteLoadFrames(path, frames)`, the wrong way round +for the engine's `spriteLoadFrames(count, filename)`, and a Hypseus gun game +stopped at its first sheet. A game that asks for the old sprite argument +order and loads the engine's `Framework.singe` through the shim got its +`spriteDraw` wrapped twice, once by the framework and once by the shim, so a y +became a sprite handle; the shim now lets the framework wrap and then takes +back the two calls it converts itself. And one game's vocabulary reimplemented +`initJob` without the score digits' positions and sprites, which only showed +once an uncapped run reached the score. The Reference's `spriteLoadFrames` +example had the arguments reversed too, and is corrected. + +**Outside the matrix, with the right drivers (2026-09-26).** Every other +family now agrees on State too -- the gun games in both libraries (Crime +Patrol HD in both, Drug Wars and its HD, Last Bounty Hunter and its HD, Mad +Dog McCree II and its HD, Space Pirates and its HD, Who Shot Johnny Rock and +its HD, Johnny Rock Noir, and the typing edition), Platoon, Jeeg, the +time-travel game, and the text adventure -- but for the exceptions below. +Getting there took, as +well as the three defects above: the vocabularies' own `initJob` put back what +the original's draws on (score digits, lives stars, the LCD's words, the +arrows), because an uncapped run reaches the drawing and the drawing stopped +the run; the time-travel game's picture drawn only while the game runs, as +the original's is; Last Bounty Hunter HD's pointer setting the cursor and the +shot's mark as the original's `onMouseMoved` does; the shim's +`spriteDrawFrame` taking every old form (with a scale, or two); the drivers' +pair kinds skipping a constant the game does not define (Jeeg has no +diagonals); and `compare.sh` finding a gun game's video when it keeps no +framefile, and saying so when a title has no video at all. + +The exceptions, open: **the library's Crime Patrol** (not the HD) left no +result in the batch; run alone, its two traces are the same 321 lines, but +neither run ends with the drive's end line, so both were stopped by +`compare.sh`'s hour rather than finishing. It agrees as far as both got, and +why its drive never reaches an end is not known. **Mad Dog McCree HD** misses one seek, entering the town +select after a continue (the original seeks back to the menu frame, the port +is five frames further on), and agrees before and after it; finding why wants +the per-frame instrumentation of 14.19 on the gun-game drivers. **The +Hypseus `WSJR_HD`** is not compared: its description sits at the title's top +level rather than in `Script/` beside its script, where 14.13 says it lived +when it agreed, so no driver finds it; where it belongs is the owner's to say. +All of this is the State row; the drawing that now runs is not checked. + +**Where the fixes live, and why that is temporary.** All three went into the +framework-emulating loops in Author.singe, beside the code they correct: +`Q.setLang`, a `Q.karis332` check, and the framework's countdown. That is +framework knowledge in Forge's runtime under framework names, which section +15 sets out to end. They move with the rest of the loops when it is done. + + +## 15. Design: Forge as its own thing (written 2026-09-26) + +The owner, after 14.19 put three more framework fixes into Author.singe: +"I want Forge to be as generic and capable as possible. If cloning the Karis +framework is what provides Forge with QTE capabilities, that's fine. But we +can't call it 'Karis' or refer to any other third party framework name. +Ideally, Forge is its own thing that is flexible enough to handle everything +these other frameworks provide." And: cover everything Forge can do, not +just QTE, and where an existing game does something Forge cannot, find a +generic way to support it. + +This section is the plan for that. Nothing in it is built. It was written +from four surveys made the same day -- Forge's whole vocabulary and editor, +the engine API against what Forge can reach, every family in the library +against what its port needs, and the five game loops in Author.singe side by +side -- and every defect it reports was checked against the code before it +was written down (15.7). + +### 15.1 The rules + +1. **No third-party names in Forge.** Not in a behaviour, layer, look, + event, condition, action, parameter, or `kind`; not in help text, the + generated vocabulary, the editor, the book, or the runtime that ships + inside every release. A framework, engine, company, or game named as the + *source* of a behaviour is the thing this rules out. Library and format + names a user needs to know (glTF, RmlUi, Jolt, WAV) are not sources of + behaviour and stay. +2. **What a framework does becomes a Forge capability.** Cloning a + framework's behaviour is fine; shipping it as that framework is not. The + capability gets a Forge name, a Forge data model, and is open to every + game, not only the ports it came from. +3. **Differences are options, not versions.** Where two frameworks (or two + versions of one) disagree, the difference is a named option with a plain + meaning, set in the description. Nothing at run time asks which framework + or version a game came from. +4. **A game's logic is data, or its own vocabulary.** What a description + can say, it says. What only one game does goes in that game's + `Vocabulary.singe` (14.7), under names that are the game's own business. + Running a game's original Lua through a shim is a porting stage, not a + destination. +5. **Only the converter knows where a game came from.** It has to read + those games, so it may name them; everything it writes uses Forge's + vocabulary. It is renamed from `util/forgePortKaris.lua` to a generic + name when the rest moves. + +### 15.2 What Forge can do today + +For the record, so the gaps below are measured against something. The +vocabulary is `docs/ForgeVocabulary.adoc`, generated from Author.singe. + +* **Layers:** bezel, disc, hud (an RmlUi document with ids bound to vars), + music, overlay, parser, scene3d, world2d. +* **Looks:** billboard, box, grid, light, mesh, model, none, particles, + sprite, text, text3d. +* **Behaviours:** animator, body, branching, camera, character, drift, + frames, gun, health, hitbox, hotspot, joint, keys, lightSensor, mover, + patrol, platformer, pointer, projectile, racer, seek, shooter, soft, + solid, sound, spawner, spin, target, thrust, timer, trigger, turret, + vehicle, walker, water; and the five game loops (15.3). +* **Events:** animationDone, arrived, branchOpen, branchTaken, branchMissed, + collision, death, enter, leave, frame, frameReached, gameOver, hit, miss, + midi, patrolEnd, pressed, released, railEnd, roomStart, roomEnd, said, + soundDone, spawn, stopped, timer, verb. +* **Conditions:** below, chance, discBetween, every, has, hitPart, hover, + inState, keyHeld, keyPressed, onGround, onScreen, once, pictureAt, + pointerIn, pointerOffscreen, switchHeld, test, timeBetween, touching, + waiting. +* **Actions:** addScore, addVar, setVar, credit, damage, destroy, spawn, + spawnAtPointer, show, moveTo, setState, setText, die, gameOver, restart, + discPause, discPlay, discTo, emit, fade, flash, shake, face, lookAt, + walkTo, walkToHotspot, walkToPointer, goTo, jump, run, saveGame, + loadGame, lua, nextVerb, setVerb, useItem, give, take, pathNext, + cameraCut, playAnimation, push, reload, playMusic, stopMusic, playSound, + say, talk, submitScore, timerStart, wait. +* **Structure:** types and instances with vars and states; rooms that keep + their state; rules with triggers, `when`, `do`, `each`, `interrupt`, and + cut-scene `controls = false`; expressions; tracks (hitbox boxes keyed by + frame or time, branches, rail points with stops, racer lines); waves; + sequences as coroutines; dialogue trees; walk polygons baked to a + navmesh, hotspots, verbs, and a verb/noun parser; whole-game save slots + and a best score; a per-game vocabulary file. +* **Editor:** a chooser, five panels (entities, types, rules, tracks, + dialogues), manifest-driven fields with pickers, rename that follows every + rule, polygon drawing, a timeline strip over the disc, a 3D viewport with + gizmos, undo and redo, save, build with diagnostics, play from the room, + export, and publish to the master service. + +Of the engine's 510 registered functions, Forge's vocabulary reaches 169; +24 more are reached only by the game loops; the other 317 are reachable +only through the `lua` action or a game's vocabulary file. 15.5 takes the +ones worth a Forge feature. + +### 15.3 Third-party names to retire + +| Where | Now | Becomes | +|---|---|---| +| behaviour | `qte` | stays `qte`: the one Forge QTE behaviour (15.4), which the other four fold into | +| behaviour | `kimmy` | folded into `qte`; its differences are options | +| behaviour | `rdg` | folded into `qte` (segmented levels, level map) | +| behaviour | `timegal` | folded into `qte` (checkpoints, a timed choice, quota lives) | +| behaviour | `sdq` | folded into `qte` (trophies, percent finish, order after a beaten or unfinished level) | +| game vocabularies | `kind = "alg"` (25 titles) | a Forge `gunGame` capability built from 15.5's gun model; until then, each game's own vocabulary under a neutral name | +| game vocabulary | `kind = "timetraveler"` | the game's own vocabulary under a neutral name, then Forge features (15.6) | +| game vocabulary | `kind = "videoland"`, look `console` | a Forge `console` look and a Forge text-adventure capability (15.5, text input) | +| game vocabularies | look `hud` (ALG, the time-travel game) | renamed: it collides with the `hud` layer, and becomes 15.5's HUD widgets | +| help text | `branching`: "Dragon's Lair:" | a plain description of branch windows (help text is Forge itself: the editor and the generated vocabulary show it) | +| help text | `parser`: "the Sierra way"; `die`: "The Sierra death" | plain descriptions, for the same reason | +| help text | `qte`, `kimmy`, `rdg`, `timegal`, `sdq`: KarisFramework, MazescaterFramework, Kimmy Script Engine, Karis's 2024 framework, RDG's lineage, RDG2010, Time Gal, Super Don Quixote | the folded `qte`'s own help | +| runtime | section headers naming KarisFramework, the Kimmy Script Engine, RDG's map mode, Time Gal, Super Don Quixote; comments naming Hypseus, American Laser Games, Mad Dog, ZeroBrane | Forge's own section names; comments say what, not whose | +| runtime | `KIMMY`, `KIMMY_MASK`, `KIMMY_BIT`, `Q.mazescater`, `Q.karis332`, `q.hayate`, `q.kimmy`, `q.rdg`, `q.timegal`, `q.sdq`, `qte.framework` | gone: options (15.4) and one constants table | +| data | `qte.framework = "3.31c"` and kin in every ported description | gone; the converter writes the options instead | +| book | the ports paragraph (KarisFramework, Hypseus, LINEA, Mazescater, Kimmy, RDG, Time Gal, Super Don Quixote, American Laser Games, Mad Dog McCree, Platoon) | Forge's QTE and gun-game chapters, in Forge's words | + +**Describing a style of game in the manual is fine** (the owner, 2026-09-26): +the book may say an adventure plays the way Sierra's and LucasArts' did, that +branch windows are the Dragon's Lair kind of game, that a tutorial builds a +gun game in the manner of Mad Dog McCree, or that the light sensor serves +ActionMax tapes. Those names describe what the reader already knows. What +they may not do is name anything in Forge itself -- a word of the +vocabulary, a help string, the editor, the runtime -- or present a Forge +capability as a framework's. The book's paragraph about the ports still +goes, because it names frameworks as the source of Forge's behaviour. + +### 15.4 Forge's QTE: one behaviour, its own model + +Everything the five loops do, as one behaviour whose model is Forge's. The +loops already share one skeleton; what differs is a list of choices. + +**The game's shape.** A title clip, then the attract loop, then play. The +attract loop is a list of steps, each a clip or a still with a hold time, +any of which a button skips; credits (coins or free play, a cap), a start +input, an optional secret input sequence that starts a hidden level. Play +is levels of scenes of moves, with these screens around it, each optional +and each a clip or a still with its own inputs: difficulty select, level +select or map, new-game menu (new, continue an autosave), level intro and +get-ready, hints on a death, continue, game over (normal and alternate), +level clear with a bonus roll, finish (points, percent, or a tally), high +score, trophies. + +**A move.** A window (`from`, `to` on the disc), a judge, and what follows: +where play continues on a right answer, and what a wrong answer or no answer +does -- a death clip (named, from a list, or drawn at random), harmless, or +counted as right. A path move names where each answer leads (another move, +a later scene, a death, the end of the scene). Frames may be written +relative to the level's intro. Scenes may be mirrored (a random draw per +scene, an offset for the scene and for its deaths, left and right swapped). + +**Judges** -- the kinds, named for what the player does: press (one input, +optionally a second accepted one); chord (inputs together, nothing else); +hold (for a length the difficulty sets); letGo (held at the start, released +by the end); mash (a rate of presses of one input or alternating inputs, +decaying, scaled by window length and difficulty); count (an input pressed +n times); sequence (inputs in order); circle (the stick round, quarter, +half, or whole, then an input); choose (n options shown shuffled, a timeout +default); path; yesNo; timed (an input within a sub-window); skip (any +input skips ahead); jump (to a scene or level, no input). Each judge's +arithmetic -- how a mash decays, how long a hold must last at each +difficulty -- is a parameter with the frameworks' values as presets, not a +second implementation. Tilt (inputs outside a window count against the +player, a warning at half the limit) is an option on the behaviour. + +**The rest of the model.** +* *Lives:* a count per credit, a life bar (size, cost of a miss, right + moves to regain one), one life, or none (practice); extra lives every n + points or at a quota and every m after. +* *Retry after a death:* restart the scene, replay the move from a lead-in, + restart the level, carry on from the next move, or resume from the last + checkpoint; per level, what a failed level does (replay, move on, one + retry, come back later in the order). +* *Order:* sequence, tiers, random with a fixed last level, map, or level + select; optionally skipping beaten levels, and a next level chosen by a + rule (15.5, progression). +* *Scoring:* per move (plus per difficulty), per scene, a death penalty, + per level, a no-death bonus, finishing the game, the secret level; or + moves counted, or deaths counted, or a percent; a cap. +* *Difficulty:* 0-3, each with a window delay, the judges' presets, and + which hard judges fold into simple ones at the easiest setting. +* *Extras:* per-difficulty scene swaps; clips a setting turns on + (get-ready, after-death, level clear); a preview of the next move; a + prompt sound; the disc's audio language (15.5). + +**Options.** The switches the five loops use today, each as what it means: + +| Today | Option | +|---|---| +| 3.32b's always-seek game over after a continue (`Q.karis332`) | `gameOverAfterContinue = "seek"` or `"playOn"` | +| 3.32b's secret input, and the later map-mode copies' | `secret = { inputs = {...} }` | +| Mazescater's compound kinds, 4 s hints, mash rate 6, any switch counts for a mash | the sequence and circle judges; `hints = 4`; the mash judge's `rate` and `anySwitch` | +| Kimmy's held-input mask | the chord judge (it is exactly "these inputs, nothing else") | +| Kimmy's text arguments for loops, paths, and multis | the circle, path, and count judges' own fields | +| Kimmy's new-game menu, level select, exit menu, tilt, next-move preview, percent game | screens and options above | +| `q.later` (map-mode copies) | `clipEnd = "exact"` or `"atOrPast"`; attract holds as data; a quit input | +| `q.hayate` (a later Kimmy copy) | `extraLifeInTwoPlayer`, `retryAtLevelStart`, `creditCap`, `continueRestartsLevel`, `promptSound` | +| `q.MazeGame` | `order.advance = false` | +| `q.tweaks.*` (per-title, map-mode) | the option each tweak stands for | +| `dip_GameType` (five different meanings across the loops) | split into `lives`, `scoring`, `simplify`, and `order` | +| `dip_PlayStyle`, `dip_Rewind` (numbered differently in two lineages), `dip_Extravid`, `dip_Hints`, `dip_Display`, `dip_ShowAction`, `dip_MashtoRun`, `dip_HoldtoLoop`, `dip_MashRes`, `dip_Tilt`, `dip_Kidmode`, `dip_MustBeatLevel`, `dip_SortStyle` | named settings (15.5, settings), each with the values above | +| the snapshot's `dips` present for `dip_Lang` (14.19) | `languages` and the chosen one | + +**The data.** The description's `qte` table today is a dump of the +original script's globals under the frameworks' names (`dip_*`, +`offsetGameOver`, `LangOpt`, positional move rows, a `settings` table of +every global) with a separate snapshot of levels. It becomes Forge's model: +`clips` (title, attract steps, get-ready, continue, game over, alternate, +high score, rankings, clear, finish, hints, secret), `deaths` (a list of +clips), `levels` (name, intro, mirror, what a failure does, scenes of named +move rows, per-difficulty variants), `scoring`, `lives`, `order`, `secret`, +`board`, `saves`, `languages`, and the options. One source for each: +today level metadata lives twice (`levels` and `settings.Level`), the +constants are copied from `settings` over Author's own table, and a dip can +be in both `settings` and `dips`. + +**Hooks.** What data cannot say stays in the game's own vocabulary as named +hooks the behaviour calls (a move's score, the next level, a death clip), +and 15.6 turns the common ones into data. + +**Drawing.** The loops compute what a player should see -- `bShow*` flags, +the prompt, score, lives, the bar -- and draw none of it. With the model in +Forge's hands, that becomes 15.5's HUD widgets bound to the behaviour's +vars, which is the Picture row of 14.18. + +### 15.5 What Forge needs, for every kind of game + +Each item is a capability some game in the library has, or the engine offers +and Forge does not reach, phrased as a Forge feature. "Needed by" names who +is waiting on it; "engine" means the engine already has it and Forge only +has to reach it. + +**Game structure** +* **Random choice among authored alternatives.** A `choose` field wherever + a value is written: one of these clips, orders, scenes, move rows, or + deaths, drawn once per game, level, scene, or use, from the game's one + seeded stream. Needed by: the seven hosted titles (15.6), the map-mode + titles, the gun games, the time-travel game. +* **Progression as data.** A level's successor as a rule: sequence, + tiers, random, or "the first of these whose condition holds" over what is + beaten, collected, or set; skip beaten; a bonus or secret level on a + condition. Needed by: Space Ace Enhanced, Brain Dead 13, Starship + Troopers, the gun games with outlaw chains, every QTE family. +* **Per-level and per-move overrides.** Any constant (a move's score, a + clip, an extra-life step) overridable on a level or a move. Needed by: + Dragon's Lair Enhanced, Aurunmilla, Ninja Hayate 1080. +* **Counters, quotas, and tallies.** Named counters with a deadline (a turn + limit), countdowns, streaks, and a tally screen with a formula. Needed + by: the text adventure's bomb, the gun games' streaks and crystal clock, + the finish tallies. +* **Settings.** A declared list of the game's options (label, values, + default, what each gates), persisted, with a generic options screen and + restore-defaults; any field of a description can be gated on one. This + is what the frameworks' dips and service menus are. Needed by: every + family. +* **A service mode.** Entered by the service input, drawn by the options + screen, with the pointer as a cursor. Needed by: every family (14.15). +* **Score boards with name entry.** Several boards per game (by difficulty + or mode), extra columns, entry by letter grid, stick, keyboard, or + on-screen keys; shown locally and, with the master service, online + (engine: `scoreBoard`, `scorePlayerName`, `scoreBegin`). Needed by: + every family. +* **Save slots with the game's own vars.** Progress plus any flags or + collectibles, a load/save screen, erase slot, reset best (engine: + `saveDelete`, `saveClear`). Needed by: the QTE families, Brain Dead 13. +* **Two players.** Alternating (one set of controls, turns, per-player + vars) and simultaneous (per-player vars, device routing, twin boards, a + tie-break). Needed by: the QTE families, the gun games. +* **Attract mode and movie mode.** An attract cycle of clips, stills, and + fillers with a timed "press start"; playing a game straight through as a + film. Needed by: every QTE family; kiosks. +* **Languages.** A list of languages with an audio suffix or track each + (engine: `discAudioSuffix`, `discSetAudioTrack`) and per-language text; + chosen in settings, applied at start. Needed by: the Hypseus QTE titles, + the Italian titles, the gun games with Spanish audio. + +**Input and judging** +* **A judge vocabulary for any game**, not only the QTE behaviour: 15.4's + judges as conditions a rule can use over any window, so an action game can + ask for a chord or a mash as easily as a QTE can. +* **Choices and menus over video.** N options on a still or a clip, as + panels, quadrants, or cursor positions, shuffled or fixed, with a timeout + default and skip-if-done. Needed by: Time Gal's time-stop, the TV Show, + the gun games' menus, every map. +* **A map or level select.** Positions, cursor movement rules, completion + state, and a still per level. Needed by: Sucker Punch, Friday the 13th, + Starship Troopers, the time-travel game, Triad. +* **Text input.** A line with editing, any-key waits, typed words judged in + a window, and a `console` look (wrap by font, cursor, fixed width). + Needed by: the text adventure, the typing edition. +* **Gamepads and rumble.** Analog sticks into dx and dy with a dead zone, a + rumble action (engine: `controller*`, `controllerDoRumble`). Needed by: + the Captain Power and Video Driver titles, any action game. +* **An interactive GUI.** Click and change events from the hud document, + show and hide actions, two-way binding (engine: `guiSetHandler`, + `guiGetValue`, `guiShow`, `guiHide`). This is also how the options + screen, name entry, and menus above get drawn. +* **The gun model, completed.** Per-gun ammo, reload by offscreen, border, + or button, bullet on press or release, fire delay, random jam, pickups, + second-chance windows, hit classes (enemy, civilian, bonus) with rules, + shots judged on the next frame. Needed by: the 25 gun-game titles. + +**Picture and sound** +* **HUD widgets bound to vars.** Digits from a sprite font, icons for + lives, a bar, an ammo row, blinking text, a scrolling ticker, gauges, and + an animated move prompt that shows the judge. Needed by: every ported + family (the Picture row); every original game. +* **Disc control.** Speed changes, holding the disc for a delay, several + videos in one game, audio tracks, subtitles, and the picture effects + (engine: `discChangeSpeed`, `discSetAudioTrack`, `srt*`, `vldp*`). + Needed by: the typing edition, Cops, the language titles. +* **Colour reads from the picture.** `pictureAt` reads brightness; a + colour or YUV read and a focus area (engine: `vldpGetPixel`, + `vldpFocusArea`). Needed by: Captain Power, Video Driver. +* **Video outside the disc.** A video look for the overlay or a 3D surface, + and a play-video action that waits (engine: `video*`, + `materialSetVideo`). +* **Sound control and positional audio.** Stop, volume, pause, fade; a + positional sound on an entity (engine: `soundStop`, `soundSetVolume`, + `soundSetNode`, `soundSetRange`, `soundSetListener`, `musicPause`). A + clip cannot be stopped today. +* **Music playlists.** Several tracks switched by state, volume, a + user-editable list. Needed by: Starship Troopers, Gallagher's Gallery, + Space Rocks. +* **Vector drawing.** Line, circle, ellipse, and polygon looks for + overlay-only games (engine: `overlayCircle`, `overlayEllipse`, + `overlayPlot`). Needed by: Space Rocks, Minesweeper. +* **Tweens.** A tween action over any field or var with an easing curve + and a duration, that can wait (engine: `tweenValue`, `EASE_*`). +* **Particles with an appearance.** Texture, frames, blend, trail, + collision, spin, direction, and drag, plus start and stop (engine: + `emitterSet*`). Today they are coloured dots. + +**3D and physics** (engine features Forge does not reach; no ported game +needs them, every original 3D game would): materials (normal, emissive, +tiling, blend, double-sided) and a set-material action; labels over 3D +entities (`sceneProject`); bloom, tonemap, environment, antialias, and +shadow quality on the scene layer; several views (split screen, mirrors); +animation layers with masks and weights, pause, and morphs; terrain from a +heightmap; wander and line-of-sight for AI (`navRandomPoint`, +`navRaycast`); velocity, enable, conveyors, hinge limits, pinned cloth, +swim and slope on the character, gears and brakes on the vehicle. + +**MIDI.** A MIDI layer that opens the input port (the `midi` event needs +Lua to open it today) and a note action for output. + +**The editor.** A canvas at the game's own size (ActionMax's is 360x240); +play from here with the editor camera; orbiting by pointer; navmesh baking +as an editor action; the chooser drawn by the RmlUi document. All were +planned (sections 1-3) and not built. + +### 15.6 The titles that still need their own Lua, and what absorbs it + +* **Dragon's Lair Enhanced:** per-level constants (overrides), one of three + fixed orders drawn at random (random choice, progression), a bonus for 24 + named moves (per-move overrides). +* **Space Ace Enhanced:** a random game-over clip and extra death clip + (random choice), the next level by what is beaten, the difficulty, and a + coin flip (progression), move rows gated on difficulty and beaten levels + (settings gating, progression conditions). +* **Aurunmilla:** one draw per game picking some moves' death clips + (random choice, drawn once per game), per-level move score (overrides). +* **Starship Troopers:** a real map with skip-beaten and a bonus stage + after four (map, progression), map music and two tracks (playlists). +* **Brain Dead 13:** seven collectible flags set by moves and saved per + slot, which pick an alternate final level (vars on moves, save slots with + the game's vars, progression); random death clips by range and an extra + death by level (random choice, overrides); a random alternate scene. +* **Ninja Hayate 1080:** a random finishing bonus by difficulty, settings + that force or gate other settings, extra clips gated on a setting (random + choice, settings gating). +* **Sintel:** no logic of its own (empty add-ons, 83 plain move rows). Why + it needs its Lua is undiagnosed; the likely cause is a value the + converter does not lift, not a mechanic. First to look at. +* **The map-mode titles** run their `main.singe` data functions every play + because their moves are drawn at random (random choice over move rows), + and their per-death clip mappings are 2,000 lines of code (deaths as + data). +* **The gun games** (26 vocabularies, 54,627 lines): the gun model, hit + classes, menus over video, counters, money instead of lives, puzzles as + choices, two players. The puzzles (roulette, cards, a safe dial) are the + last mile, and each is a game's own vocabulary under 15.1's rule 4. +* **The text adventure:** text input and the console look; its parser's + ordered rules that may re-enter are the one thing Forge's parser layer + lacks (14.14). +* **The time-travel game:** shuffled periods (random choice), deaths by + cause (deaths as data), reversal to a mark (disc control), a map, a trader + and a slot machine (menus, counters). + +The titles not ported at all (Future Boy, Road Blaster, Cracke, Triad, the +Singe 1 monoliths, and the overlay programs such as Cops, Fast Draw, +Gallagher's Gallery, Captain Power, Video Driver, Minesweeper, and Space +Rocks) need the same list plus gamepads, rumble, colour reads, vector +drawing, and speed changes. + +### 15.7 Found while surveying, checked + +* **A game's colour calls do nothing.** The shim's helper table defines + `colorForeground` twice (Author.singe 7562 and 7656); in a table + constructor the later wins, so the no-op replaces the real one. A defect. +* **Publish cannot be reached from the keyboard.** Forge's `onKeyPressed` + sends P to `forgePlay` whenever no field or picker is open (Forge.singe + 5096), so `forgeKey`'s P for `forgePublish` (4165) never runs. The book + says both. A defect. +* **The loops never clear what a game draws.** A game's own Lua draws + through `Q.queueDraw`; only the game vocabularies' looks call + `authorFlushDraws` or `authorDropDraws`, which Author.singe defines and + never calls, so under the five loops the queue grows by every draw call a + game's Lua makes, every frame. A defect, and the reason nothing a game + draws is seen. +* **The book is stale about the light sensor:** it says the sensor lives in + the ActionMax games' own vocabulary, not Forge's; it is core now. +* **Quirks copied faithfully, not defects:** the Kimmy loop's hold decay + can never run (its test is always true), the map-mode Super Don Quixote + loop plays a one-frame get-ready clip on one path, and a choice menu + handles only two to four options. Each was checked against the original + framework, which does the same; a port must, to agree. In Forge's own + model they become the judges' presets and are free to be right. +* **Checked and not true:** that Kimmy's scorer takes its arguments in a + different order from the KarisFramework loop's. + +### 15.8 Order of work, and how each step is proved + +The measure for every step that touches the loops is the port sweep: every +title that agrees before the step agrees after it, on the same binary. The +baseline is the sweep run on 2026-09-26 after 14.19 (recorded there when it +finishes). A step that changes nothing a player sees must change no trace. + +1. **The three defects in 15.7.** Independent of the rest. +2. **Names only.** The retirements of 15.3 inside the runtime, the help, + the book, and the vocabulary files; `kind` values in descriptions + regenerated by the converter. No behaviour changes; the sweep must not + move. +3. **Versions to options.** Every check of a framework, version, or title + becomes an option the converter writes (15.4's table). The sweep must + not move. +4. **One loop.** Fold the loops into the one `qte` behaviour a piece at a + time, starting with the screens that are near-copies in all five + (continue, game over, high score, difficulty select, level setup, scene + done, death clip setup), then the judges, then the level loop. The + sweep after each piece. +5. **Forge's data model.** The converter writes 15.4's model instead of the + globals dump; the loop reads it. The sweep. +6. **Generic features**, in the order that unblocks the most: languages, + random choice and progression and overrides (these retire most of the + hosted Lua), settings and the service mode, HUD widgets (the Picture + row), boards with name entry, save slots, the judge vocabulary for any + game, menus and maps, then the rest of 15.5. Each with a test scene and + a Forge tutorial or recipe, since a feature only ports can reach is not + generic. +7. **Retire the hosted Lua**, title by title (15.6), each proved by the + sweep and by `util/forgeStandalone.py`. +8. **The converter renamed and its output checked** for any third-party + name, which becomes a check in the sweep. + +### Out of scope, stated so it is not assumed + +Binary compatibility with the frameworks' own save and config files. +Keeping `kind = "kimmy"` and the other retired names working in old +descriptions (the converter regenerates them, and section 3's rule stands: +nothing has to be backward compatible). The completeness rows of 14.18 +other than State, until the features in 15.5 that they need exist. + +### 15.9 Steps 1 to 3, done (2026-09-27) + +**Step 1, the defects** (15.7): a game's colour calls, Publish (now Shift+P; +P stays Play), and the draw queue the five loops never emptied. Done the day +before, with scene 56 covering the export. + +**Step 2, names.** The four framework-named behaviours are `qteHeld` (was +`kimmy`), `qteSegments` (`rdg`), `qteCheckpoints` (`timegal`), and +`qteTrophies` (`sdq`) until step 4 folds them into `qte`; the constants +table `KIMMY` and its kin are `HELD`, `HELD_MASK`, `HELD_BIT`, `HELD_WORD`, +and `HELD_SIDE`; the trophy loop's helpers are `trophyWrong`, `trophyPath`, +`trophyAfterDeath`, and `trophyStartScene`; `RDG_PAIRS` is `PAIR_SWITCHES`; +the runtime's seed global is `AUTHOR_SEED` (the test driver sets it). Every +help string, section header, and comment in Author.singe now says what a +thing does rather than whose it was, the Forge book's paragraph on the ports +does the same (and no longer says the light sensor is not Forge's), and the +converter's header lines name no framework. The 18 descriptions that used +the old kinds were changed in place, `kind` strings only. What is left in +the descriptions is credits (an original author named as one) and players' +initials on high-score boards, which are data. + +**Step 3, versions to options.** `Q.karis332` and `Q.mazescater` are gone, +and nothing reads `qte.framework`. In their place `Q.option(q, name, +default)` reads the description's `qte.options`: `gameOverAfterContinue` +("playOn" or "seek"), `secretInputs` (the switches held for the hidden level), +`hintSeconds`, `alternateMashRate`, `mashAnySwitch`, and `doubleKind`. The +defaults are the behaviour a description without the option always had. The +converter writes the options from what it detects and no longer writes the +version; the existing descriptions were migrated (11 got the seek-and-secret +pair, one the compound-move four, the rest lost a line nothing read), and a +converter run on three of them reproduces them. + +Proved by comparison on the frozen binary: one title of each behaviour and a +gun game, capped at 15,000 frames, after each step, and Linea (the compound +options) and Arcade Xperience 2 (the seeking game over) uncapped after step 3; +all agree. The behaviour flags `q.kimmy`, `q.rdg`, `q.timegal`, and `q.sdq` +remain: they say which loop is running, and step 4 retires them with the +loops. + +**Found on the way: test runs had written to five titles' saved boards.** +Aurunmilla, badlands, Survival, and Mazinga had a player called MOR in their +`hscore.cfg` that the originals' zips do not have (Sugar Rush's zip does, so +there it is real), and the descriptions of those four had been generated from +the written boards. The boards are back to the zips' (the written ones are in +`~/claude/portwork/polluted-boards`), the four descriptions were regenerated +from them, and all four agree uncapped. Aurunmilla's converter output also +changes from run to run, because its script draws `math.random(2)` as it +loads; its description is whatever the last run drew, and it agrees. + +### 15.10 Step 4, one loop: the styles and the shared screens (2026-09-27) + +**4a, one behaviour.** The four named behaviours are gone: `qte` is the +only one, and `qte.options.style` picks how it plays: `windows` (the +default, moves answered in a window of frames), `held` (moves judged by what +is held), `segments`, `checkpoints`, or `trophies`. `QTE_STYLES` holds the +five, each with its help and its attach; an unknown style is an error that +names the ones there are. `q.qteStyle` replaced the four behaviour flags. + +**4b, the shared screens.** The held style kept its own copy of most of the +windows style's screens. Each pair is now one function, and every real +difference between them became an option in `qte.options` with the style's +own default, so a description can ask for either behaviour whatever its +style: + +| Option | What it does | Held default | +|---|---|---| +| `gameOverAfterContinue` | `"playOn"` from the continue clip when it adjoins, or `"seek"` | `"seek"` | +| `rankingsAfterBoard` | the same choice for the rankings after the high score board | `"seek"` | +| `levelSelect` | game types 2 and 4, and play style 4, choose their level | on | +| `deathCountGameType` | the game type that counts deaths: no life lost, no points, fewest ranked, the total kept across levels | 4 | +| `noGameOverGameType` | the game type that goes from the board back to the attract | 2 | +| `saveIcon` | a disk shows where the game would save | on | +| `managedHud` | the loop sets the score, lives, level, and record by the display setting through play, the get-ready, and a death, with a pause on the hints frame after a death on display 2 | on | +| `barBonusRestarts` | the count to the life bar's bonus starts over at the bonus even with the bar full | on | +| `hintedDeathCounts` | a death after the hints counts as the wrong move it was (the scene bonus reads the count) | on | +| `pendingIsUnanswered` | a move still pending as its window closes counts as unanswered | on | +| `wayOutPlaysTarget` | a way out plays the scene it names, done or not; off, it goes there if still to do and past it if done | on | +| `extraDeathClipSkips` | BUTTON1 cuts the extra clip after a death short | on | +| `waitForWindowStart` | a window that ends before it starts waits for its start, instead of closing at its end | on | +| `outEndsSceneNow` | the out jump ends the scene in the same step, instead of the next | on | + +`Q.option` reads the description's options, then the style's defaults +(`q.optionDefaults`), then the caller's. Where a shared screen must call the +style's own start or frame setup, it goes through `q.qteFns`: `Q` for the +windows style, and for the held style a table that gives its own function +where it has one and the shared one otherwise. + +**4c, the level loop.** The two level loops are one, `Q.doLevel`. What +is each style's own comes through `q.qteFns`: the judge (`Q.judge`, or the +held style's move loop, now `K.judge`), the verdict on a wrong move +(`Q.failed` or `K.failed`), and the resets of the judging state at a scene's +start and after a right move. Tilt (`Q.checkTilt`), the next-move marker, +two players' turns, and the scene and level jumps need no option: they act +only on dips, settings, and move kinds the held descriptions have, and read +through `q`, where the windows style has none of them. A move whose death +is -3 passes as -1 does in both: no windows description has one. Two more +differences surfaced only when the traces were compared, and are options: a +window that ends before it starts (a windows title has one) closes at its end +in the windows style and waits for its start in the held one +(`waitForWindowStart`), and the out jump ends the scene in the same step in +the held style and the next in the windows one (`outEndsSceneNow`). A scan +of the descriptions had said no held title used the out jump; the held +titles' paths are built by their hosted Lua, where a scan of the data cannot +see, so the traces are the check, not the scan. + +Merged in 4b: enter scene, scene done, continue, game over, difficulty select, +death clip setup, after death (with the resume and the scene-end test it +used), the skip and let-go judges, level setup, next level, on to the next +level, level replay, the high score board, the scorer, the board test, the +start of a game, and the record shown (`Q.topFor`). The held style's local +copies of the lives setup and the level-select jump are shared as +`Q.livesForType` and `Q.toLevelSelect`. About 550 lines are gone. + +Two held names were a game's name and are not any more: `q.hayate` is +`q.arcadeEdition` (the later engine's arcade mode, from its settings), and the +comment that named the title says what the edition is. One latent fault went +with the merge: the held attach put its scorer on `q`, where a game's own Lua +calling `addPoints(points, value)` would have reached it without `q`; no +description did, and the shim's helper now answers it for every style. + +Where the two copies differed in a way no option should keep, the shared one +takes the more capable form, each checked against the library first: the +game's `swapDeath` hook now works in every style (the windows copy ignored +what it chose; no description defines it); `specialScore` sets the points +in every style (the held copy wiped them; none defines it); the end-of-clip +tests are `>=` everywhere (the frame is read after the seek); the right and +wrong move counts start over with every new game (the windows copy skipped +game type 3, where nothing reads them); a level replay setting below -1 +draws the order again as the held copy did (no description has one). + +What is left of the held style is its own: the judges that test the mask of +what is held, its move loop, level loop, attract, saves, new-game menu, level +select, clear, finish (the percent made, where the windows finish counts a +bonus down), and quit menu. The judges and the level loop are the next +pieces. + +Proved by comparison on the frozen binary, to 20,000 frames: + +- 4a and 4b: the full port sweep (81 pairs) after the screen merges; 74 + agree and none differ. The other seven were the segmented titles in the + library's own layout, which `sweep.sh` had sent to the rdg family (it knows + only the Hypseus layout, so the runs found no video); `sweep.sh` now sends + them to the karis driver, and they are rerun below. One held default was + missing from the swept code (`barBonusRestarts`); no held title plays game + type 1, the only one it touches, so no trace could show it, and it is in. +- 4c: run from a separate tree with its own copy of the library's small + files, while the sweep held `forge/`: all seven held titles, and windows + titles with way-outs, out jumps, death -2 moves, an inverted window, game + types 1 and 2, and the sought game over (20 pairs); all agree. The first + pass had two differ, which found `waitForWindowStart` and + `outEndsSceneNow`. +- The seven segmented titles, rerun under the karis driver on the code with + 4c in: all agree. +- The full sweep on the code with 4c in (81 pairs): 80 agree and none + differ. The 81st, the library's plain Crime Patrol, agrees for the 321 + trace lines both runs reach, and then neither goes on: the original sits + there until the hour's limit stops it, and the port spun at full CPU, did + not quit on the SIGTERM the limit sends, and ran 16 hours before it was + killed. It was the open item of 14.19, and it is closed: the original + loops there too. The game's own script picks the rookie's next nag + (getRookieNags, with the Undertaker dip on) by drawing 1 to 5 until it finds + one not yet used, and starts over only when 1 to 6 are all used; nothing + sets the sixth, so the sixth time through it draws forever. The original's + performance line shows a 275-second frame. The port runs the same code and + hangs at the same place, which is what "plays as the original" asks. Four + of the library's five copies of the game have it (the first-generation one + draws differently). The engine's new script watchdog stops both runs cleanly + on a SIGTERM and logs where they were; the old binary had ended the + original only because timeout's two SIGTERMs arrived as two. `compare.sh` now kills a run 30 seconds + after the SIGTERM (`timeout -k`), so one hung title cannot stall a sweep; + the engine only sets a flag on SIGTERM, which a script that never returns + never lets it read. + +**4d, the segmented lineage's screens.** The segments style (and the +trophies style, which builds on it) had its own game over and continue. Both +fold into the shared ones. The lineage gets its own `q.qteFns` (`SEG_FNS`: +its start, the board test through its game's own Lua, and a step back over +segments rather than scenes), and its defaults: + +| Option | What it does | Default for the lineage | +|---|---|---| +| `clipEndExact` | a clip ends exactly at its last frame, not at or past it (`Q.clipEnded`, which replaces the lineage's own test) | on for the first copies and trophies, off for the later copies | +| `diffChoiceAfterGameOver` | the next game offers the difficulty again after a game over, even when a continue turned it off | off | +| `continueDeclines` | BUTTON2 turns the continue down | off | + +`gameOverAfterContinue` is "seek" for the lineage, as the held style has +it. Checked on the frozen binary to 20,000 frames after each of the two +merges: the six segments titles in the library's own layout, the trophies +title, the three segments titles and the checkpoints one from the Hypseus +library, and TimeGal, FBC, and Astroboy of the others; eleven of the +fourteen reach a game over three times and twelve a continue, so the screens +that changed were played. After the game over: all fourteen agree. After +the continue: all fourteen agree. + +**4e, the judges.** The trophies style's let-go and mash judges were the +shared ones reading its constants from `q` (the same numbers); they are +merged, with `mashClearsButton` (on; off for trophies: the press that ends +a mash is not used up). The rest are not copies: the windows judge answers +presses in a window of frames, the held judge tests a mask of what is held, +and the segmented lineage numbers its inputs its own way (its skip accepts only +the play switches, and its first copies a third button of their own), so each +style keeps its own judge, reached through `q.qteFns`. The three segmented +level loops (segments, checkpoints, trophies) are a different machine from the +shared one (a fifth to a quarter alike), not copies, and fold only when step 5 +gives every style one data model to play. Checked: the trophies title and +TimeGal, Tron, and Danmachi of the windows style agree. + +**Names out of Forge's comments.** A scan of Author.singe against every +title in the library found game names in nine comments (and the old +behaviour names in two); all are reworded to say what the thing does. The +trophies style's level order was kept under a data key named for a game; it +is `trophyOrder` in the runtime, the converter, and the one description that +has it, which agrees on a rerun. + +### 15.11 Step 5, Forge's data model: one source for each (2026-09-28) + +The first slice of step 5 is the one 15.4 names last: a fact written in one +place. Measured first: across the 49 QTE descriptions, 467 dips were in both +`settings` and `dips` (the held titles disagreed on up to 15 of them; the +runtime lays `dips` over `settings`, so the other copy was dead data that +looked meaningful), and 38 descriptions carried their levels twice, as +`settings.Level` rows and as `levels`. + +- **Reproducible conversion first.** The converter now seeds Lua's random + generator (`CONVERT_SEED`), so a game that draws as it loads (a scene swap, + a level's variant) is converted the same every time. Before the change, + every description was regenerated and compared with the library's as data: + 46 of 49 identical; BD13 and Esh Aurunmilla differed by their random + draws, and hayate 1080 by a high-score board the description had taken from + a test run's polluted board (restored from the zip since, but the + description had kept the old score). Those three were replaced by the + seeded converter's output, and all three agree. +- **Levels in one place.** `levels` is the only home of a level's fields, + every row the game declares included (a row past the last stage, a secret + row a game cannot reach), and the eighth column the held style reads (the + level select's still) is `selectStill`. The runtime builds the loops' + positional rows from `levels` (`Q.applySnapshot`, which both attaches now + share, with `QTE.SELECTSTILL` named); `settings.Level` is gone. A check + over all 49 found the rows built from the new `levels` equal to the old + `settings.Level` row for row. +- **Dips in one place.** A setting `dips` also has is left out of + `settings`. Every removal was checked to be one `dips` carries. + +Found on the way: the engine wrote a log line's text and its newline in two +calls, so a line from another thread (the video thread's) could land inside +it, which made one comparison's trace look different; a line is now written +whole. + +Checked: Astroboy and Arcade Xperience 2 (added rows), FBC (the level +select's still), the trophies title, Jeeg, and TimeGal agree. The full +sweep (81 pairs, to 20,000 frames, on the frozen binary): all 81 agree, +Crime Patrol for the 321 trace lines both halves reach before the game's own +nag loop (15.10); its port half was ended at 33 minutes, the result being +known. + +**Forge's names for the data (prepared, to go in after the sweep above).** +Three more slices, each proved before any run by the runtime's own view: +`QTE_CLIPS`, `QTE_FIELDS`, the constant tables, and `Q.applySnapshot` taken +from Author.singe, each old and new description laid down on an empty state +as its style's attach lays it, and the two states compared field by field. + +- **Clips and stills.** The game's `offsetX`/`offsetXend` globals are + `clips` under Forge's names (`title`, `gameIntro`, `getReady`, + `resurrect`, `extraDeath`, `continue`, `gameOver`, `gameOverAlt`, + `clear`, `newHighScore`, `enterName`, `rankings`, `quit`, and `attract`, + a list of the attract steps); its `frameX` globals are `stills` (`hints`, + `rankings`, the difficulty stills, and the rest, each named by what + follows "frame"). The names are written once, in Author.singe's + `QTE_CLIPS`; the converter reads that table from there. +- **Named fields.** `scoring` (move, perDifficulty, scene, deathPenalty, + level, noDeathBonus, game, secret, extraLifeEvery), `lifeBar` (size, + missCost, regainAfter), `windowDelay` (normal, hard, extreme), `order` + (play, levels, tiers, mapStart), `deaths` (clips, and randomCount: how + many of them a random death draws from, which in 16 descriptions is not + all of them), `secret` (allowed), `video` (fps, relativeFrames); + Author.singe's `QTE_FIELDS` maps each to the global the loops read. +- **No copies of constants.** A setting equal to the constant the runtime + lays down first (the QTE table, and HELD over it for a held game) is left + out; and the segmented lineage's descriptions carry no settings at all, + since its runtime reads none (its game's own Lua declares them). + +The 38 windows and held descriptions give the runtime the same state after +each slice; the 11 segmented ones differ only by the settings their runtime +never reads. The settings across the 49 went from 26,289 keys to 9,806. + +## 16. TimeGal, complete (started 2026-09-28) + +The owner, on a week of step 4 and 5 work: "It just seems like we're +spending a lot of time and not really getting much closer to actual, +complete, ports of anything." Fair: every sweep re-proved the State row +of 14.18 and nothing moved the other six. So one title goes through every +row, each with a check, before anything general: TimeGal (the windows +style, plays with none of its game's Lua, State agreeing). + +### 16.1 Picture + +**The checks.** `karisDrive.singe` gains `PORT_SHOTS=N` (a numbered +screenshot at each change the trace prints and one a frame into each +move's window; the traces agree, so a number is the same moment in both) +and `PORT_HUD=1` (the HUD's flags as letters on each trace line: S score, +L lives, V level, T record, C credits, A action, K skip, G get-ready, H +choices, D LCD, P pause, 1-4 disks). `testScripts/ports/shotDiff.py` pairs +the screenshots by number and reports how much of each differs, with a side +by side and the differing pixels marked for a person to look at; +`compare.sh` now gathers screenshots from wherever the engine saves them. + +**What the checks found.** The port drew nothing of the game: no score, no +lives, no record, and no time-stop menu, so a player could not see what the +choice was between (the trace agreed because the drive does not look). And +the port's HUD flags differed from the original's almost everywhere: the +windows loop set them by a simpler rule than every framework copy does. + +**What changed.** +- The HUD is a look, `qteHud` (14.15's design), drawing from the loop's + state what the flags say, in the games' order, from the game's own + pictures (`Overlay/`, `Overlay/Lores/` at the lower resolution) and font, + laid out as the games lay it out: the score in the digit pictures, the + record, the lives or the life bar, the credits (FREE PLAY, INSERT COIN, or + the count, blinking), the level name, get-ready, skip, the choice menu, + pause, and the move prompt (a direction's arrow sliding out from the + middle, a button's picture, or the hint pictures by `dip_ShowAction`). + It draws nothing until the game runs, as the games draw nothing during + the title clip. It changes no state and draws no random number: where + the games' drawing had a side effect (the level name clearing once play + passes the first move), the loop does it, at the end of its step. The + converter gives a qte game this look. +- The windows style's `managedHud` default is on: all 21 framework copies + that could be found set the HUD by the display setting. +- The windows attach turns `ShowTop`, `ShowLCD`, and `ShowLevel` from 1 and + 0 into true and false, as the games' config reader does (a 0 is true in + Lua, so a switched-off record would have shown), and the start sets the + record's flag as the games' `readConfig` does. +- Forge's own font file is named once (`AUTHOR_FONT_FILE`). + +**Where it stands** (the frozen binary, the scripted run to 20,000 frames: +the attract loop, play, the prompts, the time-stop menu, get-ready, deaths, +five continues, three game overs): the trace with the HUD flags agrees on +all 226 lines; 247 of 250 screenshots are identical to the original's. +The other 3 are the FREE PLAY blink: the games blink by CPU time, the +port by the engine's clock, from when a picture appears, so after minutes +of play the phases part. Not yet reached, so not yet checked: the high +score entry, the level clear and finish screens, pause, and the service +screens (16.2). + +### 16.2 Sound + +`PORT_SOUND=1` puts each sound played or stopped (by its file's name) and +each change to the disc's audio on the trace line, the engine's calls +wrapped before the game loads so the original's and the port's are caught +the same way. The run of 16.1 plays 31 sounds (one coin, twenty right, +ten wrong), the same files at the same frames in both; the perfect run +below plays every sound it reaches the same. Not reached, so not checked: +the victory and clear sounds of a run that finishes badly. + +### 16.3 Screens and persistence + +**The drives.** `PORT_WRONG_EVERY` and `PORT_MISS_EVERY` set high make a +perfect run: every level, the level clears, the finish, the high score +screen, the game over. `PORT_ATTRACT=N` lets the attract loop run N frames +before the coin and START, so its rankings and best-percent screens play. +`PORT_SERVICE=1` walks the three service screens from the attract loop: +the options' first row on to the graphics screen, every row changed once and +"Default", on to the performance screen, the same, on to the options, the +same, and EXIT; the trace carries each screen's row and every setting. +`compare.sh` gains `PORT_TWICE=1`, each side run twice in the same data +directory (the original's own files put back only after both), so the +second run shows what the first saved. + +**What was missing, and is now there.** +- *Pause:* TimeGal pauses only through its title clip, where no HUD draws; + nothing to build. +- *The autosave disk* after a level clear, two seconds, as the games time + it (on the shared timer and altState, from the frame after it is set); + `saveIcon` is on for the windows style too. +- *The boards.* The description carries all three (`boards.normal`, + `lifeBar`, `survivor`) and the best percent of each level + (`boards.percent`), from the game's hscore.cfg; the record shown and the + board a result goes on are the game type's; a result goes on above the + first entry it equals or beats, and a name left empty is drawn from the + games' ten names at random (the same draw, so what follows stays in step). + The boards are kept through the engine's save store and read at attach. +- *Name entry:* the 44-cell grid, CLR and END, the joystick's quarter + second, the sounds, drawn with the cursor blinking; `nameEntry` is on for + the windows style and off for the held one until its framework is read. +- *The board screens:* the game type's board after a result, the three + boards side by side on the attract loop's rankings, and the best percent. +- *The service screens:* the options (game type, difficulty, play style, + start level and scene, rewind, coins, lives, continues, prompts, hints, + mash), the graphics screen (extra clips, titles, LCD, level, record, + where the score, lives, and coins go, the font, its size, the two + colours), and the performance screen (the machine model and what it sets, + resolution, display, scanlines, score and lives drawn or rendered, font + quality), one loop and one printer driven by a table per screen, the + values changed by the games' own rules (the ties between them, and the + asymmetries: hints the kid's type keeps going on, a colour stepping past + the other to 13, the third model's resolution set differently each way). + "Default" restores the game's `default.cfg`, which the description now + carries as `defaults`; leaving writes the settings (`Q.configSave`). +- *The start* (`Q.initJob`): what the games' `initJob` does again after the + service screens, which the port had done only once: the attract loop from + its first state, the counters cleared, the HUD back to the credits, the + extra clips, language, and record read from the settings, and the overlay + laid out again (`Q.applyResolution`, which also says whether the + resolution has scanlines, sticky once found, as the games' flag is). +- The windows switch handler opens and closes the service screens on + SERVICE and takes no coins while they show. + +**Where it stands** (the frozen binary): +- The perfect run (60,000 frames): 813 trace lines agree (state, HUD flags, + sounds); 905 of 909 screenshots identical, the other four a blink phase + (FREE PLAY three times, the name cursor once). +- The attract loop run twice through (20,000 frames): 41 lines agree; 21 of + 25 screenshots identical, the other four FREE PLAY. +- The service walk and the play after it (9,000 frames): 259 lines agree; + 185 of 187 screenshots identical, the other two FREE PLAY. +- Persistence, the board: each side run twice (80,000 frames each, the + attract loop, then a perfect run): 1,679 lines agree, and the second run's + rankings show the first run's score in both; every screenshot pair that + differs is a blink. + +**Not yet:** the save-slot screen, the exit menu, SERVICE from play (the +games autosave and come back to the level; the port starts a new game), +the in-game difficulty screen, the LCD ticker of the attract loop (off for +TimeGal), and a check that the settings survive a restart (running). The +"Show Moves" setting has a value the games print as "Cliff" (a prompt in +that game's style), which names a game in Forge's code; the owner decides. + +## 17. Forge can reproduce every game (proposed 2026-09-28) + +**The goal changes.** Sections 14 and 16 set out to make each port play +exactly as its original, checked picture by picture. That meant writing +each framework's behaviour into Forge again, one version at a time, and +every check found another corner (16.1 to 16.3). It does not converge, and +it is the opposite of Forge as its own thing (15.1). The goal is instead: +**anything a game in the library does, a developer can do in Forge**, so a +developer on another framework has no reason not to use Forge. A converted +title is the evidence that Forge can reproduce it, not a replica to +maintain. + +**What "can reproduce" means.** For each thing a game in the library does +(a judge, a screen, a HUD element, a setting, a kind of persistence, a +control), Forge has a capability that does it, described in the vocabulary +and the manual, with its behaviour set by data and options rather than by +which framework it came from. The look is the developer's: pictures, +positions, fonts, and colours are data on HUD widgets and screens (15.5), +so a developer can make it look like their game did, but Forge ships no +copy of anyone's layout code. + +**The ledger.** One table, generated from the library and not written by +hand: every capability the library's games use, which titles use it, and +its state in Forge (built, partial, missing). The converter produces it: +as it maps a title onto Forge's model (15.4) it names each thing it maps +and each thing it cannot, and the ledger is the union. Today's move kinds +alone: press, chord, diagonal, action-plus-direction, mash (one input, +two, run), hold (four directions and a button), letGo, double, loop, +multi/count, choose, path, yesNo, timed, way, wayOut, skip, and the compound +kinds of 3.31c (Linea); most titles use a dozen or more of them. + +**The checks, per capability, not per title.** +1. *It exists:* a Forge test scene exercises it (as scenes 52 to 57 do + now), and the vocabulary and manual describe it. +2. *It is enough:* at least one converted title that uses it plays the + same state trace as its original (the State row of 14.18, which stays; + it checks the data and the judging, not the picture). +3. *It is reachable:* a developer can author it from the editor or a + description without Lua. + +A title is **reproducible** when the converter maps everything it uses, +nothing unmapped, and its state trace agrees. Picture, Screens, and Sound +(14.18) become "every element the title shows or plays is a capability in +the ledger", not pixel equality. + +**What happens to the current code.** The five loops' styles (windows, +held, segments, checkpoints, trophies) become option sets over one model, +as 15.4 already planned; what was written to match one framework's +pixels (the service screen's exact rows, blink phases, the copied default +font, per-framework layout constants) is retired or turned into widget and +screen data. The switches, saves, pause, and prompt work of 16.3 stays as +capabilities (save slots, quick save, exit menu, pause during the title). + +**Order of work.** +1. The converter reports what it maps and what it cannot, per title; the + ledger is generated from that over the whole library. +2. The ledger is sorted by how many titles each missing or partial + capability blocks; work goes down that list, each capability through + the three checks above. +3. Each style's framework-specific code is replaced by the generic + capability as its turn comes, and removed when no title needs it. +4. Progress is reported as the ledger: capabilities built, titles + reproducible, and what blocks the rest. + +### 17.1 Step 1, the ledger (2026-09-28) + +`util/forgePortKaris.lua` with `FORGE_LEDGER=<file>` writes, instead of the +description, one line for each thing it maps: **forge** (a field of Forge's +model), **framework** (played under the framework's names or numbers, or a +style), or **lua** (the game's own Lua still does it). `util/forgeLedger.py` +runs it over every description the converter made and writes +`testScripts/ports/ledger.md`. It takes about a second and plays nothing. +Hooks in the add-ons file most titles ship unchanged (18 copies) count as +framework; `swapScene` counts as forge, since the converter runs it into +`scene.swaps`. + +**What it shows (76 titles):** +* *In Forge's model already*, for the 38 windows and held titles: the + screens and stills, scoring, the life bar, window delays, level order, + deaths, boards, saves, and scene swaps. The other 38 have almost nothing + in the model: their mechanics are Lua. +* *Lua:* 25 gun games, the time-travel game, and the text adventure play + through a hand-written `Vocabulary.singe`; 11 segmented titles run their + own level functions; 12 titles have add-on hooks of their own + (`startConf` and `swapLevel` 7, `doLevelSelect` and `moveCursor` 6, + `swapDeath` 5, `specialScore` 4, `loadSavePlus` and `writeSavePlus` 3, + and six of Chantze's Stone's own). +* *Framework:* 21 judges, every move row still in the framework's + numbering (Linea and Arcade Xperience 2 use compound kinds Forge has no + name for, named in their frameworks like sequences: UL, DRU, LDB); 69 + settings under `dip_` names; 455 globals nothing names, most of them the + framework's own state flags and picture numbers rather than game data. + +**What it does not cover yet:** the five ActionMax titles (their converter +is `util/forgePortActionMax.py`); the gun games' and segmented titles' +mechanics are one "vocabulary" or "levelFunctions" line each, not itemized; +Picture and Sound capabilities (prompts, gauges, HUD elements) are not in +the ledger; "runs without its Lua" is `standalone.txt` as proved on +2026-09-24, before today's regeneration. + +**Order of work, by titles blocked:** +1. The gun model (15.5): 25 titles. First itemize what the vocabularies + do, so the ledger lists the gun capabilities rather than one line. +2. The segmented lineage's level functions as data: 11 titles. +3. Move rows under Forge's judge names instead of framework numbers: 38 + titles, mostly converter work; the compound kinds get a sequence judge. +4. Settings (15.5): the 69 `dip_` names as declared, named settings: 75 + titles. +5. The globals: drop what is the framework's own state (checked by the + State trace), name what is data: 38 titles. +6. The remaining hooks, most-used first. + +**The text adventure (Rollercoaster).** Not a priority of its own (owner, +2026-09-28), but it fits the Sierra model of section 6: its rooms, exits, +disc stills and clips, two-word parser, objects, furniture, room scenes, and +the bomb's turn count are rooms, `said` rules, the inventory, sequences, a +counter, and `die`. Two capabilities are missing, and both belong to the +Sierra lineage generally: a console look (a scrolling text window with +wrapping and a cursor, 15.5) and a question a typed answer satisfies (the +name, the prize, "put it where?"). With those, it becomes rooms and rules +instead of a vocabulary; until then it stays as it is. + +### 17.2 What each type of game needs (surveyed 2026-09-28) + +Four read-only surveys: the 2D gun games (the 18 distinct vocabularies), +the QTE lineages still in Lua with ActionMax and the time-travel game, 2D +and 3D platformers, and 3D light-gun games. The last two have no titles in +the library, so they are measured against the genres. Each item was checked +against `forge/Author.singe` and `docs/ForgeVocabulary.adoc`; "unchecked" +marks what no scene proves. + +**Wrong today (checked in the code):** +* A 3D shot passes through anything that is not a target, up to four + bodies (`fire`, Author.singe ~4524): walls and cover never stop a shot. + Meant for the player's own body and glass; should skip only those, and + triggers, and a `seeThrough` flag. +* A 3D target's zones: the last zone within reach wins, not the nearest. +* The QTE loop never calls `writeSavePlus`, so collectibles saved per slot + (DL2e, BD13) are not kept (reported, unchecked at play). +* ActionMax's sensor is in every room, so the gunshot sounds on the title, + menu, and game-over screens; the original sounds it in play only + (reported, unchecked at play). A converter fix. +* The QTE's held, segments, checkpoints, and trophies titles draw no HUD + or prompts at all (`look = "none"`); `vars.prompt` is a framework kind + number, not a judge name, so a custom HUD cannot bind to it. +* The ledger counted empty add-on hooks as Lua (Sintel, BD13); it now + reads each hook's body. + +**Shared by several types (the most leverage):** + +| Capability | Needed by | Forge today | +|---|---|---| +| Random choice among authored alternatives (clips, rows, orders, segments, deaths), drawn per game, level, or use, from the seeded stream | QTE (SAe, DLe, hayate, BD13, starblazers, SDQ, hq-timegal), all 24 gun games, the time-travel game | `chance`, `spawner pick=random` only | +| Progression as data: next level by rule over beaten, collected, or set; unlocks; "any of these counts"; a finale on a condition | QTE maps (4), QTE hooks (DL2e, BD13, SAe), all gun games, the time-travel game, metroidvania | rules and `goTo` only | +| A map screen: nodes, a cursor graph, stills by beaten set, completion markers, reveal clips, timeout | 4 QTE titles, 2 segmented, gun-game menus (panels by shot), the time-travel game, platformer world maps | missing (the QTE runs the game's `doLevelSelect`) | +| Deaths as data: the clip by cause, by level, from a range, with an aftermath clip (nag, undertaker, second clip) and conditions | QTE (SAe, BD13, hayate), all gun games, the time-travel game | a flat random pick | +| Per-level, per-move, and per-difficulty overrides of any constant, and settings that force others | QTE hooks (6 titles), gun games' difficulty tables, the segmented lineage's hardcoded scoring | missing | +| Counters, streaks, quotas, tallies, and money (spend, buy, cap) | gun games (streaks, money as life, store), the time-travel game (items, trader), 3D gun accuracy and combos, collect-a-thons | vars and `addVar` by hand | +| Checkpoints and respawn separate from `die` | gun games (sections reached, 22), platformers, 3D gun areas | QTE checkpoints style only | +| Settings and a service screen for any game | every family | QTE loop only (15.5) | +| Attract mode, credits and continue, boards with name entry (letter grid by shot, stick, keyboard), for any game | every family; gun games shoot the letters | QTE loop only; `credit`, best score, `submitScore` | +| Two players: per-player vars, device assignment, join mid-game, twin HUD and boards, tie-break | 7 gun games, 3D gun games, QTE two-player modes | `gun.player`, pointer per mouse | +| HUD widgets: digits, icon rows, bars, gauges (mash, hold, boss), blinking, ticker, prompts per judge, labels over 3D entities (`sceneProject`), binding to instance vars | every family | hud layer with game-var binding; the QTE prompt for presses only | +| Tweens and eased motion (`tweenValue`) | rail speed, pop-outs, platformer feel, UI | engine only | +| Rumble (`controllerDoRumble`), positional sound, sound stop, mute, channels | gun games, 3D gun, platformers | engine only | + +**By type, beyond the shared list:** + +*QTE (windows, held, segments, checkpoints, trophies).* The segmented +lineage's level functions are data except their random draws: seven titles +are pure tables (Conan, Daitarn3, DragonTrainer, PussInBoots, SamuraiJack, +jeeg, freedomfighter) and can be converted now; the rest need random +choice. Move rows under judge names; a sequence judge for the compound +kinds; prompts and gauges for every judge; the HUD for all styles; a +random hidden prompt; choice-menu text as data; collectibles set on a +named move and saved with the slot. + +*2D gun games (25).* The converter must lift the data out of the original +scripts first: the hitboxes live in `hitbox-*.singe` and the moves are +built by the game's `setupLevel`, not in the descriptions. Then: several +boxes per target per frame with a head zone in 2D; hit classes (enemy, +civilian, bonus, capture) with a judging order; a shooting window with a +second-chance hold; reload by border, bottom edge, button, on empty, or +immediate, and reload disabled per window; ammo overfill; full-auto with a +fire delay and a random jam; delayed judging (a shot that lands later); a +quick draw with a holster rule; lives or money as life with a store; +shooting menus with done panels refused and a timeout; crosshair choice, +recoil sprite, reload animation; hints and clues without repeats. +Title-specific, as hooks or data: the puzzles (roulette, cans, crystal +sockets, the signpost maze), the case layer of one title, outlaw capture. +The typing edition needs a typed-word judge (per letter, in a window, with +its own score formula), which belongs with text input. + +*ActionMax (5).* Covered by Forge's core words; fixes: the sensor per room +and the `every` phase (ticks from its own start, not wall-clock seconds). + +*The time-travel game.* Deaths by cause, hint clips by move with +conditions, a rewind item spent during a death, a trader screen, a weighted +slot machine, a bonus shrinking per death, a tutorial level, a perfect-run +ending, and per-language frames. One title; after the shared list, little +is left that is its own. + +*3D light-gun games (none in the library).* Shots stopped by solid bodies +(above); rail speed, easing, a stop timeout, a cover offset (duck) that the +rail does not overwrite, and a `setTrack` for forks; the finished gun model +(auto-fire, damage, rate, spread, weapon pickups); hull or mesh targets; +labels and bars over 3D entities; slow motion (a global time scale; missing +in the engine); recoil output (missing in the engine); gun calibration in +Forge. + +*2D platformers (none in the library).* **A 2D world cannot scroll**: +looks draw at node coordinates in overlay space, with no camera or view +offset (checked). Needed: a 2D camera (follow, dead zone, look-ahead, +bounds, room lock, shake) with editor pan and zoom; tile collision from the +`grid` look, tile properties, a tile painter, autotiling, parallax; +controller options (acceleration and air control, coyote time, jump buffer, +variable jump, air jumps, dash, wall slide and jump, ladders, swimming, +slopes, knockback) on `playerSetVelocity` and `playerSetGravityScale`, which +Forge does not reach; an edge-turning ground enemy; invincibility frames; +sprite animation with one-shots and per-state rates. One-way platforms need +an engine feature (collision filters). + +*3D platformers (none in the library).* The same controller options; +momentum and turn rate; `solid` with a mesh or hull shape; heightmap +terrain; camera collision, dead zone, auto-recentre, and stick orbit; a +blob shadow; animation layers and speed blends (engine only); root motion, +decals, and level streaming are missing in the engine too. The editor needs +snapping, drop-to-surface, and multi-select. + +**Proposed order** (shared first, then by titles blocked, then new genres): +1. The wrong-today list above. +2. Random choice, deaths as data, and overrides: they unblock most QTE + hooks, most of the segmented lineage, and the gun games' draws. +3. Settings, attract, credits, boards and name entry, and checkpoints for + any game (lift them out of the QTE loop). +4. The gun-game converter (hitboxes and moves out of the scripts) and the + 2D gun model; then the 3D gun items on the same model. +5. Progression as data and the map screen. +6. HUD widgets, prompts and gauges per judge, and 3D labels. +7. The 2D camera and scrolling world, tiles, and the controller options; + then the 3D platformer items. +8. Engine work: collision filters (one-way), a time scale, recoil output, + root motion, decals, streaming. + +## 18. The plan: everything section 17 found (written 2026-09-28) + +To be implemented later, in the order given. Each package names what it +adds to a description (in the syntax of `testScripts/author/*.forge`), where +it lands in Author.singe, what the converters do with it, and how it is +proved. Scene numbers from 101 are free. + +**Rules for every package.** +* Generic first: a capability is designed for any game, then the QTE, gun, + and other loops use it. No framework's name, numbering, or layout in + Forge; a converted title's look is data in its own description. +* One source for each: a value lives in one place in a description; a + capability lives in one place in Author.singe, and the QTE loop's own + copy (its attract, boards, service, deaths) is replaced, not kept beside. +* Each capability is proved three ways (17): a scene that exercises it + (with a headless screenshot check where it draws), a converted title that + uses it keeping its State trace, and authoring it with no Lua. The + vocabulary and the Forge book document it in the same change. +* The ledger is regenerated after each package; its counts are the + progress report. + +### 18.0 Before everything: open the loops, and the birds test + +The owner's worry (2026-09-28): that Forge will do only what it is +programmed to do. "What if a user wants a QTE game where they collect +birds by visiting certain scenes? Will we need a birds property?" The +answer must be no, and today it is only half no: birds can be counted with +a var and a `frame` rule on `discBetween` frames, shown by `setText` or a +sprite's `copies`, but the QTE loop is a sealed box. It raises no events +of its own, publishes five vars (score, lives, credits, level, prompt), and +takes no orders from rules. The same is true of the other loops. And the +packages below, as first written, add a named field for each thing the +library happened to do, which is the worry exactly. + +**Forge's generality is its rule system** (vars, events, conditions, +actions, expressions, looks bound to vars). So every loop, and the shell +of 18.5, takes full part in it: +1. **Events at every moment a game could care about:** `gameStart`, + `levelStart`, `sceneStart`, `moveOpen`, `moveRight`, `moveWrong`, + `moveMissed`, `death`, `continued`, `levelDone`, `gameOver`, and the + loop's own screens (`screenShown` with its name). Each carries level, + scene, move, judge, and input, and a rule may name any of them + (`on = "sceneStart", level = 3, scene = 2`). +2. **Its whole state readable** as vars in expressions: level, scene, + move, judge, input, lives, the life bar, deaths, difficulty, what is + beaten. +3. **Actions that steer it:** `goLevel`, `goScene`, `skipMove`, + `giveLife`, `takeLife`, `killPlayer` (with a cause), `setNextLevel`, + `endLevel`, `showScreen`. +4. **Looks bound to any expression** (18.7's `bind`), so a count shows as + digits or as a row of icons with no copying rule. +5. **No predefined vars.** Score, lives, credits, level, and the rest are + the game's own vars, declared like any other; a loop keeps no private + copy. Today the QTE loops hold `q.iScore`, `q.iLives`, and `q.iCredits` + and copy them over the game vars every frame (`Q.publishVars` and three + copies of it), so a rule that adds to `score` is undone a frame later. + Instead a loop reads and writes the vars its description names + (`qte = { vars = { score = "points", lives = "hearts" } }`, defaulting + to the plain names). What the outside world needs to know is a mapping, + not a name built in: the boards and `submitScore` rank by a named var + (`by = "birds"`, deaths, time), the best score kept at `gameOver` is per + board, the bezel layer maps its digits to vars (`show = { score = "p1.points", lives = "hearts", credits = "coins" }`), + and the coin switch adds to the var the shell names. `addScore` and + `credit` stay as shorthand for `addVar`, and `score` is no longer created + when the game does not declare it. + +**Special fields are shorthand, not features.** Where 18.2 to 18.9 add a +table for something rules could say (collectibles set on a move, deaths by +cause, progression, per-level overrides), the table compiles to rules and +does nothing rules cannot; a game that outgrows the table writes the rules. +The converters may write either. A capability that rules cannot express is +a missing event, condition, action, or var, and is fixed there, once. + +**The birds test.** Every package is also proved on a made-up game that no +library title is: QTE birds collected by visiting scenes and shown as +icons, lost on a death, a secret level at ten; a gun game where civilians +pay you; a platformer whose double jump is bought with birds. Written as +scenes without Lua; if one needs a new named property, the package is not +done. + +This package comes first: the events, vars, and actions for the QTE loop +(the loop already knows each moment; it has only to say so), then the same +for each loop as it is touched. Proof: scene 113, the birds game above, +headless, its bird count and secret level traced. + +### 18.1 Package 0: wrong today + +0.1 **3D shots stop at walls.** `fire` skips a body only when it is the + firing player's own, a trigger, or its type says `seeThrough = true` + (glass, foliage). Anything else ends the cast as a miss. Zones: the + zone whose centre the ray passes closest to, relative to its reach, + wins, not the last listed. Scene 101: a target behind a wall, one + behind glass, a zombie with overlapping head and body zones. +0.2 **Saves carry game vars.** A slot saves the declared vars the game + marks `saved = true` (18.4 counters) with the progress; the QTE's + `loadSavePlus`/`writeSavePlus` hooks go. Until 18.4, the loop calls + `writeSavePlus` where it writes a slot. Proved on DL2e and BD13 with + PORT_TWICE (the second run loads what the first collected). +0.3 **ActionMax's sensor in play only.** `util/forgePortActionMax.py` + puts the sensor entity in the play rooms, not the title, menu, and + game-over rooms. Proved by the ActionMax sound trace. +0.4 **`every` on wall-clock phase.** `every` gains `from = "room"`: ticks + at whole multiples of its period since the room began, not since its + last tick. ActionMax's heartbeat uses it. +0.5 **The prompt var names the judge.** `vars.prompt` becomes the judge's + name (`"press"`, `"mash"`, ...) with `vars.promptInput` (`"up"`, + `"button1"`) and `vars.promptFill` (0 to 1, for gauges); the + framework kind number leaves the vars. + +### 18.2 Package 1: random choice + +A value form accepted by every field that takes a value, in a description +or in a rule's action: + + { choose = { a, b, c }, weights = { 2, 1, 1 }, per = "game", key = "ending", noRepeat = true } + +* `per`: `"use"` (default, a fresh draw each read), `"room"`, `"level"`, + `"scene"`, `"game"`, or `"load"`. A draw is kept until its scope ends. +* `key`: draws with one key share one draw, which is how one draw fixes + several fields (a level's rows and its intro). +* `noRepeat`: a `"use"` draw does not repeat until every choice has come up. +* `weights`: a weighted outcome (a slot machine's results). +* Every draw comes from the game's one seeded stream, so `--deterministic` + replays it, and a save keeps the draws in force. + +Lands in `authorValue(value, context)` (one resolver every reader calls), +the draws in `AUTHOR_DRAWS`, saved with the game. The `chance` condition +and `spawner pick = "random"` become users of the same stream. + +Converters: the QTE converter writes `choose` where a hook drew (SAe's +game-over clip, DLe's three level orders, hayate's finish bonus, BD13's +scene 4 or 5) by running the hook over a seeded range, as `swaps()` does, +and recording every outcome it produces; a hook whose outcomes do not fit +a choice stays a hook. The segmented converter writes a level's variants +and its drawn rows (starblazers, SDQ's random correct input, hq-timegal's +shuffled blocks); the gun converter writes random clip rows and the +three-in-ten encounters. + +Proof: scene 102 (each `per`, `key`, `noRepeat`, and `weights`, printed +over a thousand draws and replayed under `--deterministic`); SAe, DLe, +and starblazers keep their traces. + +### 18.3 Package 2: deaths as data, and named clips for any game + +A description's top-level `clips` names clips and stills for any game, not +only the QTE: + + clips = { deathShot = { from = 1200, to = 1260 }, undertaker = { choose = { { from = 5000, to = 5100 }, { from = 5200, to = 5300 } } } } + +with a `playClip` action (`clip = "deathShot"`, `then = "hold"` or +`"resume"`), a `clipDone` event, and `stills`. Deaths are rules over them: + + deaths = { + { when = { { "test", expr = "event.cause == 'civilian'" } }, clip = "friendlyNag" }, + { when = { { "test", expr = "level == 3" } }, clip = { choose = { "d31", "d32" } } }, + { clip = { choose = { "d1", "d2", "d3" } }, after = { clip = "undertaker", when = { { "chance", percent = 50 } } } } } + +The first entry whose conditions hold is the death; `after` is its +aftermath clip, which the trigger may skip. `event.cause` is `"wrong"`, +`"timeout"`, `"civilian"`, a move's name, or what a rule passes to `die`. +The QTE loop's `Q.setupDeathClip`, `swapDeath`, and extra-death clip become +this; a range (`from`/`to` clip numbers) is written as a `choose` by the +converter. + +Proof: scene 103; SAe, BD13, and hayate_1080 keep their traces with +their `swapDeath` hooks gone. + +### 18.4 Package 3: settings, overrides, and counters + +**Settings.** A description declares its options once: + + settings = { + { name = "difficulty", label = "Difficulty", values = { "Easy", "Normal", "Hard", "Extreme" }, default = 2, group = "Game" }, + { name = "reload", label = "Reload", values = { "Offscreen", "Border", "Button" }, default = 1, group = "Gun" }, + forces = { { when = { { "test", expr = "settings.difficulty == 4" } }, set = { prompts = 1 } } } } + +Kept in the engine's save store per game; read in expressions as +`settings.difficulty`; any field may be gated on one (`when` on a type, +a rule, a room entity). A generic **service screen**, drawn by a +Forge-generated RmlUi document, lists the groups and rows, restores the +defaults, and opens on the service switch for any game (the QTE's +`Q.doServiceScreen` becomes this, its framework's rows written by the +converter as data). The QTE converter maps the 69 `dip_` settings to named +settings once, in one table (15.4's), not per title. + +**Overrides.** A number field may be a table resolved in context: + + score = { base = 100, byLevel = { [3] = 250 }, byDifficulty = { 80, 100, 150, 200 }, bySetting = { reload = { 100, 90, 80 } } } + +`authorValue` resolves it with the level, difficulty, and settings in +force. A move row may carry its own `score`, `sets = { gem3 = true }` +(collectibles set on a named move), and `clip` overrides. The converter +turns `startConf`, `swapLevel`'s constants, and `specialScore` into these +by running them over each level and difficulty, as for random choice. + +**Counters.** A var may be declared with limits and behaviour: + + vars = { money = { value = 300, min = 0, max = 9999, saved = true }, streak = { value = 0, resetOn = "miss" }, gems = { value = 0, max = 11, saved = true } } + +with a `spend` action (`name`, `amount`, `else` actions when short), a +`varReached` event, and `saved = true` (0.2). A **tally** screen action +shows lines of label, expression, and running total, then adds the total +to a var (finish bonuses, accuracy, a bonus shrinking per death). + +Proof: scenes 104 (settings, forces, and the service screen, driven +headless) and 105 (overrides and counters); DL2e, DLe, hayate_1080, and +Esh_Aurunmilla keep their traces with `startConf`, `swapLevel`, and +`specialScore` gone; TimeGal keeps its service-walk trace (16.3). + +### 18.5 Package 4: the game shell for any game + +What every arcade-style game has around its play, lifted out of the QTE +loop into one `shell` table any description may use: + + shell = { + attract = { steps = { { clip = "title" }, { still = "rankings", seconds = 5, show = "boards" }, { room = "demo", seconds = 20 } }, skip = "any" }, + credits = { coinsPerCredit = "settings.coins", freePlay = "settings.freePlay", cap = 9 }, + lives = { perCredit = 3, max = 9, extraEvery = 50000 }, + continue = { clip = "continue", seconds = 10, cost = 1 }, + boards = { { name = "normal", by = "score", size = 10, seed = { { "AAA", 10000 } } } }, + nameEntry = { style = "grid", letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ.-", still = "enterName", select = "shoot" }, + pause = { switch = "pause" } } + +* The shell runs attract, coin, start, play, continue, game over, name + entry, and rankings, and hands play to the rooms and rules (or a loop + behaviour). Events `gameStart`, `lifeLost`, `continued`, `gameOver`; + actions `loseLife`, `endGame`. +* **Name entry** styles: `grid` (a cursor over letters, stick or + pointer), `stick` (up and down through letters), `keyboard`, and + `select = "shoot"` (letters shot on a still or clip, the gun games' way, + with the letters' boxes as a track). +* **Checkpoints:** a `checkpoint` action records the place (room, rail + point, disc frame, entity positions the type marks) and `die` with + `respawn = true` returns there with a life lost; the QTE checkpoints + style and the gun games' sections become this. +* **Two players:** `players = 2` gives each player a var table + (`p1.score`, `p2.ammo`; `player.score` in a rule with `event.player`), + a device assignment screen (press to claim a mouse or gun), joining + mid-game (`join` rule, a credit), twin boards, and a `tie` event the game + answers (the gun games' draw-to-break). +* The QTE loop's `Q.doIntro`, `Q.doHighScore`, `Q.doContinue`, + `Q.doGameOver`, boards, and name entry become the shell, and the gun + games' vocabularies stop carrying their own. + +Proof: scene 106 (a small non-QTE game through the whole shell, headless, +coins, a continue, name entry by each style, the boards persisting across +two runs); TimeGal's standard, attract, and persistence traces (16) keep +agreeing; one gun game's attract and board. + +### 18.6 Package 5: progression and the map + +**Progression.** A description's `levels` (any game's, not only the QTE's) +with an `order`: + + order = { + start = { choose = { { 1, 2, 3 }, { 2, 1, 3 }, { 3, 2, 1 } }, per = "game" }, + next = { { when = { { "test", expr = "beatenAll{1,2,3,4} and gems == 11" } }, level = "secretFinal" }, + { when = { { "test", expr = "beatenCount{1,2,3,4} >= 3" } }, level = "quartz" }, + "sequence" }, + skipBeaten = true, + counts = { { 6, 7 } }, + unlocks = { tier2 = "beatenCount{1,2,3,4} >= 3", finale = "beaten(6) or beaten(7)" } } + +Expression functions `beaten(n)`, `beatenAll{...}`, `beatenCount{...}`, +`unlocked("tier2")`, and `level`; `counts` makes alternatives count as one +(beating 6 or 7 clears the node); `unlocks` are derived flags, +recomputed after a load (Chantze's `TestState`). A `nextLevel` action and +a `levelDone` event serve non-QTE games. + +**The map screen**, for any game: + + map = { + nodes = { { level = 1, x = 120, y = 90, up = 2, right = 3, still = { base = 4100, beatenOffset = 1 } }, ... }, + stills = { byBeaten = true }, + select = "stick", + timeout = { seconds = 20, pick = "firstOpen" }, + refuse = "beaten", + markers = { look = { kind = "sprite", file = "Overlay/done.png" } }, + reveals = { { unlock = "tier2", clip = "revealTier2" } }, + music = "map" } + +`select` is `"stick"` (a cursor graph), `"pointer"`, or `"shoot"` (panels +shot on a still, the gun games' menus, with `refuse` and `timeout`). +Events `mapChosen`; the QTE's `levelMap` screen and `doLevelSelect`, +`moveCursor`, `LvlMapComplete`, `AllowLvlEnd`, `AllowLvlQuartz`, +`ShowComplete`, `mapConf`, `TestDips`, and `TestState` hooks become this, +written by the converter where they are tables in the hook (the stills by +beaten set and the neighbour lists are), and left as hooks where they are +not. + +Proof: scene 107 (a map by stick, by pointer, and by shot, reveals and +markers, a load re-deriving unlocks); SuckerPunch, f13, Chantze, Triad, +Daitarn3, and SamuraiJack keep their traces with their map hooks gone; one +gun game's menu. + +### 18.7 Package 6: HUD widgets + +Looks for the `hud` and overlay layers, each bound to any expression +(`bind = "gun1.ammo"`, `"p2.score"`, `"self.health / self.max"`), so a +game var copy is never needed: +* `digits`: a number drawn from a sprite font or a TTF, with padding. +* `icons`: a row or column of a picture, one per unit, with `max`. +* `bar`: a filled bar, horizontal or vertical, pictures or colours. +* `gauge`: a picture sequence chosen by a fraction (mash and hold gauges, + the gun games' power meters). +* `prompt`: the current move's prompt, one picture or animation per judge + and input (`pictures = { ["press.up"] = "arrowUp.png", mash = { "mash.png", "action.png" } }`), + slides and blinks as options, and a hidden or "?" variant. +* `ticker`: scrolling text. +* `label3d`: any look pinned over a 3D entity (`sceneProject`), for + warning rings and boss bars. +* Every look gains `blink = { period, from = "appear" }` and `showWhen`. + +The QTE's `qteHud` look becomes a widget set: the converter writes each +title's widgets, positions, pictures, and fonts into its description (the +title's own layout as data), so all five QTE styles draw a HUD, and +`Q.hudDraw` and its framework constants leave Author.singe. The gun +games' ammo rows, badges, crosshair choice, recoil sprite, and reload +animation are widgets and a `pointer` option (18.9). + +Proof: scene 108 (every widget, screenshots headless); TimeGal's HUD +trace keeps agreeing with the widget set; a held and a segmented title +draw their prompts (screenshots checked by a person once, then kept as +references). + +### 18.8 Package 7: the QTE model completed + +* **Move rows by judge name:** `{ from = 215, to = 235, judge = "press", input = "right", death = 17 }`, + with `judge` from 15.4's list and a `sequence` judge + (`inputs = { "up", "left" }`) for the compound kinds. The converter + writes names; the loop's `QTE`/`HELD` kind numbers leave the runtime. +* **One loop, options not styles:** windows, held, segments, checkpoints, + and trophies become option sets (15.4); `Q.karis332`-style switches and + the framework version go (15.3). +* **The segmented lineage as data:** the converter runs each + `setupLevelNN(seg)` over every segment under stubs and writes the rows; + seven titles are pure tables and convert first, the rest after 18.2. The + loop stops loading the game's `setupLevel`, `setupDeathClip`, + `NextLevel`, and `doLevelSelect`. The segmented lineage's scoring + constants become data (18.4). +* **The time-travel game** on the same model plus: hints by move type with + conditions (a `hint` field on a row, a rule), a rewind item (an action + that reverses the disc to the move's start during a death, spending a + counter), the trader and the slot machine (a map node and a weighted + choice), and its tutorial level (a level with its own scoring override). +* The retired globals (17.1's 455): the converter drops what is the + framework's own state; each drop is checked by the title's trace. + +Proof: every QTE title's trace, the whole library once at the end of the +package (the sweep, not per change); the ledger shows no `framework` +judges and no `lua` level functions. + +### 18.9 Package 8: the gun game model (2D and 3D) + +**Converter first.** `util/forgePortKaris.lua`'s gun-game branch lifts +the data out of the original scripts: `hitbox-*.singe` into box tracks +keyed by frame; the moves from the game's `setupLevel` under stubs into +windows; menus into maps (18.6); deaths into 18.3; settings into 18.4. +The ledger then itemizes each title. + +**The gun** (behaviour fields): + + { kind = "gun", player = 1, ammo = 6, max = 6, overfill = true, + reload = "offscreen", margin = 10, auto = { rate = 0.12 }, jam = { chance = 0.0025, clear = "reload" }, + damage = 1, spread = 0, holster = "bottom" } + +`reload` is `"offscreen"`, `"border"` (within `margin`), `"bottom"`, +`"button"`, `"empty"`, or `"auto"`; the `canReload` var turns it off per +window; `auto` fires while held; `jam` needs a reload to clear; +`holster` and the `holstered` condition make the quick draw a rule. +Weapons are types carrying these fields, and an `equip` action swaps them. + +**The target and the shot:** +* `class = "enemy"` (or `"civilian"`, `"bonus"`, `"capture"`, any name), + judged in the order `judge = { "enemy", "bonus", "civilian" }` on the + game; `hit` carries `class`. +* 2D boxes per target per frame with parts (`{ x, y, w, h, part = "head" }` + in a box track), so a head zone works over video as in 3D. +* A `window` on a box track: frames in which a shot counts, and + `hold = { byDifficulty = { 1.0, 0.7, 0.5, 0.3 } }` seconds of second + chance on the last frame; events `windowMissed`. +* A delayed shot: `judge = "later"` with a clip, the hit judged when the + clip ends (a cannon). +* The typing edition's judge: a `typing` behaviour (words from a + `choose`, per-letter matching in a window, wrong keys counted, a score + expression, words that must not be typed). + +**3D on the same model:** 0.1's walls; `target shape = "hull"` or +`"mesh"`; the rail gains `speed` (a var), `ease`, a stop `timeout`, a +`cover = { switch = "pedal", offset = { 0, -0.8, 0 } }` added after the rail +position (reload in cover by rule), and a `setTrack` action for forks; +`label3d` warnings and boss bars (18.7); weapon pickups by `equip`; a +`rumble` action (`controllerDoRumble`); positional sound on an entity +(`soundSetNode`); slow motion and recoil wait for 18.12. + +Money as life, the store, streaks, and hints are 18.4's counters and a map +or menu; the puzzles (roulette, cans, crystal sockets, the signpost maze) +and one title's case layer stay hooks until a second game needs them. + +Proof: scenes 109 (2D gun: classes, parts, windows with holds, every +reload mode, auto and jam, a quick draw, two players with a tie) and 110 +(3D gun: cover, forks, easing, hull targets, pickups, a boss with a bar); +every converted gun game's trace against its original (the ALG family's +compare driver); the typing edition's trace. + +### 18.10 Package 9: 2D platformers + +* **The 2D camera:** a `view` on the `world2d` or overlay layer: + `{ follow = "hero", deadZone = { w = 80, h = 60 }, lookAhead = 40, bounds = { 0, 0, 4096, 480 }, lock = "room", shake = true }`. + Every look in the layer draws at its position less the view's; a look + with `parallax = 0.5` scrolls at half speed; the HUD layer does not + scroll. `shake` works in 2D. +* **Tiles:** a `tiles` look (a sheet, a tile size, layers of rows) with a + `tileTypes` table (`solid`, `oneWay`, `hazard`, `ladder`, `water`, any + tag the rules read as `tileAt(x, y)`); solid tiles merged into as few + static boxes as fit; autotile rules (a tile chosen by its neighbours). +* **The controller** (`platformer` fields, most on `playerSetVelocity` and + `playerSetGravityScale`): `accel`, `decel`, `airControl`, `coyote`, + `buffer`, `jumpCut`, `fallGravity`, `airJumps`, `dash = { speed, time, cooldown }`, + `wallSlide`, `wallJump`, `climb` (in a ladder tile or area), `swim`, + `slope`, `step`; a `launch` action (bounce, knockback, springs); + abilities as vars (`canDash`) the fields read. +* **Enemies and hazards:** a `groundWalker` that turns at edges and walls; + `health` gains `iframes`; stomp as a rule on `collision` with + `event.fromAbove` and `launch`. +* **Sprites:** `frames` per state gains `fps` and `loop = false`, and + raises `animationDone`. +* Checkpoints are 18.5's. +* **Editor:** pan and zoom on the 2D canvas, level and camera bounds, a + tile painter (palette from the sheet, brush, fill, erase, layers, tile + types, autotile preview). + +Proof: scene 111 (a scrolling level with every controller option, tiles, +parallax, and a ladder; inputs driven headless, positions traced) and a +small complete platformer sample in `testScripts/author/`, built without +Lua. + +### 18.11 Package 10: 3D platformers + +* The controller options of 18.10 on `character`, plus momentum and a turn + rate (no snap to facing), long jump, ground pound, and wall kick as + options. +* `solid shape = "mesh"` or `"hull"` from the look's model; a `terrain` + look from a heightmap (`meshHeightmap`, `terrainGetHeight`). +* The camera: collision (a ray from target to camera), dead zone, + auto-recentre, stick orbit with zoom steps, camera zones by trigger. +* A blob shadow (a disc placed under the entity by a downward ray) and the + shadow quality settings (`sceneSetShadow*`). +* Animation layers and speed blends (`animationPlayLayer`, + `animationSetLayerMask`, `animationSetLayerWeight`). +* `wander` and `canSee` for enemies (`navRandomPoint`, `navRaycast`). +* **Editor:** snapping, drop to surface, multi-select, duplicate, rotate + and scale handles. + +Proof: scene 112 and a sample, as 18.10. + +### 18.12 Package 11: engine work + +Things neither Forge nor the engine can do yet: +* Collision filters or layers, and one-way bodies (one-way platforms, + shots that pass teammates). +* A global time scale for physics, animation, and timers (slow motion), + with the disc and sound left at speed. +* Gun recoil output (a solenoid or the gun's own recoil command: which + guns and protocols is to be surveyed first). +* Root motion from an animation clip. +* Decals (blob shadows, bullet holes). +* Loading a room or model without a stop (streaming). +* A gun calibration screen in the menu. + +Each lands as an engine feature with its Reference entry and CHANGELOG +line first, then its Forge word. + +### 18.13 Also + +* **The ledger grows** as the packages land: Picture and Sound + capabilities (widgets, prompts, sounds per event), the gun games + itemized (18.9), ActionMax reported by its converter, and "runs without + its Lua" regenerated by `util/forgeStandalone.py` rather than read from + an old list. +* **The text adventure** waits for a console look and a typed answer to a + question (17.1); both can ride on 18.5's name entry by keyboard. +* **Retired as packages land:** the QTE loop's copies of the shell, the + deaths, the service screen, and the HUD; `qteHud`; the framework kind + numbers; the style tables; every hook the converter absorbs; the gun + vocabularies, title by title, as each plays from its description. +* **Order and dependencies:** 18.0 first, then 0. 1 (random) before 2 (deaths) and + before the converter work in 3, 7, and 8. 3 (settings and counters) + before 4 (the shell reads settings and lives). 4 before 8 (the gun games + use the shell). 5 after 1 and 3. 6 can go beside 3 to 5. 7 after 1 to + 6. 8 after 4 to 6. 9 and 10 need only 3 to 6 and can go in parallel + with 7 and 8. 11 whenever a package reaches it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bac0759 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Forge + +Forge is an authoring tool for Singe: a game is described -- its types, rooms, rules, and tracks -- +rather than written, and Forge compiles the description into an ordinary Singe game that can be +read and edited by hand. Forge is itself a Singe game. It is its own project: nothing of it ships +inside Singe, and a game it builds carries its own copy of the runtime. + +Forge needs Singe 3.00 or later. + +## Layout + +| Folder | What is in it | +|---|---| +| forge/ | Forge itself: the editor (Forge.singe, Forge.rml), the runtime (Author.singe), the compiler (AuthorCompile.singe), its games.dat, the kit of art the tutorials use, and the tutorials' finished descriptions | +| docs/ | The Forge book (Forge.adoc), its vocabulary chapter (ForgeVocabulary.adoc, generated), and its pictures | +| testScripts/ | The test scenes, the sample descriptions (author/), the library port comparisons (ports/), and the files they load | +| util/ | Tools: the vocabulary chapter, the tutorial kit and pictures, credits, and the library converters | +| FORGE.md | The plan | + +## Building + + ./build.sh + +writes build/Forge.html, build/Forge.pdf, and build/Forge.game, the distributable. Packing is +Singe's own --pack, so the build needs a Singe binary: by default the newest one in a Singe tree +beside this one (../singe/.builddir), or whatever SINGE names: + + SINGE=/path/to/Singe-v3.00-Linux-x86_64 ./build.sh + +It also needs lua5.4, asciidoctor, and asciidoctor-pdf. + +## Testing + + testScripts/run.sh every scene + testScripts/run.sh 52 83 just these + +runs the scenes headless against the same Singe binary (SINGE again) and reports each. Logs and +screenshots are kept in build/tests/. + +## Licence + +GPL version 3 (COPYING). The test files' and the kit's credits are in testScripts/README.md and +forge/kit/LICENSE. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..cb4c029 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.00.1.1 diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7bf6c8f --- /dev/null +++ b/build.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Builds Forge: the vocabulary chapter from the manifest in forge/Author.singe, the book as +# build/Forge.html and build/Forge.pdf, and the distributable build/Forge.game -- forge/ with the +# book and four samples beside it, packed by Singe's own --pack. Singe is not part of this +# project: point SINGE at the engine binary to pack with, or leave it to find the newest build in a +# Singe tree beside this one. +# +# ./build.sh uses ../singe/.builddir/Singe-v*-Linux-x86_64 +# SINGE=/path/to/Singe-v3.00-Linux-x86_64 ./build.sh +# +# Needs lua5.4, asciidoctor and asciidoctor-pdf. The version comes from VERSION: four numbers, +# patch 1 meaning a beta and tweak saying which, so 1.00.1.1 is "1.00b1", as Singe numbers itself. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +out=$here/build +singe=${SINGE:-$(ls -t "$here"/../singe/.builddir/Singe-v*-Linux-x86_64 2>/dev/null | head -1)} + +for tool in lua5.4 asciidoctor asciidoctor-pdf; do + if ! command -v "$tool" > /dev/null; then + echo "error: $tool not found" >&2 + exit 1 + fi +done +if [[ -z $singe || ! -x $singe ]]; then + echo "error: no Singe binary; set SINGE to the engine to pack with" >&2 + exit 1 +fi +read -r major minor patch tweak <<< "$(tr '.' ' ' < "$here/VERSION")" +if [[ $patch == 1 ]]; then + version="${major}.${minor}b${tweak}" +else + version="${major}.${minor}" +fi + +mkdir -p "$out" +# The vocabulary tables come from the manifest itself, so the book cannot drift from it. +(cd "$here" && lua5.4 util/forgeVocabulary.lua > docs/ForgeVocabulary.adoc) +asciidoctor -a data-uri -a revnumber="$version" "$here/docs/Forge.adoc" -o "$out/Forge.html" +asciidoctor-pdf -a revnumber="$version" "$here/docs/Forge.adoc" -o "$out/Forge.pdf" + +# The package: the scripts, the kit, the tutorials, the book, and the samples that need no art +# beyond what Singe ships, so the chooser has something to open on a fresh install. Packing +# refuses to overwrite, and it runs in the staging folder because Singe writes Singe/ and data/ into +# its working directory. +stage=$out/stage +rm -rf "$stage" "$out/Forge.game" +mkdir -p "$stage" +cp -r "$here/forge" "$stage/Forge" +cp "$out/Forge.pdf" "$stage/Forge/" +cp "$here"/testScripts/author/{platformer,breakout,qte,fmv}.forge "$stage/Forge/" +(cd "$stage" && "$singe" --pack "$stage/Forge" "$out/Forge.game") +rm -rf "$stage" + +echo "Forge $version, packed with $singe:" +ls -la "$out/Forge.html" "$out/Forge.pdf" "$out/Forge.game" diff --git a/docs/Forge.adoc b/docs/Forge.adoc new file mode 100644 index 0000000..0ba05ff --- /dev/null +++ b/docs/Forge.adoc @@ -0,0 +1,1928 @@ += Forge +Scott Duensing <scott@kangaroopunch.com> +: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 +<<own-vocabulary>>): a behaviour that is the game's loop rewritten in +Forge's terms, its screens and each level's flow, over the game's own +data files (its level setups, its hitbox tables, its hit tests, its +clips) loaded through the same shim the framework ports use. A press +carries the device it came from, so two guns are told apart, and the +hitbox behaviour's `step` keeps a box only on the frames a track keys. + +*3D over a painting.* A room in the scene may be a picture on a flat `mesh` +(its `texture`, `unlit`) with 3D characters in front of it. Whatever stands +in the picture -- a pillar, a doorway, a table -- that a character has to walk +behind is a `mesh` look with `occluder = true`: an invisible box that hides +what is behind it while the picture shows through it. The editor's 3D +viewport keeps occluders visible so they can be placed; the game hides them. +`testScripts/author/painted.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, <<own-vocabulary>>) +-- a table parameter is typed as text: a map such as `scene3d.ambient` as +`r=80, g=80, b=100`, a list of points such as `spawner.points` as +`100,-20; 300,-20`, a list of records such as `target.zones` as +`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 new file mode 100644 index 0000000..ab08b5c --- /dev/null +++ b/docs/ForgeVocabulary.adoc @@ -0,0 +1,721 @@ +// 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/images/preview.svg b/docs/images/preview.svg new file mode 100644 index 0000000..eb6bcfb --- /dev/null +++ b/docs/images/preview.svg @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- The proofreading watermark: laid under every page of a PDF built with ./build-docs.sh + preview, and never under a release build. One word, diagonal, faint enough to read through. --> +<svg xmlns="http://www.w3.org/2000/svg" width="595" height="842" viewBox="0 0 595 842"> + <text x="297" y="421" text-anchor="middle" dominant-baseline="middle" + font-family="Helvetica" font-weight="bold" font-size="150" + fill="#000000" fill-opacity="0.11" + transform="rotate(-50 297 421)">PREVIEW</text> +</svg> diff --git a/docs/images/singeLogo.png b/docs/images/singeLogo.png new file mode 100644 index 0000000..73029f8 --- /dev/null +++ b/docs/images/singeLogo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e681ced59bed5a2e05ab371c0d7c8c4020360c528b1ff000ab1e6d4bbc7f7a31 +size 429924 diff --git a/docs/images/tutorials/01-chooser.png b/docs/images/tutorials/01-chooser.png new file mode 100644 index 0000000..265096e --- /dev/null +++ b/docs/images/tutorials/01-chooser.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e06ddf5 --- /dev/null +++ b/docs/images/tutorials/01-entities.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..126958f --- /dev/null +++ b/docs/images/tutorials/01-moved.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..f096bf5 --- /dev/null +++ b/docs/images/tutorials/01-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..8a1534c --- /dev/null +++ b/docs/images/tutorials/01-recoloured.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7c23c94 --- /dev/null +++ b/docs/images/tutorials/01-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6d0bb06 --- /dev/null +++ b/docs/images/tutorials/01-saved.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..43becfc --- /dev/null +++ b/docs/images/tutorials/01-types.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..db34621 --- /dev/null +++ b/docs/images/tutorials/02-cave.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..3fb1467 --- /dev/null +++ b/docs/images/tutorials/02-coinType.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..efba558 --- /dev/null +++ b/docs/images/tutorials/02-coins.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..bdf7987 --- /dev/null +++ b/docs/images/tutorials/02-placed.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6ff2ad3 --- /dev/null +++ b/docs/images/tutorials/02-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..c632cb3 --- /dev/null +++ b/docs/images/tutorials/02-rule.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..ca90263 --- /dev/null +++ b/docs/images/tutorials/02-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..2199fb1 --- /dev/null +++ b/docs/images/tutorials/03-coinSpin.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..db2c9ec --- /dev/null +++ b/docs/images/tutorials/03-heroType.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..3cd6c33 --- /dev/null +++ b/docs/images/tutorials/03-jumpRule.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..2adf9a2 --- /dev/null +++ b/docs/images/tutorials/03-music.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..aa8ae34 --- /dev/null +++ b/docs/images/tutorials/03-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..aec5b3b --- /dev/null +++ b/docs/images/tutorials/04-cleared.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..4a69ffe --- /dev/null +++ b/docs/images/tutorials/04-hitRule.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..c6c0f9c --- /dev/null +++ b/docs/images/tutorials/04-placed.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..649133e --- /dev/null +++ b/docs/images/tutorials/04-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..8e9f5f9 --- /dev/null +++ b/docs/images/tutorials/04-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6d4d4be --- /dev/null +++ b/docs/images/tutorials/04-ship.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b5aa1c7 --- /dev/null +++ b/docs/images/tutorials/04-types.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..8518b8e --- /dev/null +++ b/docs/images/tutorials/05-disc.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..0913de5 --- /dev/null +++ b/docs/images/tutorials/05-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..1e66eb5 --- /dev/null +++ b/docs/images/tutorials/05-prompts.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e76deb2 --- /dev/null +++ b/docs/images/tutorials/05-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..08faf0d --- /dev/null +++ b/docs/images/tutorials/05-window.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..14600e3 --- /dev/null +++ b/docs/images/tutorials/06-firstKey.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..a6fd624 --- /dev/null +++ b/docs/images/tutorials/06-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e8db33e --- /dev/null +++ b/docs/images/tutorials/06-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e9256b8 --- /dev/null +++ b/docs/images/tutorials/06-tracks.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..ef87ab3 --- /dev/null +++ b/docs/images/tutorials/06-types.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..408e9a9 --- /dev/null +++ b/docs/images/tutorials/07-dialogue.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..2f767a7 --- /dev/null +++ b/docs/images/tutorials/07-hall.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..d245740 --- /dev/null +++ b/docs/images/tutorials/07-hotspots.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..1c00918 --- /dev/null +++ b/docs/images/tutorials/07-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..79dc52d --- /dev/null +++ b/docs/images/tutorials/07-verbRules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..41e7035 --- /dev/null +++ b/docs/images/tutorials/07-walkArea.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..15a76f1 --- /dev/null +++ b/docs/images/tutorials/08-hero.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..0d3fb6e --- /dev/null +++ b/docs/images/tutorials/08-meshes.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..8a472e4 --- /dev/null +++ b/docs/images/tutorials/08-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..4682288 --- /dev/null +++ b/docs/images/tutorials/08-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..dabcf2d --- /dev/null +++ b/docs/images/tutorials/08-scene.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7d5e48c --- /dev/null +++ b/docs/images/tutorials/08-viewport.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e788c1b --- /dev/null +++ b/docs/images/tutorials/09-hitRule.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..d13f8ee --- /dev/null +++ b/docs/images/tutorials/09-placed.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..696ec85 --- /dev/null +++ b/docs/images/tutorials/09-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..11e52a8 --- /dev/null +++ b/docs/images/tutorials/09-rail.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..c2bcff6 --- /dev/null +++ b/docs/images/tutorials/09-room.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..991bc56 --- /dev/null +++ b/docs/images/tutorials/09-rules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e698ef1 --- /dev/null +++ b/docs/images/tutorials/09-zombie.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..9a7ba37 --- /dev/null +++ b/docs/images/tutorials/10-arcadeRules.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..3284f7b --- /dev/null +++ b/docs/images/tutorials/10-bezel.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..9e7bd64 --- /dev/null +++ b/docs/images/tutorials/10-playing.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..27788fd --- /dev/null +++ b/docs/images/tutorials/10-released.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f3b9183e7e40777eb24935ccf908db229d5ff80b97b997087b83ea09061350e +size 19262 diff --git a/docs/preview/docinfo.html b/docs/preview/docinfo.html new file mode 100644 index 0000000..fc15cdd --- /dev/null +++ b/docs/preview/docinfo.html @@ -0,0 +1,20 @@ +<!-- The proofreading watermark for the HTML books: ./build-docs.sh preview adds this to every + page's head, and a release build never does. Fixed to the window, so it is on every screen of + the document, and it lets clicks through. --> +<style> +body::before { + content: "PREVIEW"; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-50deg); + font-family: Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 22vw; + color: #000000; + opacity: 0.11; + pointer-events: none; + z-index: 1000; + white-space: nowrap; +} +</style> diff --git a/forge/Author.singe b/forge/Author.singe new file mode 100644 index 0000000..4046ff0 --- /dev/null +++ b/forge/Author.singe @@ -0,0 +1,14807 @@ +--[[ + * + * 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 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/<title>/ tree: the + -- port carries them flat beside itself. + if stem and (name:sub(1, #stem + 7) == "singe/" .. stem .. "/") then + return name:sub(#stem + 8) + 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 new file mode 100644 index 0000000..3693d1d --- /dev/null +++ b/forge/AuthorCompile.singe @@ -0,0 +1,896 @@ +--[[ + * + * 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 new file mode 100644 index 0000000..41f4f83 --- /dev/null +++ b/forge/Forge.rml @@ -0,0 +1,63 @@ +<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 new file mode 100644 index 0000000..0cc09f9 --- /dev/null +++ b/forge/Forge.singe @@ -0,0 +1,5248 @@ +--[[ + * + * 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 new file mode 100644 index 0000000..978e19e --- /dev/null +++ b/forge/games.dat @@ -0,0 +1,26 @@ +-- 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 new file mode 100644 index 0000000..87389da --- /dev/null +++ b/forge/kit/LICENSE @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..e15386e --- /dev/null +++ b/forge/kit/README.txt @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..13277cc --- /dev/null +++ b/forge/kit/click.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..0e2083b --- /dev/null +++ b/forge/kit/clip.mkv @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..d65f21d --- /dev/null +++ b/forge/kit/coin.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..ea91e45 --- /dev/null +++ b/forge/kit/coin.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..4617418 --- /dev/null +++ b/forge/kit/crate.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..d830bec --- /dev/null +++ b/forge/kit/door.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..ac7f28d --- /dev/null +++ b/forge/kit/door.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..58c8dc0 --- /dev/null +++ b/forge/kit/enemy.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..c68b86d --- /dev/null +++ b/forge/kit/fox.glb @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7e6f004 --- /dev/null +++ b/forge/kit/hall.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..278d8b3 --- /dev/null +++ b/forge/kit/hero.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..88c28f9 --- /dev/null +++ b/forge/kit/hit.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..325c81a --- /dev/null +++ b/forge/kit/jump.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..e016f22 --- /dev/null +++ b/forge/kit/key.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..4d888f3 --- /dev/null +++ b/forge/kit/loop.ogg @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b4d2797 --- /dev/null +++ b/forge/kit/rock.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..1045593 --- /dev/null +++ b/forge/kit/ship.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..57d0771 --- /dev/null +++ b/forge/kit/shot.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..58c1122 --- /dev/null +++ b/forge/kit/shot.wav @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6dd7c5a --- /dev/null +++ b/forge/kit/spike.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..73cdfb2 --- /dev/null +++ b/forge/kit/target.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..43629b0 --- /dev/null +++ b/forge/kit/tiles.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..b431512 --- /dev/null +++ b/forge/kit/yard.png @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..5cea7b2 --- /dev/null +++ b/forge/tutorials/01-tenMinutes.forge @@ -0,0 +1,76 @@ +-- 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 new file mode 100644 index 0000000..d881964 --- /dev/null +++ b/forge/tutorials/02-platformer.forge @@ -0,0 +1,144 @@ +-- 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 new file mode 100644 index 0000000..dfee4ef --- /dev/null +++ b/forge/tutorials/03-sprites.forge @@ -0,0 +1,156 @@ +-- 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 new file mode 100644 index 0000000..bc48f71 --- /dev/null +++ b/forge/tutorials/04-shmup.forge @@ -0,0 +1,135 @@ +-- 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 new file mode 100644 index 0000000..b708540 --- /dev/null +++ b/forge/tutorials/05-qte.forge @@ -0,0 +1,140 @@ +-- 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 new file mode 100644 index 0000000..f7f216a --- /dev/null +++ b/forge/tutorials/06-gun.forge @@ -0,0 +1,115 @@ +-- 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 new file mode 100644 index 0000000..9ddbefb --- /dev/null +++ b/forge/tutorials/07-adventure.forge @@ -0,0 +1,244 @@ +-- 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 new file mode 100644 index 0000000..4b11519 --- /dev/null +++ b/forge/tutorials/08-scene.forge @@ -0,0 +1,129 @@ +-- 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 new file mode 100644 index 0000000..feab26f --- /dev/null +++ b/forge/tutorials/09-rail.forge @@ -0,0 +1,213 @@ +-- 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 new file mode 100644 index 0000000..4348865 --- /dev/null +++ b/forge/tutorials/10-release.forge @@ -0,0 +1,193 @@ +-- 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/testScripts/Models/Fox.glb b/testScripts/Models/Fox.glb new file mode 100644 index 0000000..c68b86d --- /dev/null +++ b/testScripts/Models/Fox.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d97044e701822bac5a62696459b27d7b375aada5de8574ed4362edbba94771f7 +size 162852 diff --git a/testScripts/README.md b/testScripts/README.md new file mode 100644 index 0000000..861d390 --- /dev/null +++ b/testScripts/README.md @@ -0,0 +1,29 @@ +# Forge's tests + +Each scene drives Forge or its runtime and says what it found: a line with FAIL in it when +something is wrong, and it quits by itself. Run them with testScripts/run.sh (see ../README.md). +They run from a folder holding Forge/ (a copy of ../forge) and this folder, as a game directory +holds a game; the disc and the font are Singe's own, Singe/menuBackground.mkv and +Singe/FreeSansBold.ttf. + +| Scenes | What they cover | +|---|---| +| 52-57 | The runtime and the editor: a described platformer, the chooser, entities, rules | +| 58-60 | The description format: a shoot-em-up, a light gun game over video, the timeline | +| 61-63 | 3D: a rail shooter, a 3D platformer, the editor's 3D room | +| 64-65 | Adventures in 2D and 3D | +| 66-71 | Racing, tower defence, branching video, the editor's pickers, the long tail | +| 72-82 | The rest of the catalogue, occluders, gizmos, turning sprites | +| 83-92 | The ten tutorials, driven by the keys the book names (tutorialDrive.singe) | +| 93-98, 100 | The panel document, waves, sound, a game's own vocabulary, mash and hold, the light sensor | + +author/ holds the sample descriptions the scenes build; ports/ holds the library port comparisons +(see FORGE.md section 14). + +## Credits + +Models/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. It and the other +files here -- crate.png, checker.png, strip.png, hills.png, crate.jxl, crackle.wav, tune.mp3, +chip.nsf, and the tracker files -- are copies of Singe's own test files; Singe's +testScripts/README.md says where each came from. diff --git a/testScripts/author/adventure2d.forge b/testScripts/author/adventure2d.forge new file mode 100644 index 0000000..57a9388 --- /dev/null +++ b/testScripts/author/adventure2d.forge @@ -0,0 +1,140 @@ +-- 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 new file mode 100644 index 0000000..6062449 --- /dev/null +++ b/testScripts/author/adventure3d.forge @@ -0,0 +1,53 @@ +-- 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 new file mode 100644 index 0000000..cd33e8c --- /dev/null +++ b/testScripts/author/bowling.forge @@ -0,0 +1,55 @@ +-- 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 new file mode 100644 index 0000000..088c3c4 --- /dev/null +++ b/testScripts/author/breakout.forge @@ -0,0 +1,55 @@ +-- 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 new file mode 100644 index 0000000..0c0f67d --- /dev/null +++ b/testScripts/author/fmv.forge @@ -0,0 +1,74 @@ +-- 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 new file mode 100644 index 0000000..bc95000 --- /dev/null +++ b/testScripts/author/fps.forge @@ -0,0 +1,81 @@ +-- 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 new file mode 100644 index 0000000..7adc2b7 --- /dev/null +++ b/testScripts/author/gunvideo.forge @@ -0,0 +1,61 @@ +-- 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 new file mode 100644 index 0000000..30844c8 --- /dev/null +++ b/testScripts/author/hud.rml @@ -0,0 +1,17 @@ + + + hud + + + + +
0
+
3
+
+ +
diff --git a/testScripts/author/mash.forge b/testScripts/author/mash.forge new file mode 100644 index 0000000..cdf7c97 --- /dev/null +++ b/testScripts/author/mash.forge @@ -0,0 +1,34 @@ +-- Mash and hold on the branching track: one window wants RIGHT six times, the next wants UP +-- held for twenty frames, the last wants RIGHT mashed again and gets too few. +return { + title = "Author test: mash and hold", + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, + vars = { taken = 0, missed = 0, wanted = 0, opened = "" }, + + types = { + dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } } + }, + + rooms = { + { name = "cavern", + entities = { { type = "dirk", id = "dirk", x = 0, y = 0 } }, + tracks = { + { name = "moves", key = "frame", + branches = { { from = 120, to = 160, move = "RIGHT", mash = 6, success = 200, fail = 200 }, + { from = 200, to = 240, move = "UP", hold = 20, success = 280, fail = 280 }, + { from = 280, to = 320, move = "RIGHT", mash = 6, success = 340, fail = 340 } } } + } } + }, + + rules = { + { note = "A window opens: remember what it wants", + on = "branchOpen", + act = { { "setVar", name = "wanted", value = "event.mash or event.hold or 0" }, { "setVar", name = "opened", value = "opened .. event.move .. \" \"" } } }, + { note = "Made it", + on = "branchTaken", + act = { { "addVar", name = "taken", amount = 1 } } }, + { note = "Missed it", + on = "branchMissed", + act = { { "addVar", name = "missed", amount = 1 } } } + } +} diff --git a/testScripts/author/maze.forge b/testScripts/author/maze.forge new file mode 100644 index 0000000..70dcd62 --- /dev/null +++ b/testScripts/author/maze.forge @@ -0,0 +1,48 @@ +-- 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 new file mode 100644 index 0000000..a6fbe73 --- /dev/null +++ b/testScripts/author/overlayBits.forge @@ -0,0 +1,47 @@ +-- The pieces the ActionMax port asked for, over the menu video: a text look with a font, size, +-- and anchor of its own; a sprite drawn in copies (a row of coins); a crosshair on the pointer; +-- the disc paused and played by rules; and pointerX in an expression. +return { + title = "Author test: overlay bits", + players = 1, + size = { w = 720, h = 480 }, + layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } }, + vars = { coins = 3, side = "", held = 0 }, + + types = { + title = { look = { kind = "text", text = "OVERLAY BITS", r = 255, g = 220, b = 80, font = "Singe/FreeSansBold.ttf", size = 36, anchor = "top" } }, + foot = { look = { kind = "text", text = "standing on the bottom", r = 200, g = 200, b = 255, anchor = "feet" } }, + mid = { look = { kind = "text", text = "", r = 255, g = 255, b = 255, anchor = "centre" } }, + coins = { look = { kind = "sprite", file = "Forge/kit/coin.png", step = 40 }, vars = { copies = 3 } }, + cross = { look = { kind = "box", w = 9, h = 9, r = 255, g = 80, b = 80 }, behaviours = { { kind = "pointer", player = 1 } } } + }, + + rooms = { + { name = "board", + entities = { + { type = "title", id = "title", x = 360, y = 10 }, + { type = "foot", id = "foot", x = 360, y = 480 }, + { type = "mid", id = "mid", x = 360, y = 240 }, + { type = "coins", id = "coins", x = 40, y = 40 }, + { type = "cross", id = "cross", x = 0, y = 0 } + } } + }, + + rules = { + { note = "The coins follow the var", + on = "frame", + act = { { "setVar", entity = "coins", name = "copies", value = "coins" } } }, + { note = "Which side the pointer is on", + on = "frame", when = { { "test", expr = "pointerX(1) < 360" } }, + act = { { "setVar", name = "side", value = "\"left\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } }, + { note = "Or the other", + on = "frame", when = { { "test", expr = "pointerX(1) >= 360" } }, + act = { { "setVar", name = "side", value = "\"right\"" }, { "setText", entity = "mid", text = "\"pointer on the \" .. side" } } }, + { note = "SPACE holds the disc", + on = "pressed", key = "SPACE", + act = { { "discPause" }, { "setVar", name = "held", value = "frame" } } }, + { note = "RETURN lets it go", + on = "pressed", key = "RETURN", + act = { { "discPlay" } } } + } +} diff --git a/testScripts/author/painted.forge b/testScripts/author/painted.forge new file mode 100644 index 0000000..9fdf561 --- /dev/null +++ b/testScripts/author/painted.forge @@ -0,0 +1,43 @@ +-- 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 new file mode 100644 index 0000000..217ccb7 --- /dev/null +++ b/testScripts/author/picture.forge @@ -0,0 +1,37 @@ +-- 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 new file mode 100644 index 0000000..8287e34 --- /dev/null +++ b/testScripts/author/platformer.forge @@ -0,0 +1,62 @@ +-- 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 new file mode 100644 index 0000000..409abc9 --- /dev/null +++ b/testScripts/author/platformer3d.forge @@ -0,0 +1,65 @@ +-- 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 new file mode 100644 index 0000000..c29c5ba --- /dev/null +++ b/testScripts/author/pool.forge @@ -0,0 +1,42 @@ +-- 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 new file mode 100644 index 0000000..fc2028c --- /dev/null +++ b/testScripts/author/qte.forge @@ -0,0 +1,47 @@ +-- 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 new file mode 100644 index 0000000..c2cbddd --- /dev/null +++ b/testScripts/author/racing.forge @@ -0,0 +1,66 @@ +-- 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 new file mode 100644 index 0000000..343320d --- /dev/null +++ b/testScripts/author/railshooter.forge @@ -0,0 +1,120 @@ +-- 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 new file mode 100644 index 0000000..7f2a53b --- /dev/null +++ b/testScripts/author/rhythm.forge @@ -0,0 +1,50 @@ +-- 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 new file mode 100644 index 0000000..57e7f05 --- /dev/null +++ b/testScripts/author/shmup.forge @@ -0,0 +1,77 @@ +-- 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 new file mode 100644 index 0000000..c87599d --- /dev/null +++ b/testScripts/author/space.forge @@ -0,0 +1,48 @@ +-- 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 new file mode 100644 index 0000000..2b68a03 --- /dev/null +++ b/testScripts/author/tail.forge @@ -0,0 +1,73 @@ +-- 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 new file mode 100644 index 0000000..b296aba --- /dev/null +++ b/testScripts/author/thirdperson.forge @@ -0,0 +1,78 @@ +-- 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 new file mode 100644 index 0000000..5ac2a7e --- /dev/null +++ b/testScripts/author/towerdefence.forge @@ -0,0 +1,70 @@ +-- 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 new file mode 100644 index 0000000..92c3012 --- /dev/null +++ b/testScripts/author/vocab.forge @@ -0,0 +1,32 @@ +-- A game with a vocabulary of its own: the bob behaviour, the bobbing condition, and the setBob +-- action live in vocab.singe beside this file, not in Forge. +return { + title = "Author test: a game's own vocabulary", + players = 1, + size = { w = 720, h = 480 }, + layers = { { kind = "overlay" } }, + vocabulary = "vocab.singe", + vars = { seen = 0 }, + + types = { + buoy = { look = { kind = "box", w = 30, h = 30, r = 80, g = 200, b = 255 }, behaviours = { { kind = "bob", height = 40, speed = 3 } } }, + rock = { look = { kind = "box", w = 30, h = 30, r = 120, g = 120, b = 120 } } + }, + + rooms = { + { name = "sea", + entities = { + { type = "buoy", id = "buoy", x = 200, y = 240 }, + { type = "rock", id = "rock", x = 500, y = 240 } + } } + }, + + rules = { + { note = "Counts the frames the buoy bobs", + on = "frame", when = { { "bobbing", entity = "buoy" } }, + act = { { "setVar", name = "seen", value = "seen + 1" } } }, + { note = "SPACE makes it bob higher", + on = "pressed", key = "SPACE", + act = { { "setBob", entity = "buoy", height = 100 } } } + } +} diff --git a/testScripts/author/vocab.singe b/testScripts/author/vocab.singe new file mode 100644 index 0000000..fa130be --- /dev/null +++ b/testScripts/author/vocab.singe @@ -0,0 +1,47 @@ +-- A game's own vocabulary: a behaviour, a condition, and an action this game alone knows, +-- written as Author.singe writes its own manifest entries. The description names this file +-- (vocabulary = "vocab.singe"); nothing else in Forge has heard of a bob. + +-- A bob: the entity rises and falls on a sine wave about where it was put. +AUTHOR.behaviours.bob = { + help = "Rises and falls about its place, height pixels either way, speed times a second.", + params = { height = "number", speed = "number" }, + attach = function(instance, params) + local _, y = authorPosition(instance) + + instance.bob = { height = params.height or 20, speed = params.speed or 2, base = y, at = 0 } + end, + step = function(instance, dt) + local bob = instance.bob + local x, _, z = authorPosition(instance) + + bob.at = bob.at + dt * bob.speed + nodeSetPosition(instance.node, x, bob.base + math.sin(bob.at) * bob.height, z) + end +} + +-- Whether an instance bobs: a condition of this game's own. +AUTHOR.conditions.bobbing = { + help = "An instance has a bob.", + params = { entity = "entity" }, + emit = function(p) return "vocabBobbing(" .. p.entity .. ")" end +} + +-- Sets how high an instance bobs: an action of this game's own. +AUTHOR.actions.setBob = { + help = "Changes how high an instance bobs.", + params = { entity = "entity", height = "number" }, + emit = function(p) return "vocabSetBob(" .. p.entity .. ", " .. p.height .. ")" end +} + + +function vocabBobbing(instance) + return (instance ~= nil) and (instance.bob ~= nil) +end + + +function vocabSetBob(instance, height) + if instance and instance.bob then + instance.bob.height = height + end +end diff --git a/testScripts/author/waves.forge b/testScripts/author/waves.forge new file mode 100644 index 0000000..d125a72 --- /dev/null +++ b/testScripts/author/waves.forge @@ -0,0 +1,37 @@ +-- 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/checker.png b/testScripts/checker.png new file mode 100644 index 0000000..da6d652 --- /dev/null +++ b/testScripts/checker.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb781c131340b718d427ac044f53ef6828e3f225a7732823fc8fe01cffc3ea96 +size 262 diff --git a/testScripts/chip.nsf b/testScripts/chip.nsf new file mode 100644 index 0000000..a14994e --- /dev/null +++ b/testScripts/chip.nsf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8015d01d67f78c4fd6f5dd02a5dd04239e5aeeb2f90f8717050b4ed85effbe5 +size 749 diff --git a/testScripts/crackle.wav b/testScripts/crackle.wav new file mode 100644 index 0000000..5539bc9 --- /dev/null +++ b/testScripts/crackle.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d2d361f372355a336bc8447d2583102b88496f19edadc04a5fcc8ce8388dfb6 +size 88244 diff --git a/testScripts/crate.jxl b/testScripts/crate.jxl new file mode 100644 index 0000000..109c85d --- /dev/null +++ b/testScripts/crate.jxl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c5f4e26054194e214cd1709b42bcc3a009ab7501ddb397b2108197af4b3c31e +size 849 diff --git a/testScripts/crate.png b/testScripts/crate.png new file mode 100644 index 0000000..8ae5aef --- /dev/null +++ b/testScripts/crate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:853924469942946176165eab399d7257a29ec139a9b08a624f39017e5375b3b0 +size 627 diff --git a/testScripts/hills.png b/testScripts/hills.png new file mode 100644 index 0000000..41902d9 --- /dev/null +++ b/testScripts/hills.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:679cb94f17f58fb35072537340c20d72fa8a60d0132ca4db103c5bfee0451b21 +size 19412 diff --git a/testScripts/ports/README b/testScripts/ports/README new file mode 100644 index 0000000..26a434d --- /dev/null +++ b/testScripts/ports/README @@ -0,0 +1,30 @@ +Port comparisons (FORGE.md section 14): each pair runs an original game from the test library +and its Forge port with the same scripted inputs, on the deterministic clock, and prints TRACE +lines; the port passes when the two traces agree. They need the library beside the repo +(SINGE_LIBRARY, or ~/claude/singetest) and are not part of the reference sweep. + + ActionMax: actionMaxOriginal.singe and actionMaxPort.singe, with + -v /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 new file mode 100644 index 0000000..b6e0578 --- /dev/null +++ b/testScripts/ports/actionMaxInputs.singe @@ -0,0 +1,36 @@ +-- The inputs both the original ActionMax emulator and its Forge port are driven with, by frame +-- of the script's own clock, so the two can be compared move for move (FORGE.md section 14.1). +-- A shot is a pointer position and a trigger pull. The title takes one pull, the menu one on +-- the left half (a standard game), and then the picture is shot at from a dozen places while +-- it plays. +PORT_INPUTS = { + { at = 40, x = 300, y = 200 }, -- Title: pull to start. + { at = 140, x = 100, y = 240 }, -- Menu: the left half, a standard game. +} +-- In .38 Ambush Alley the picture flickers with the sensor -- targets -- at 240,140 from disc +-- frame 2968, at 220,140 from 2591, and at 220,80 from 2577, all left of the sensor's own corner, +-- which the game reaches about 2740 frames in. Most shots go there, a few elsewhere, so good +-- and bad judgments both come up. +local TARGETS = { { x = 240, y = 140 }, { x = 220, y = 140 }, { x = 220, y = 80 } } +for shot = 1, 40 do + local target = TARGETS[(shot % 3) + 1] + local input = { at = 2740 + shot * 28, x = target.x, y = target.y } + + if shot % 4 == 0 then + input.x, input.y = 40 + (shot * 97) % 240, 20 + (shot * 53) % 150 + end + PORT_INPUTS[#PORT_INPUTS + 1] = input +end +PORT_LAST = 2740 + 41 * 28 + 200 + + +-- The input due this frame, if any. +function portInputAt(frame) + for _, input in ipairs(PORT_INPUTS) do + if input.at == frame then + return input + end + end + + return nil +end diff --git a/testScripts/ports/actionMaxOriginal.singe b/testScripts/ports/actionMaxOriginal.singe new file mode 100644 index 0000000..99816b7 --- /dev/null +++ b/testScripts/ports/actionMaxOriginal.singe @@ -0,0 +1,52 @@ +-- The original ActionMax emulator, driven by the shared inputs, tracing what a port must match: +-- its state, its counts, and the disc frame at every input and every change of state. Run +-- with -v /ActionMax/frame_38AmbushAlley.txt; PORT_GAME names another of the five. +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "38AmbushAlley" +local script = library .. "/ActionMax/" .. game .. ".singe" + +-- The inputs sit beside this script, wherever the engine was started from. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") + +dofile(here .. "actionMaxInputs.singe") + +-- The emulator finds its files by its own script's path, and draws sprites in the 2.10 order, +-- which its games.dat entry would have said. +SINGE_LEGACY_SPRITE_ARGS = true +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate +local frames = 0 +local lastState = nil + + +function onOverlayUpdate() + frames = frames + 1 + local input = portInputAt(frames) + + if input then + onMouseMoved(input.x, input.y, 0, 0) + onInputPressed(SWITCH_BUTTON3) + debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d shots %d good %d bad %d", frames, input.x, input.y, currentState, discGetFrame(), shotsFired, shotsGood, shotsBad)) + end + local r = realUpdate() + + if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then + debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s/%s gun %s/%s mouse %d,%d", frames, discGetFrame(), triggerPulled, tostring(sensorLastState), tostring(sensorState), tostring(gunLastState), tostring(gunState), mouseX, mouseY)) + end + -- The startup and setup states are the emulator's own housekeeping; the title is the first + -- state a port has. + if (currentState ~= lastState) and (currentState >= 2) then + lastState = currentState + debugPrint(string.format("TRACE state %d from frame %d: disc %d shots %d good %d bad %d", currentState, frames, discGetFrame(), shotsFired, shotsGood, shotsBad)) + end + if frames >= PORT_LAST then + debugPrint(string.format("TRACE end: state %d disc %d shots %d good %d bad %d", currentState, discGetFrame(), shotsFired, shotsGood, shotsBad)) + singeQuit() + end + + return r +end diff --git a/testScripts/ports/actionMaxPort.singe b/testScripts/ports/actionMaxPort.singe new file mode 100644 index 0000000..c23c7c2 --- /dev/null +++ b/testScripts/ports/actionMaxPort.singe @@ -0,0 +1,52 @@ +-- The Forge port of an ActionMax game, driven by the same inputs as the original and tracing +-- the same things, in the original's terms: its state numbers stand for the port's rooms. +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/ported") +local game = os.getenv("PORT_GAME") or "38AmbushAlley" +local STATES = { startup = 0, title = 2, menu = 3, intro = 4, playing = 5, gameOver = 6 } + +dofile("Forge/AuthorCompile.singe") +-- The inputs sit beside this script, wherever the engine was started from. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") + +dofile(here .. "actionMaxInputs.singe") +dofile(authorBuild(library .. "/ActionMax/" .. game .. ".forge", singeGetDataPath() .. game .. ".singe")) + +local realUpdate = onOverlayUpdate +local frames = 0 +local lastRoom = nil + + +local function counts() + return string.format("shots %d good %d bad %d", AUTHOR_VARS.shots, AUTHOR_VARS.good, AUTHOR_VARS.bad) +end + + +function onOverlayUpdate() + frames = frames + 1 + local input = portInputAt(frames) + + if input then + authorSetPointer(1, input.x, input.y, true) + authorSwitchDown(SWITCH_BUTTON3) + authorSwitchUp(SWITCH_BUTTON3) + debugPrint(string.format("TRACE input %d at %d,%d: state %d frame %d %s", frames, input.x, input.y, STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts())) + end + local r = realUpdate() + + if os.getenv("PORT_SENSOR_TRACE") and (frames >= tonumber(os.getenv("PORT_SENSOR_TRACE"))) and (frames < tonumber(os.getenv("PORT_SENSOR_TRACE")) + 12) then + local sensor = authorEntity("sensor") + local gx, gy = authorPointer(1) + + debugPrint(string.format("SENSOR frame %d disc %d pulled %d spot %s gun %s now %s/%s pointer %d,%d", frames, discGetFrame(), sensor.pulled, tostring(sensor.spotWas), tostring(sensor.gunWas), authorPixelState(312, 205, 127, 20), authorPixelState(gx, gy, 127, 20), gx, gy)) + end + if AUTHOR_ROOM.name ~= lastRoom then + lastRoom = AUTHOR_ROOM.name + debugPrint(string.format("TRACE state %d from frame %d: disc %d %s", STATES[AUTHOR_ROOM.name] or -1, frames, discGetFrame(), counts())) + end + if frames >= PORT_LAST then + debugPrint(string.format("TRACE end: state %d disc %d %s", STATES[AUTHOR_ROOM.name] or -1, discGetFrame(), counts())) + singeQuit() + end + + return r +end diff --git a/testScripts/ports/algDrive.singe b/testScripts/ports/algDrive.singe new file mode 100644 index 0000000..aeb12f3 --- /dev/null +++ b/testScripts/ports/algDrive.singe @@ -0,0 +1,263 @@ +-- Drives an American Laser Games title (the original or its port) from what the game shows, +-- the same way for both. The driver file defines what it takes: a view() of the game's state +-- and algPress(switch, device, down) / algPointer(device, x, y) to press a gun's trigger or +-- put its pointer; this file decides. The trace lines it prints are what compare.sh diffs. +-- +-- The policy: the first gun skips the intro on frame 300 of the run and joins at the player +-- select; at the town select it points at the first unbeaten quadrant and shoots; in a level +-- it shoots the first hitbox of the current window's frame three frames into the window (a +-- miss well outside it on every fifth move, no shot on every seventh), reloads by pointing +-- offscreen when its ammo is out, and takes the continue. + +ALG_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local answered = {} -- Windows shot at, by "level/move/lives". +local tried = {} -- Moves met before, by "level/move". +local frames = 0 +local joined = false +local coined = false +local continued = false +local continueCoined = false +local seeks = 0 +SWITCH_RETURN_KEY = 13 -- The return key's keysym, as the typing edition names it. +local wrongTyped = {} -- Moves already given their wrong letter. +local pendingRelease = nil -- { switch, device } pressed this frame, let go the next. + + +function algTraceSeek(frame) + seeks = seeks + 1 + debugPrint(string.format("TRACE seek %d", frame)) +end + + +-- PORT_PERFECT=1 plays without a wrong or a missed shot, to walk the whole game in one run. +local perfect = (os.getenv("PORT_PERFECT") == "1") + +-- PORT_SERVICE=1 opens the service screen at the attract loop before the game, steps the +-- pointer down it a row at a time with a shot on each (or, on a screen the stick drives, a +-- press and a step down in turn), then leaves it; the trace carries the option under the +-- cursor and every dip while the screen shows. PORT_SHOTS=1 takes a screenshot at the +-- attract loop, on the service screen, in a level just after a shot, at the continue, and +-- at the board, each once, named for the moment. +local walkService = (os.getenv("PORT_SERVICE") == "1") +local shots = (os.getenv("PORT_SHOTS") == "1") +local walked = false +local walkStep = 0 +local walkAt = 0 +local taken = {} +local shotAt = nil + + +local function shot(name) + if shots and not taken[name] then + taken[name] = true + singeScreenshot("shot-" .. name) + end +end + + +local function wrongMove(view) + return (not perfect) and (view.move % 5 == 0) and not view.retry +end + + +local function missedMove(view) + return (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not view.retry +end + + +-- Presses a gun's trigger for one frame. +local function tap(switch, device) + algPress(switch, device, true) + pendingRelease = { switch, device } +end + + +-- view: { screen, step, move, frame, frames, score, lives, ammo, active, window = { from, to }, +-- box = { x1, y1, x2, y2 } (the first hitbox of this frame, in the game's pointer +-- coordinates), quadrants = { {x, y}, ... } (the town select's unbeaten choices), +-- offscreen = { x, y } (a pointer position that counts as off the picture), +-- spare (bullets the game never judges, kept back), reloadSwitch (what reloads) } +function algDrive(view) + if pendingRelease then + algPress(pendingRelease[1], pendingRelease[2], false) + pendingRelease = nil + end + frames = frames + 1 + local line = string.format("TRACE screen %s step %s move %s score %s lives %s ammo %s", tostring(view.screen), tostring(view.step), tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.ammo)) + + if view.service then + line = line .. " opt " .. tostring(view.opt) .. " dips " .. tostring(view.dips) + end + if line ~= lastLine then + local detail = "" + + -- PORT_DEBUG=1 adds what the drive decides by: the window, the spare, the free shot, and + -- the pause. + if os.getenv("PORT_DEBUG") == "1" then + detail = string.format(" win %s spare %s free %s paused %s", view.window and (view.window[1] .. "-" .. view.window[2]) or "nil", tostring(view.spare), tostring(view.free), tostring(view.paused)) + end + debugPrint(line .. detail .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= ALG_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- A typing game (the typing edition of Mad Dog II): the key the view names is released, one + -- a frame; with misses on, every fifth move gets a wrong letter first and every seventh is + -- left untyped (a death), each once per move. The continue is taken once, by the return key. + if view.typed then + if view.continuing then + if (not continued) and (frames % 30 == 0) then + continued = true + algKey(SWITCH_RETURN_KEY) + end + return + end + if view.typing then + local moveKey = tostring(view.screen) .. "/" .. tostring(view.move) + + if (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and (view.typing ~= SWITCH_RETURN_KEY) then + -- Untyped: the game holds the disc and takes the life. + return + end + if (not perfect) and (view.move % 5 == 0) and (view.typing ~= SWITCH_RETURN_KEY) and not wrongTyped[moveKey] then + wrongTyped[moveKey] = true + algKey((view.typing == 122) and 121 or 122) + return + end + algKey(view.typing) + end + return + end + -- The service screen, walked once before the game. + if walkService and not walked then + if view.service then + if (frames - walkAt) >= 30 then + walkAt = frames + walkStep = walkStep + 1 + if walkStep == 3 then + shot("service") + end + if view.serviceKeys then + -- The stick's screen: a press on the option, then a step down; the last option + -- is the way out. + if walkStep % 2 == 1 then + tap(SWITCH_BUTTON1, 0) + else + tap(SWITCH_DOWN, 0) + end + if walkStep > 26 then + walked = true + end + elseif walkStep <= 9 then + algPointer(0, view.width * 0.5, view.height * (walkStep - 0.5) / 9) + tap(SWITCH_BUTTON3, 0) + else + walked = true + tap(SWITCH_BUTTON1, 0) + end + end + return + elseif view.intro and (frames > 200) then + walkAt = frames + tap(SWITCH_SERVICE, nil) + return + end + end + if shots and view.intro and (frames == 330) then + shot("attract") + end + if shots and view.continuing then + shot("continue") + end + if shots and view.board then + shot("board") + end + if shotAt and (frames == shotAt + 3) then + shot("level") + end + -- The player select: START for the first gun, once. + if (view.screen == -2) and not joined and (frames > 60) then + joined = true + tap(SWITCH_START1, 0) + return + end + -- The intro: a coin, then START begins a game (the tutorial or the first level by dip). + -- (A game with no player select, the one-player Johnny Rock, counts as joined from the start.) + if view.intro and (joined or view.noSelect) then + if not coined and (frames > 200) then + coined = true + tap(SWITCH_COIN1, nil) + return + end + if coined and (frames > 300) and (frames % 40 == 0) then + tap(SWITCH_START1, 0) + end + return + end + -- The continue: taken once, with a coin first (Mad Dog II's continue wants a credit). + if view.continuing and not continued and (frames % 30 == 0) then + if not continueCoined then + continueCoined = true + tap(SWITCH_COIN1, nil) + return + end + continued = true + tap(SWITCH_START1, 0) + return + end + -- The town select: the pointer to the first unbeaten quadrant, then the trigger. + if (view.screen == 100) and view.quadrants and view.quadrants[1] and (view.step == 2) and (frames % 20 == 0) then + algPointer(0, view.quadrants[1][1], view.quadrants[1][2]) + tap(SWITCH_BUTTON3, 0) + return + end + -- A screen with targets and no windows (the signpost, the mine, Mad Dog II's menu, item, and + -- skull): the first target shot, every twenty frames, while its state waits for shots (the + -- running state unless the view names another). + if view.targets and view.targets[1] and (view.step == (view.targetStep or 2)) and (frames % 20 == 0) then + if (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then + algPointer(0, view.offscreen[1], view.offscreen[2]) + tap(view.reloadSwitch or SWITCH_BUTTON3, 0) + else + algPointer(0, view.targets[1][1], view.targets[1][2]) + tap(SWITCH_BUTTON3, 0) + end + return + end + -- A level: ammo out (or down to the spare an edition never judges) means a reload by pointing + -- offscreen and pressing the switch the view names (the trigger, or a reload button). + if view.window and (view.ammo ~= nil) and (view.ammo <= (view.spare or 0)) and view.offscreen then + algPointer(0, view.offscreen[1], view.offscreen[2]) + tap(view.reloadSwitch or SWITCH_BUTTON3, 0) + return + end + if view.window and view.box then + -- (A move is answered once per life, and once more per life in the game the continue + -- starts over.) + local moveKey = tostring(view.screen) .. "/" .. tostring(view.move) + local key = moveKey .. "/" .. tostring(view.lives) .. (continued and "/c" or "") + + view.retry = (tried[moveKey] ~= nil) + if (view.frame >= view.window[1] + 3) and (view.frame <= view.window[2]) and not answered[key] then + answered[key] = true + tried[moveKey] = true + if missedMove(view) then + return + end + local cx, cy = (view.box[1] + view.box[3]) / 2, (view.box[2] + view.box[4]) / 2 + + if wrongMove(view) then + cx, cy = view.box[3] + 60, view.box[4] + 60 + end + algPointer(0, cx, cy) + tap(SWITCH_BUTTON3, 0) + if shotAt == nil then + shotAt = frames + end + end + end +end diff --git a/testScripts/ports/algOriginal.singe b/testScripts/ports/algOriginal.singe new file mode 100644 index 0000000..8c94697 --- /dev/null +++ b/testScripts/ports/algOriginal.singe @@ -0,0 +1,515 @@ +-- An American Laser Games title as it ships, driven by algDrive.singe and traced. PORT_GAME +-- names the title's folder under the library and PORT_SCRIPT its script under it (the Hypseus +-- layout: singe//<Title>.singe); run with -v the title's framefile. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus") +local game = os.getenv("PORT_GAME") or "maddog-hd" +local script = library .. "/" .. game .. "/" .. (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe")) + +dofile(here .. "algDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +local realTime = os.time +os.time = function(...) return 12345 end +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + algTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + algTraceSeek(frame) + return realSearch(frame) +end +-- One mouse: a single gun joins by START, as the game does with one mouse. +local realMice = mouseHowMany +mouseHowMany = function() return 1 end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate + +-- PORT_HOOK=1 names the line the script is on every ten million instructions: where a run +-- that never ends is spinning. +if os.getenv("PORT_HOOK") == "1" then + debug.sethook(function() + local where = debug.getinfo(2, "Sl") + + debugPrint("HOOK " .. tostring(where and where.short_src) .. ":" .. tostring(where and where.currentline)) + end, "", 10000000) +end + + +function algPress(switch, device, down) + if down then + onInputPressed(switch, device) + else + onInputReleased(switch, device) + end +end + + +-- A key released, as the typing edition reads it (its keysym, in MODE_FULL). +function algKey(keysym) + onInputReleased(keysym) +end + +-- The pointer as the game's onMouseMoved computes it: a raw position that lands on the point +-- asked for, in the game's own coordinates. +function algPointer(device, x, y) + local rx = (x + (ratioxOffset or 0)) / ((ratiox or 1) * (gunscale or 1)) + local ry = (y + (ratioyOffset or 0)) / ((ratioy or 1) * (gunscale or 1)) + + onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device) +end + + +-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer +-- editions); the one-player editions aim in the hitmap's own. +local function hmx(v) + if ResHMx then + return ResHMx(v) + end + return v +end + + +local function hmy(v) + if ResHMy then + return ResHMy(v) + end + return v +end + + +-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates. +local function boxesAt(rel) + local boxes = {} + + for k = (move[currentMove][hitmapStart or hitboxStart] or 1), (hitmapTotal or 0) do + local row = hitmap[k] + + if row and (row[hitmapFrame] == rel) and (row[hitmapCount] > 0) then + for j = row[hitmapIndex], row[hitmapIndex] + row[hitmapCount] - 1 do + local b = hitbox[j] + + -- (A box given back to front -- Platoon's hitbox lines are, often -- can never be + -- hit by the game's own test, so it is no aim.) + if b and (b[bbx1] <= b[bbx2]) and (b[bby1] <= b[bby2]) then + boxes[#boxes + 1] = { hmx(b[bbx1]), hmy(b[bby1]), hmx(b[bbx2]), hmy(b[bby2]) } + end + end + break + end + end + + return boxes +end + + +local function firstBox() + -- The first hitbox of this frame, aimed at a point that also lies inside a box of the next + -- frame when the two overlap: the press the drive makes now is judged on this frame or the + -- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands. + if (move == nil) or (move[currentMove] == nil) or (hitmap == nil) then + return nil + end + local rel = currentFrame - (thisOffset or 0) + local now = boxesAt(rel) + local b = now[1] + + if b == nil then + return nil + end + for _, n in ipairs(boxesAt(rel + 1)) do + local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4]) + + if (x1 <= x2) and (y1 <= y2) then + return { x1, y1, x2, y2 } + end + end + + return b +end + + +local function view() + local window = nil + + -- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's + -- move rows have no shoot window of their own, so the move's frames stand in. + local inLevel = (currentLevel ~= nil) and (((frameShootStart ~= nil) and (currentLevel >= 101) and (currentLevel <= 121)) or ((frameShootStart == nil) and (((currentLevel >= 1) and (currentLevel <= 30)) or (currentLevel == levelStart)))) + + if ((practiceHit ~= nil) or (fRndPopOrder ~= nil)) and (currentLevel == levelPractice) then + inLevel = false + end + if (levelSigns ~= nil) and (currentLevel == levelSigns) then + inLevel = false + end + if (levelTieIntro ~= nil) and ((currentLevel == levelTie) or (currentLevel == levelTieIntro)) then + inLevel = false + end + + -- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour + -- at the doors, Fellina's skull and arrow, the sockets of the crystal build. + if (levelCrystalBuild ~= nil) and ((currentLevel == levelCrystalBuild) or ((currentLevel == levelDoor2) and (lvlState == lvlPlayClip4)) or ((currentLevel == levelDoor3) and (lvlState == lvlPlayClip3)) or ((currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)))) then + inLevel = false + end + + -- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows. + if (GetMapArray01 ~= nil) and ((currentLevel == levelStore) or (currentLevel == levelMansion2) or (currentLevel == levelMansion4)) then + inLevel = false + end + + -- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them, + -- the moves left are let pass. + if (levelReaper2 ~= nil) and (currentLevel == levelReaper2) and (currentMove > 1) and (iReaperHits >= iReaperLaughs) then + inLevel = false + end + + -- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the + -- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all. + local typing = nil + + if validKey ~= nil then + inLevel = false + local function nextOf(word) + if word and (iKeyIndex or 1) <= string.len(word) then + return string.byte(word, iKeyIndex or 1) + end + return nil + end + -- (The game starts with the key slot at zero, not at none, and never clears it.) + local waiting = (iKey == SWITCH_NONE) or (iKey == 0) + + if not waiting then + typing = nil + elseif (currentLevel == levelTitle) and (lvlState == lvlRunning) then + typing = SWITCH_RETURN + elseif (currentLevel == levelMenu) and (lvlState == lvlRunning) then + typing = SWITCH_RETURN + elseif (currentLevel == levelHighScore) and (lvlState == lvlRunning) then + -- (Once, before the name's frames run out; the return key seeks to the entry's end.) + if discGetFrame() < offsetMenus + 3275 then + typing = SWITCH_RETURN + end + elseif bShowSprinkles and sprinkles then + for k = 1, (iSprinkleMax or 0) do + if sprinkles[k] and (sprinkles[k][4] == true) then + typing = string.byte(sprinkles[k][3]) + break + end + end + elseif (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then + typing = nextOf(sMagicWord) + elseif move and move[currentMove] and ((lvlState == lvlPauseAction) or ((lvlState == lvlRunning) and (discGetFrame() >= move[currentMove][moveFrameStart]) and (discGetFrame() < move[currentMove][moveFrameEnd]))) then + typing = nextOf(move[currentMove][(currentLevel == levelItem2) and moveType or wordval]) + end + end + + -- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the + -- state: no window until that press is in, and a target anywhere to make it. + local skipping = (levelCharlie ~= nil) and bOnce and (currentLevel ~= nil) and (currentLevel >= 1) and (currentLevel <= 6) and (lvlState == lvlRunning) + + if skipping then + inLevel = false + end + + -- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a + -- death, and the drive would take it at every level that has one. + if inLevel and move and move[currentMove] and (rndMoveType ~= nil) and (GOODGUY ~= nil) and (move[currentMove][rndMoveType] == GOODGUY) then + inLevel = false + end + if inLevel and move and move[currentMove] then + window = { move[currentMove][frameShootStart or moveFrameStart], move[currentMove][frameShootEnd or moveFrameEnd] } + end + local quadrants = nil + + if (currentLevel == levelTownSelect) and stage then + quadrants = {} + local w, h = resX or 360, resY or 240 + + -- In the order the game itself takes when the select times out: the saloon before the + -- sheriff, whose level needs the key from it. + if not stage[STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end + if not stage[STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end + if not stage[STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end + if not stage[STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end + end + + -- The signpost's sign and the mine's lantern and item, as a list of points to shoot in + -- order (the middle line of each, scaled as the game scales them). + local targets = nil + local targetStep = nil + local function lineMiddle(rows, count) + local row = rows and rows[math.floor((count + 1) / 2)] + + if row then + return { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy(row[lineY]) } + end + return nil + end + + if skipping then + targets = { { 180, 120 } } + targetStep = lvlRunning + elseif (currentLevel == levelSignSelect) and sign then + targets = {} + if stage[STAGE_FUSE] == false then + targets[1] = lineMiddle(sign[SIGN_PROSPECTOR], 48) + elseif stage[STAGE_MINE] == false then + targets[1] = lineMiddle(sign[SIGN_MINE], 28) + else + targets[1] = lineMiddle(sign[SIGN_HIDEOUT], 58) + end + elseif (currentLevel == levelMine) and mine then + targets = {} + local counts = { [MINE_SIGN] = 21, [MINE_LANTERN] = 20, [MINE_PAN] = 12, [MINE_PITCHFORK] = 23, [MINE_SKULL] = 25 } + + if not bLanternHit then targets[#targets + 1] = lineMiddle(mine[MINE_LANTERN], counts[MINE_LANTERN]) end + if not bItemHit then targets[#targets + 1] = lineMiddle(mine[iWhatToHit], counts[iWhatToHit]) end + elseif (currentLevel == levelMaze) and maze and maze[curMazePos] and (maze[curMazePos][mazeType] > 0) and sign then + -- A maze sign: the sign for the way to go. + local frame, dir = maze[curMazePos][signType], maze[curMazePos][signDirection] + local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } } + local pair = counts[frame] + + if pair and sign[dir] then + targets = { lineMiddle(sign[dir], pair[dir]) } + end + elseif (levelItem1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelItem1) and (lvlState == lvlPlayClip3) then + -- Mad Dog II's first item level: the item the guide asked for, from the game's own + -- rectangles. + local boxes = { [ITEM_LANTERN] = { 57, 66, 117, 129 }, [ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [ITEM_MOONSHINE] = { 260, 272, 60, 72 } } + local b = boxes[iThisItem] + + if b then + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = lvlPlayClip3 + end + elseif (levelProspector1 ~= nil) and (ResHMx ~= nil) and (currentLevel == levelProspector1) and (lvlState == lvlPlayClip4) then + -- Mad Dog II's prospector: the skull he chose, from the game's own rectangles. + local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } } + local b = boxes[iSkull] or boxes[4] + + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = lvlPlayClip4 + elseif (panelHit ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then + -- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line + -- of its area (the count of lines is the game's own). + local MENUS = {} + + if MENU_SIERRA ~= nil then + MENUS[MENU_SIERRA] = { { PANEL_PRACTICE, STAGE_PRACTICE, 99 }, { PANEL_BAR, STAGE_BAR, 99 }, { PANEL_CARCHASE, STAGE_CARCHASE, 99 }, { PANEL_HIDEOUT, STAGE_HIDEOUT, 99 } } + MENUS[MENU_CHICAGO] = { { PANEL_HELI, STAGE_HELI, 111 }, { PANEL_COURTROOM, STAGE_COURTROOM, 111 }, { PANEL_BUSCHASE, STAGE_BUSCHASE, 111 } } + MENUS[MENU_BORDER] = { { PANEL_SHIPYARD, STAGE_SHIPYARD, 111 }, { PANEL_BOATHOUSE, STAGE_BOATHOUSE, 111 }, { PANEL_BEACH, STAGE_BEACH, 111 } } + MENUS[MENU_SA] = { { PANEL_BASE, STAGE_BASE, 111 }, { PANEL_NIGHTVISION, STAGE_NIGHTVISION, 111 }, { PANEL_TOWN, STAGE_TOWN, 111 } } + end + if MENU_ROOKIE ~= nil then + MENUS = { [MENU_ROOKIE] = { { PANEL_PRACTICE, STAGE_PRACTICE, 98 }, { PANEL_STORE, STAGE_STORE, 99 }, { PANEL_GANG, STAGE_GANG, 99 }, { PANEL_WAREHOUSE, STAGE_WAREHOUSE, 97 } }, + [MENU_UNDERCOVER] = { { PANEL_AIRPORT, STAGE_AIRPORT, 104 }, { PANEL_GARAGE, STAGE_GARAGE, 104 }, { PANEL_STRIPCLUB, STAGE_STRIPCLUB, 104 } }, + [MENU_SWAT] = { { PANEL_DRUGS1, STAGE_DRUGS1, 111 }, { PANEL_DRUGS2, STAGE_DRUGS2, 111 }, { PANEL_BANK, STAGE_ROBBERY, 103 } }, + [MENU_DELTA] = { { PANEL_HANGAR, STAGE_HANGAR, 108 }, { PANEL_AMBUSH, STAGE_AMBUSH, 103 }, { PANEL_AIRTERRORISTS, STAGE_AIRTERRORISTS, 109 } } } + end + + for _, entry in ipairs(MENUS[iCurrentMenu] or {}) do + if not panel[entry[2]] then + targets = { lineMiddle(area[entry[1]], entry[3]) } + break + end + end + elseif (GetArrayPanels ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and area then + -- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of + -- his panel's rectangle. + for _, stage in ipairs({ STAGE_HARRY, STAGE_DAN, STAGE_LOCO, STAGE_CACTUS }) do + local r = (panel[stage] == ATLARGE) and area[stage] and area[stage][1] + + if r then + targets = { { hmx((r[lineStartX] + r[lineEndX]) / 2), hmy((r[lineStartY] + r[lineEndY]) / 2) } } + break + end + end + elseif (levelSigns ~= nil) and (currentLevel == levelSigns) and (lvlState == lvlEnd) then + -- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates. + targets = { { 175, 101 } } + targetStep = lvlEnd + elseif (iLastMove ~= nil) and (levelFinal ~= nil) and (currentLevel == levelFinal) and (lvlState == lvlPlayClip2) then + -- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own + -- pointer coordinates. + local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } } + local b = STAND[iLastMove] or STAND[3] + + targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } } + targetStep = lvlPlayClip2 + elseif (practiceHit ~= nil) and (currentLevel == levelPractice) and (lvlState == lvlRunning) then + -- Crime Patrol's practice range: the first standing target of the still, in the game's + -- own pointer coordinates, or the cutout of the move (the middle line of its outline). + if currentMove == 0 then + local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } } + + for _, t in ipairs(STILL) do + if _G[t[1]] then + targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } } + break + end + end + elseif (currentMove >= 1) and move and move[currentMove] and target then + local kind = move[currentMove][rndMoveType] + local counts = { [BADGUY01] = 98, [CIV01] = 82, [BADGUY02] = 97, [CIV02] = 107, [BADGUY03] = 79, [CIV03] = 148 } + + if kind and counts[kind] and target[kind] then + targets = { lineMiddle(target[kind], counts[kind]) } + end + end + elseif (levelFirePractice ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and panel then + -- Space Pirates's planet menu: the first panel not yet done, in the order the game takes + -- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the + -- game scales them). + local PANELS = { { STAGE_FELLINA, 807, 1280, 565, 916 }, { STAGE_REAPER, 245, 720, 565, 916 }, { STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { STAGE_MOUNTAIN, 245, 720, 100, 450 } } + + for _, p in ipairs(PANELS) do + if panel[p[1]] == false then + targets = { { UnX * (p[2] + p[3]) / 2, UnY * (p[4] + p[5]) / 2 } } + break + end + end + elseif (levelDoor2 ~= nil) and (currentLevel == levelDoor2) and (lvlState == lvlPlayClip4) and band then + -- Space Pirates's band of colour: the right one, within its frames, by the middle of its + -- line. + local row = band[iBand] and band[iBand][1] + + if row and (discGetFrame() >= bandFrameStart + 3) and (discGetFrame() < bandFrameEnd) then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + targetStep = lvlPlayClip4 + end + elseif (levelDoor3 ~= nil) and (currentLevel == levelDoor3) and (lvlState == lvlPlayClip3) and (ursie or (ursulaHit ~= nil)) then + -- Space Pirates's commander: Ursula shot within her colour's frames. + local WINDOWS = { [COLOR_RED] = { 2243, 2306 }, [COLOR_GREEN] = { 2312, 2371 }, [COLOR_BLUE] = { 2377, 2439 } } + local w = WINDOWS[iUrsulaColor] + local rel = discGetFrame() - thisOffset + + if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then + -- (The one-player edition builds her rectangles inside its hit test, so before the first + -- shot the game's own numbers stand in.) + local box = (ursie and ursie[1]) or { 152, 137, 210, 238 } + + targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } } + targetStep = lvlPlayClip3 + end + elseif (levelFellina1 ~= nil) and (currentLevel == levelFellina1) and ((lvlState == lvlPlayPuzzle1) or (lvlState == lvlPlayPuzzle2)) and thingie then + -- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of + -- the game's own line. + local which = (lvlState == lvlPlayPuzzle1) and iFellinaSkull or iFellinaArrow + local row = thingie[which] and thingie[which][1] + + if row and ((lvlState == lvlPlayPuzzle2) or (discGetFrame() >= iSkullStart + 3)) then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + targetStep = lvlState + end + elseif (levelCrystalBuild ~= nil) and (currentLevel == levelCrystalBuild) and (lvlState == lvlRunning) and hole and socket then + -- Space Pirates's crystal build: the next socket in the order shown. + local ORDERS = { [ORDER_RGB] = { SOCKET_RED, SOCKET_GREEN, SOCKET_BLUE }, [ORDER_GBR] = { SOCKET_GREEN, SOCKET_BLUE, SOCKET_RED }, [ORDER_BRG] = { SOCKET_BLUE, SOCKET_RED, SOCKET_GREEN } } + + for _, which in ipairs(ORDERS[iCrystalOrder] or {}) do + if not socket[which] then + local row = hole[which] and hole[which][1] + + if row then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + end + break + end + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMenu) and (lvlState == lvlRunning) and stage then + -- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the + -- clues are all had, else the first place still open (the casino, the pool hall, the + -- warehouse, the garage), by the middle of its panel. + local which = nil + + if bKillerRevealed then + which = ({ [JILL] = PLACE_CASINO, [MUMPS] = PLACE_POOL, [MEASLES] = PLACE_WAREHOUSE, [POX] = PLACE_GARAGE })[iKiller] + elseif bGotAllClues then + which = PLACE_MANSION + elseif not stage[levelCasino] then + which = PLACE_CASINO + elseif not stage[levelPool] then + which = PLACE_POOL + elseif not (stage[levelWarehouse1] and stage[levelWarehouse2] and stage[levelWarehouse3]) then + which = PLACE_WAREHOUSE + elseif not stage[levelGarage] then + which = PLACE_GARAGE + end + if which then + GetMapArray01() + local row = place[which] and place[which][1] + + if row then + targets = { { hmx((row[lineStartX] + row[lineEndX]) / 2), hmy((row[lineStartY] + row[lineEndY]) / 2) } } + end + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion2) and (lvlState == lvlRunning) and clue and thisItem then + -- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second + -- scale. + GetArrayItems() + local row = item[clue[thisItem]] and item[clue[thisItem]][1] + + if row then + targets = { { ResHMx2((row[lineStartX] + row[lineEndX]) / 2), ResHMy2((row[lineStartY] + row[lineEndY]) / 2) } } + end + elseif (GetMapArray01 ~= nil) and (currentLevel == levelMansion4) and (lvlState == lvlRunning) and combination and iSafeNumber then + -- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit + -- (on this frame and the next, since the press is judged on the next). + local digit = tonumber(string.sub(combination, iSafeNumber, iSafeNumber)) + local rel = discGetFrame() - thisOffset + + if (getSafeNumber(rel) == digit) and (getSafeNumber(rel + 1) == digit) then + targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } } + end + elseif (GUIDE_BEAVER ~= nil) and (ResHMx ~= nil) and (currentLevel == levelMenu) and panel then + -- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel. + targets = {} + local x = (panel[GUIDE_BEAVER] == false) and 90 or ((panel[GUIDE_BONNIE] == false) and 186 or ((panel[GUIDE_PROF] == false) and 282 or nil)) + + if x then + targets[1] = { hmx(x), hmy(121) } + end + end + + -- At the attract loop: Mad Dog's state machine names it currentState, Mad Dog II's gameflow. + local playing = ((currentState or gameflow) == statePlaying) + local intro = playing and (currentLevel == (lvlIntro or levelIntro)) + + -- The service screen: the option under the cursor and every dip, for the trace; a screen + -- the stick drives rather than the pointer (Hologram Time Traveler). + local dips = {} + + for key, value in pairs(_G) do + if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then + dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value) + end + end + table.sort(dips) + + return { screen = currentLevel, step = lvlState, move = currentMove, frame = discGetFrame(), frames = frames, score = iScore, lives = iLives or iMoney, ammo = iBullets, active = p1Active, intro = intro, + service = (levelService ~= nil) and (currentLevel == levelService), serviceKeys = (TURNRIGHTSHOOT ~= nil), opt = optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(), + board = (levelHighScore ~= nil) and (currentLevel == levelHighScore), + window = window, box = window and firstBox() or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (currentLevel == levelContinue), noSelect = (mouseIndexSelect == nil), typing = typing, typed = (validKey ~= nil), offscreen = { -5, -5 }, free = bAmmo, paused = bPause, + -- The one-player editions (no player select, lives rather than money) spend a shot + -- before the level tests for ammo, so their last shot is never judged: a spare to keep. + spare = ((mouseIndexSelect == nil) and (iMoney == nil) and (iBullets ~= nil) and ((panelHit == nil) or (resX ~= nil)) and (levelCharlie == nil)) and 1 or 0, + -- The library's one-player Crime Patrol family judges a shot before the release takes its + -- bullet (its HD twins take it on the press, as the others do), and reloads by its second + -- button (immediately, as its config ships); so does Platoon. + reloadSwitch = (((panelHit ~= nil) and (mouseIndexSelect == nil)) or (levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + algDrive(view()) + + return result +end diff --git a/testScripts/ports/algPort.singe b/testScripts/ports/algPort.singe new file mode 100644 index 0000000..49b5f8e --- /dev/null +++ b/testScripts/ports/algPort.singe @@ -0,0 +1,503 @@ +-- The Forge port of an American Laser Games title, driven and traced as the original is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest/hypseus") +local game = os.getenv("PORT_GAME") or "maddog-hd" +local name = (os.getenv("PORT_SCRIPT") or ("singe/" .. game .. "/" .. game .. ".singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "algDrive.singe") + +-- The game seeds its random draws from os.time at load; the original's driver pins it, and so +-- does this, to the same number. +os.time = function(...) return 12345 end + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + algTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + algTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. game .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +function algPress(switch, device, down) + if down then + authorSwitchDown(switch, device) + else + authorSwitchUp(switch, device) + end +end + + +-- A key released, as the typing edition reads it: the runtime hands a keysym over as a switch. +function algKey(keysym) + authorSwitchUp(keysym) +end + +function algPointer(device, x, y) + local q = authorEntity("game").q + local rx = (x + (q.ratioxOffset or 0)) / ((q.ratiox or 1) * (q.gunscale or 1)) + local ry = (y + (q.ratioyOffset or 0)) / ((q.ratioy or 1) * (q.gunscale or 1)) + + onMouseMoved(math.floor(rx), math.floor(ry), 0, 0, device) +end + + +-- The hitmap's coordinates scaled as the game scales them, where it does (the multiplayer +-- editions); the one-player editions aim in the hitmap's own. +local function hmx(v) + local q = authorEntity("game").q + + if q.ResHMx then + return q.ResHMx(v) + end + return v +end + + +local function hmy(v) + local q = authorEntity("game").q + + if q.ResHMy then + return q.ResHMy(v) + end + return v +end + + +-- The hitboxes of a frame in the current move's hitmap range, in pointer coordinates. +local function boxesAt(q, rel) + local boxes = {} + + for k = (q.move[q.currentMove][q.hitmapStart or q.hitboxStart] or 1), (q.hitmapTotal or 0) do + local row = q.hitmap[k] + + if row and (row[q.hitmapFrame] == rel) and (row[q.hitmapCount] > 0) then + for j = row[q.hitmapIndex], row[q.hitmapIndex] + row[q.hitmapCount] - 1 do + local b = q.hitbox[j] + + -- (A box given back to front -- Platoon's hitbox lines are, often -- can never be + -- hit by the game's own test, so it is no aim.) + if b and (b[q.bbx1] <= b[q.bbx2]) and (b[q.bby1] <= b[q.bby2]) then + boxes[#boxes + 1] = { hmx(b[q.bbx1]), hmy(b[q.bby1]), hmx(b[q.bbx2]), hmy(b[q.bby2]) } + end + end + break + end + end + + return boxes +end + + +local function firstBox(q) + -- The first hitbox of this frame, aimed at a point that also lies inside a box of the next + -- frame when the two overlap: the press the drive makes now is judged on this frame or the + -- next, as the clock falls. Without an overlap (a fast target) the box of this frame stands. + if (q.move == nil) or (q.move[q.currentMove] == nil) or (q.hitmap == nil) then + return nil + end + local rel = q.currentFrame - (q.thisOffset or 0) + local now = boxesAt(q, rel) + local b = now[1] + + if b == nil then + return nil + end + for _, n in ipairs(boxesAt(q, rel + 1)) do + local x1, y1, x2, y2 = math.max(b[1], n[1]), math.max(b[2], n[2]), math.min(b[3], n[3]), math.min(b[4], n[4]) + + if (x1 <= x2) and (y1 <= y2) then + return { x1, y1, x2, y2 } + end + end + + return b +end + + +local function view() + local q = authorEntity("game").q + local window = nil + + -- A level of Mad Dog's (101 to 121) or of Mad Dog II's (its trails, and levelStart); Mad Dog II's + -- move rows have no shoot window of their own, so the move's frames stand in. + local inLevel = (q.currentLevel ~= nil) and (((q.frameShootStart ~= nil) and (q.currentLevel >= 101) and (q.currentLevel <= 121)) or ((q.frameShootStart == nil) and (((q.currentLevel >= 1) and (q.currentLevel <= 30)) or (q.currentLevel == q.levelStart)))) + + if ((q.practiceHit ~= nil) or (q.fRndPopOrder ~= nil)) and (q.currentLevel == q.levelPractice) then + inLevel = false + end + if (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) then + inLevel = false + end + if (q.levelTieIntro ~= nil) and ((q.currentLevel == q.levelTie) or (q.currentLevel == q.levelTieIntro)) then + inLevel = false + end + + -- Space Pirates's puzzles and crystals are targets, not windows: the band and Ursula's colour + -- at the doors, Fellina's skull and arrow, the sockets of the crystal build. + if (q.levelCrystalBuild ~= nil) and ((q.currentLevel == q.levelCrystalBuild) or ((q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4)) or ((q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3)) or ((q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)))) then + inLevel = false + end + + -- Who Shot Johnny Rock's store, clue items, and safe are targets, not windows. + if (q.GetMapArray01 ~= nil) and ((q.currentLevel == q.levelStore) or (q.currentLevel == q.levelMansion2) or (q.currentLevel == q.levelMansion4)) then + inLevel = false + end + + -- Space Pirates's Reaper wants exactly as many hits as he laughed: once the drive has them, + -- the moves left are let pass. + if (q.levelReaper2 ~= nil) and (q.currentLevel == q.levelReaper2) and (q.currentMove > 1) and (q.iReaperHits >= q.iReaperLaughs) then + inLevel = false + end + + -- The typing edition of Mad Dog II plays on the keyboard: the key the game waits for now (the + -- next letter of the word, a sprinkled letter, the return key at a menu), and no gun at all. + local typing = nil + + if q.validKey ~= nil then + inLevel = false + local function nextOf(word) + if word and (q.iKeyIndex or 1) <= string.len(word) then + return string.byte(word, q.iKeyIndex or 1) + end + return nil + end + -- (The game starts with the key slot at zero, not at none, and never clears it.) + local waiting = (q.iKey == q.SWITCH_NONE) or (q.iKey == 0) + + if not waiting then + typing = nil + elseif (q.currentLevel == q.levelTitle) and (q.lvlState == q.lvlRunning) then + typing = q.SWITCH_RETURN + elseif (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) then + typing = q.SWITCH_RETURN + elseif (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning) then + -- (Once, before the name's frames run out; the return key seeks to the entry's end.) + if discGetFrame() < q.offsetMenus + 3275 then + typing = q.SWITCH_RETURN + end + elseif q.bShowSprinkles and q.sprinkles then + for k = 1, (q.iSprinkleMax or 0) do + if q.sprinkles[k] and (q.sprinkles[k][4] == true) then + typing = string.byte(q.sprinkles[k][3]) + break + end + end + elseif (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then + typing = nextOf(q.sMagicWord) + elseif q.move and q.move[q.currentMove] and ((q.lvlState == q.lvlPauseAction) or ((q.lvlState == q.lvlRunning) and (discGetFrame() >= q.move[q.currentMove][q.moveFrameStart]) and (discGetFrame() < q.move[q.currentMove][q.moveFrameEnd]))) then + typing = nextOf(q.move[q.currentMove][(q.currentLevel == q.levelItem2) and q.moveType or q.wordval]) + end + end + + -- Platoon's patrols open with a clip the first press of the trigger jumps past, whatever the + -- state: no window until that press is in, and a target anywhere to make it. + local skipping = (q.levelCharlie ~= nil) and q.bOnce and (q.currentLevel ~= nil) and (q.currentLevel >= 1) and (q.currentLevel <= 6) and (q.lvlState == q.lvlRunning) + + if skipping then + inLevel = false + end + + -- A good guy's move (the Crime Patrol family marks the row) is no window: shooting him is a + -- death, and the drive would take it at every level that has one. + if inLevel and q.move and q.move[q.currentMove] and (q.rndMoveType ~= nil) and (q.GOODGUY ~= nil) and (q.move[q.currentMove][q.rndMoveType] == q.GOODGUY) then + inLevel = false + end + if inLevel and q.move and q.move[q.currentMove] then + window = { q.move[q.currentMove][q.frameShootStart or q.moveFrameStart], q.move[q.currentMove][q.frameShootEnd or q.moveFrameEnd] } + end + local quadrants = nil + + if (q.currentLevel == q.levelTownSelect) and q.stage then + quadrants = {} + local w, h = q.resX or 360, q.resY or 240 + + -- In the order the game itself takes when the select times out: the saloon before the + -- sheriff, whose level needs the key from it. + if not q.stage[q.STAGE_CORRAL] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.25 } end + if not q.stage[q.STAGE_SALOON] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.25 } end + if not q.stage[q.STAGE_SHERIFF] then quadrants[#quadrants + 1] = { w * 0.25, h * 0.75 } end + if not q.stage[q.STAGE_BANK] then quadrants[#quadrants + 1] = { w * 0.75, h * 0.75 } end + end + + -- The signpost's sign and the mine's lantern and item, as a list of points to shoot in + -- order (the middle line of each, scaled as the game scales them). + local targets = nil + local targetStep = nil + local function lineMiddle(rows, count) + local row = rows and rows[math.floor((count + 1) / 2)] + + if row then + return { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy(row[q.lineY]) } + end + return nil + end + + if skipping then + targets = { { 180, 120 } } + targetStep = q.lvlRunning + elseif (q.currentLevel == q.levelSignSelect) and q.sign then + targets = {} + if q.stage[q.STAGE_FUSE] == false then + targets[1] = lineMiddle(q.sign[q.SIGN_PROSPECTOR], 48) + elseif q.stage[q.STAGE_MINE] == false then + targets[1] = lineMiddle(q.sign[q.SIGN_MINE], 28) + else + targets[1] = lineMiddle(q.sign[q.SIGN_HIDEOUT], 58) + end + elseif (q.currentLevel == q.levelMine) and q.mine then + targets = {} + local counts = { [q.MINE_SIGN] = 21, [q.MINE_LANTERN] = 20, [q.MINE_PAN] = 12, [q.MINE_PITCHFORK] = 23, [q.MINE_SKULL] = 25 } + + if not q.bLanternHit then targets[#targets + 1] = lineMiddle(q.mine[q.MINE_LANTERN], counts[q.MINE_LANTERN]) end + if not q.bItemHit then targets[#targets + 1] = lineMiddle(q.mine[q.iWhatToHit], counts[q.iWhatToHit]) end + elseif (q.currentLevel == q.levelMaze) and q.maze and q.maze[q.curMazePos] and (q.maze[q.curMazePos][q.mazeType] > 0) and q.sign then + -- A maze sign: the sign for the way to go. + local frame, dir = q.maze[q.curMazePos][q.signType], q.maze[q.curMazePos][q.signDirection] + local counts = { [32] = { 51, 49 }, [175] = { 59, 17 }, [340] = { 67, 90 }, [500] = { 24, 42 }, [630] = { 11, 30 }, [446] = { 59, 64 } } + local pair = counts[frame] + + if pair and q.sign[dir] then + targets = { lineMiddle(q.sign[dir], pair[dir]) } + end + elseif (q.levelItem1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelItem1) and (q.lvlState == q.lvlPlayClip3) then + -- Mad Dog II's first item level: the item the guide asked for, from the game's own + -- rectangles. + local boxes = { [q.ITEM_LANTERN] = { 57, 66, 117, 129 }, [q.ITEM_AMMOBOX] = { 120, 148, 111, 132 }, [q.ITEM_MOONSHINE] = { 260, 272, 60, 72 } } + local b = boxes[q.iThisItem] + + if b then + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = q.lvlPlayClip3 + end + elseif (q.levelProspector1 ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelProspector1) and (q.lvlState == q.lvlPlayClip4) then + -- Mad Dog II's prospector: the skull he chose, from the game's own rectangles. + local boxes = { { 305, 315, 59, 76 }, { 68, 75, 73, 82 }, { 147, 154, 58, 67 }, { 119, 127, 101, 115 } } + local b = boxes[q.iSkull] or boxes[4] + + targets = { { hmx((b[1] + b[2]) / 2), hmy((b[3] + b[4]) / 2) } } + targetStep = q.lvlPlayClip4 + elseif (q.panelHit ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then + -- Crime Patrol's mission menus: the first panel whose stage is open, by the middle line + -- of its area (the count of lines is the game's own). + local MENUS = {} + + if q.MENU_SIERRA ~= nil then + MENUS[q.MENU_SIERRA] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 99 }, { q.PANEL_BAR, q.STAGE_BAR, 99 }, { q.PANEL_CARCHASE, q.STAGE_CARCHASE, 99 }, { q.PANEL_HIDEOUT, q.STAGE_HIDEOUT, 99 } } + MENUS[q.MENU_CHICAGO] = { { q.PANEL_HELI, q.STAGE_HELI, 111 }, { q.PANEL_COURTROOM, q.STAGE_COURTROOM, 111 }, { q.PANEL_BUSCHASE, q.STAGE_BUSCHASE, 111 } } + MENUS[q.MENU_BORDER] = { { q.PANEL_SHIPYARD, q.STAGE_SHIPYARD, 111 }, { q.PANEL_BOATHOUSE, q.STAGE_BOATHOUSE, 111 }, { q.PANEL_BEACH, q.STAGE_BEACH, 111 } } + MENUS[q.MENU_SA] = { { q.PANEL_BASE, q.STAGE_BASE, 111 }, { q.PANEL_NIGHTVISION, q.STAGE_NIGHTVISION, 111 }, { q.PANEL_TOWN, q.STAGE_TOWN, 111 } } + end + if q.MENU_ROOKIE ~= nil then + MENUS = { [q.MENU_ROOKIE] = { { q.PANEL_PRACTICE, q.STAGE_PRACTICE, 98 }, { q.PANEL_STORE, q.STAGE_STORE, 99 }, { q.PANEL_GANG, q.STAGE_GANG, 99 }, { q.PANEL_WAREHOUSE, q.STAGE_WAREHOUSE, 97 } }, + [q.MENU_UNDERCOVER] = { { q.PANEL_AIRPORT, q.STAGE_AIRPORT, 104 }, { q.PANEL_GARAGE, q.STAGE_GARAGE, 104 }, { q.PANEL_STRIPCLUB, q.STAGE_STRIPCLUB, 104 } }, + [q.MENU_SWAT] = { { q.PANEL_DRUGS1, q.STAGE_DRUGS1, 111 }, { q.PANEL_DRUGS2, q.STAGE_DRUGS2, 111 }, { q.PANEL_BANK, q.STAGE_ROBBERY, 103 } }, + [q.MENU_DELTA] = { { q.PANEL_HANGAR, q.STAGE_HANGAR, 108 }, { q.PANEL_AMBUSH, q.STAGE_AMBUSH, 103 }, { q.PANEL_AIRTERRORISTS, q.STAGE_AIRTERRORISTS, 109 } } } + end + + for _, entry in ipairs(MENUS[q.iCurrentMenu] or {}) do + if not q.panel[entry[2]] then + targets = { lineMiddle(q.area[entry[1]], entry[3]) } + break + end + end + elseif (q.GetArrayPanels ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.area then + -- The Last Bounty Hunter's wanted posters: the first outlaw still at large, by the middle of + -- his panel's rectangle. + for _, stage in ipairs({ q.STAGE_HARRY, q.STAGE_DAN, q.STAGE_LOCO, q.STAGE_CACTUS }) do + local r = (q.panel[stage] == q.ATLARGE) and q.area[stage] and q.area[stage][1] + + if r then + targets = { { hmx((r[q.lineStartX] + r[q.lineEndX]) / 2), hmy((r[q.lineStartY] + r[q.lineEndY]) / 2) } } + break + end + end + elseif (q.levelSigns ~= nil) and (q.currentLevel == q.levelSigns) and (q.lvlState == q.lvlEnd) then + -- The Last Bounty Hunter's signs: the practice range, in the game's own pointer coordinates. + targets = { { 175, 101 } } + targetStep = q.lvlEnd + elseif (q.iLastMove ~= nil) and (q.levelFinal ~= nil) and (q.currentLevel == q.levelFinal) and (q.lvlState == q.lvlPlayClip2) then + -- Drug Wars's last stand: the tank, the blanket, or the gas cans, in the game's own + -- pointer coordinates. + local STAND = { { 262, 329, 133, 172 }, { 101, 111, 160, 188 }, { 209, 243, 201, 215 } } + local b = STAND[q.iLastMove] or STAND[3] + + targets = { { (b[1] + b[2]) / 2, (b[3] + b[4]) / 2 } } + targetStep = q.lvlPlayClip2 + elseif (q.practiceHit ~= nil) and (q.currentLevel == q.levelPractice) and (q.lvlState == q.lvlRunning) then + -- Crime Patrol's practice range: the first standing target of the still, in the game's + -- own pointer coordinates, or the cutout of the move (the middle line of its outline). + if q.currentMove == 0 then + local STILL = { { "bTopRight", 261, 300, 9, 60 }, { "bTopLeft", 78, 119, 70, 120 }, { "bMiddle", 205, 243, 70, 123 }, { "bBottomLeft", 120, 158, 124, 181 }, { "bBottomRight", 268, 297, 132, 183 } } + + for _, t in ipairs(STILL) do + if q[t[1]] then + targets = { { (t[2] + t[3]) / 2, (t[4] + t[5]) / 2 } } + break + end + end + elseif (q.currentMove >= 1) and q.move and q.move[q.currentMove] and q.target then + local kind = q.move[q.currentMove][q.rndMoveType] + local counts = { [q.BADGUY01] = 98, [q.CIV01] = 82, [q.BADGUY02] = 97, [q.CIV02] = 107, [q.BADGUY03] = 79, [q.CIV03] = 148 } + + if kind and counts[kind] and q.target[kind] then + targets = { lineMiddle(q.target[kind], counts[kind]) } + end + end + elseif (q.levelFirePractice ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.panel then + -- Space Pirates's planet menu: the first panel not yet done, in the order the game takes + -- when the clip runs out, by the middle of its quadrant (pointer coordinates scaled as the + -- game scales them). + local PANELS = { { q.STAGE_FELLINA, 807, 1280, 565, 916 }, { q.STAGE_REAPER, 245, 720, 565, 916 }, { q.STAGE_SCRAPYARD, 807, 1280, 100, 450 }, { q.STAGE_MOUNTAIN, 245, 720, 100, 450 } } + + for _, p in ipairs(PANELS) do + if q.panel[p[1]] == false then + targets = { { q.UnX * (p[2] + p[3]) / 2, q.UnY * (p[4] + p[5]) / 2 } } + break + end + end + elseif (q.levelDoor2 ~= nil) and (q.currentLevel == q.levelDoor2) and (q.lvlState == q.lvlPlayClip4) and q.band then + -- Space Pirates's band of colour: the right one, within its frames, by the middle of its + -- line. + local row = q.band[q.iBand] and q.band[q.iBand][1] + + if row and (discGetFrame() >= q.bandFrameStart + 3) and (discGetFrame() < q.bandFrameEnd) then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + targetStep = q.lvlPlayClip4 + end + elseif (q.levelDoor3 ~= nil) and (q.currentLevel == q.levelDoor3) and (q.lvlState == q.lvlPlayClip3) and (q.ursie or (q.ursulaHit ~= nil)) then + -- Space Pirates's commander: Ursula shot within her colour's frames. + local WINDOWS = { [q.COLOR_RED] = { 2243, 2306 }, [q.COLOR_GREEN] = { 2312, 2371 }, [q.COLOR_BLUE] = { 2377, 2439 } } + local w = WINDOWS[q.iUrsulaColor] + local rel = discGetFrame() - q.thisOffset + + if w and (rel >= w[1] + 3) and (rel <= w[2] - 3) then + -- (The one-player edition builds her rectangles inside its hit test, so before the first + -- shot the game's own numbers stand in.) + local box = (q.ursie and q.ursie[1]) or { 152, 137, 210, 238 } + + targets = { { hmx((box[1] + box[3]) / 2), hmy((box[2] + box[4]) / 2) } } + targetStep = q.lvlPlayClip3 + end + elseif (q.levelFellina1 ~= nil) and (q.currentLevel == q.levelFellina1) and ((q.lvlState == q.lvlPlayPuzzle1) or (q.lvlState == q.lvlPlayPuzzle2)) and q.thingie then + -- Space Pirates's Fellina puzzles: the skull she chose, then the arrow, by the middle of + -- the game's own line. + local which = (q.lvlState == q.lvlPlayPuzzle1) and q.iFellinaSkull or q.iFellinaArrow + local row = q.thingie[which] and q.thingie[which][1] + + if row and ((q.lvlState == q.lvlPlayPuzzle2) or (discGetFrame() >= q.iSkullStart + 3)) then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + targetStep = q.lvlState + end + elseif (q.levelCrystalBuild ~= nil) and (q.currentLevel == q.levelCrystalBuild) and (q.lvlState == q.lvlRunning) and q.hole and q.socket then + -- Space Pirates's crystal build: the next socket in the order shown. + local ORDERS = { [q.ORDER_RGB] = { q.SOCKET_RED, q.SOCKET_GREEN, q.SOCKET_BLUE }, [q.ORDER_GBR] = { q.SOCKET_GREEN, q.SOCKET_BLUE, q.SOCKET_RED }, [q.ORDER_BRG] = { q.SOCKET_BLUE, q.SOCKET_RED, q.SOCKET_GREEN } } + + for _, which in ipairs(ORDERS[q.iCrystalOrder] or {}) do + if not q.socket[which] then + local row = q.hole[which] and q.hole[which][1] + + if row then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + break + end + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMenu) and (q.lvlState == q.lvlRunning) and q.stage then + -- Who Shot Johnny Rock's city map: the killer's place once revealed, the mansion once the + -- clues are all had, else the first place still open (the casino, the pool hall, the + -- warehouse, the garage), by the middle of its panel. + local which = nil + + if q.bKillerRevealed then + which = ({ [q.JILL] = q.PLACE_CASINO, [q.MUMPS] = q.PLACE_POOL, [q.MEASLES] = q.PLACE_WAREHOUSE, [q.POX] = q.PLACE_GARAGE })[q.iKiller] + elseif q.bGotAllClues then + which = q.PLACE_MANSION + elseif not q.stage[q.levelCasino] then + which = q.PLACE_CASINO + elseif not q.stage[q.levelPool] then + which = q.PLACE_POOL + elseif not (q.stage[q.levelWarehouse1] and q.stage[q.levelWarehouse2] and q.stage[q.levelWarehouse3]) then + which = q.PLACE_WAREHOUSE + elseif not q.stage[q.levelGarage] then + which = q.PLACE_GARAGE + end + if which then + q.GetMapArray01() + local row = q.place[which] and q.place[which][1] + + if row then + targets = { { hmx((row[q.lineStartX] + row[q.lineEndX]) / 2), hmy((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion2) and (q.lvlState == q.lvlRunning) and q.clue and q.thisItem then + -- Who Shot Johnny Rock's clue items: the item of the clue asked for, in the game's second + -- scale. + q.GetArrayItems() + local row = q.item[q.clue[q.thisItem]] and q.item[q.clue[q.thisItem]][1] + + if row then + targets = { { q.ResHMx2((row[q.lineStartX] + row[q.lineEndX]) / 2), q.ResHMy2((row[q.lineStartY] + row[q.lineEndY]) / 2) } } + end + elseif (q.GetMapArray01 ~= nil) and (q.currentLevel == q.levelMansion4) and (q.lvlState == q.lvlRunning) and q.combination and q.iSafeNumber then + -- Who Shot Johnny Rock's safe: the dial shot while it shows the combination's next digit + -- (on this frame and the next, since the press is judged on the next). + local digit = tonumber(string.sub(q.combination, q.iSafeNumber, q.iSafeNumber)) + local rel = discGetFrame() - q.thisOffset + + if (q.getSafeNumber(rel) == digit) and (q.getSafeNumber(rel + 1) == digit) then + targets = { { hmx((151 + 207) / 2), hmy((64 + 88) / 2) } } + end + elseif (q.GUIDE_BEAVER ~= nil) and (q.ResHMx ~= nil) and (q.currentLevel == q.levelMenu) and q.panel then + -- Mad Dog II's guide menu: the first guide whose trail is not done, by its panel. + targets = {} + local x = (q.panel[q.GUIDE_BEAVER] == false) and 90 or ((q.panel[q.GUIDE_BONNIE] == false) and 186 or ((q.panel[q.GUIDE_PROF] == false) and 282 or nil)) + + if x then + targets[1] = { hmx(x), hmy(121) } + end + end + + local playing = ((q.currentState or q.gameflow) == q.statePlaying) + local intro = playing and (q.currentLevel == (q.lvlIntro or q.levelIntro)) + + -- The service screen: the option under the cursor and every dip, for the trace; a screen + -- the stick drives rather than the pointer (Hologram Time Traveler). + local dips = {} + + for key, value in pairs(q) do + if (type(key) == "string") and (key:sub(1, 4) == "dip_") and (type(value) ~= "function") then + dips[#dips + 1] = key:sub(5) .. "=" .. tostring(value) + end + end + table.sort(dips) + + return { screen = q.currentLevel, step = q.lvlState, move = q.currentMove, frame = discGetFrame(), frames = frames, score = q.iScore, lives = q.iLives or q.iMoney, ammo = q.iBullets, active = q.p1Active, intro = intro, + service = (q.levelService ~= nil) and (q.currentLevel == q.levelService), serviceKeys = (q.TURNRIGHTSHOOT ~= nil), opt = q.optSel, dips = table.concat(dips, ","), width = overlayGetWidth(), height = overlayGetHeight(), + board = (q.levelHighScore ~= nil) and (q.currentLevel == q.levelHighScore), + window = window, box = window and firstBox(q) or nil, quadrants = quadrants, targets = targets, targetStep = targetStep, continuing = (q.currentLevel == q.levelContinue), noSelect = (q.mouseIndexSelect == nil), typing = typing, typed = (q.validKey ~= nil), offscreen = { -5, -5 }, free = q.bAmmo, paused = q.bPause, + -- The one-player editions (no player select, lives rather than money) spend a shot + -- before the level tests for ammo, so their last shot is never judged: a spare to keep. + spare = ((q.mouseIndexSelect == nil) and (q.iMoney == nil) and (q.iBullets ~= nil) and ((q.panelHit == nil) or (q.resX ~= nil)) and (q.levelCharlie == nil)) and 1 or 0, + -- The library's one-player Crime Patrol family judges a shot before the release takes its + -- bullet (its HD twins take it on the press, as the others do), and reloads by its second + -- button (immediately, as its config ships); so does Platoon. + reloadSwitch = (((q.panelHit ~= nil) and (q.mouseIndexSelect == nil)) or (q.levelCharlie ~= nil)) and SWITCH_BUTTON1 or SWITCH_BUTTON3 } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + algDrive(view()) + + return result +end diff --git a/testScripts/ports/compare.sh b/testScripts/ports/compare.sh new file mode 100755 index 0000000..03f86c6 --- /dev/null +++ b/testScripts/ports/compare.sh @@ -0,0 +1,234 @@ +#!/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 new file mode 100644 index 0000000..9cc21b5 --- /dev/null +++ b/testScripts/ports/karisDrive.singe @@ -0,0 +1,776 @@ +-- 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 new file mode 100644 index 0000000..f7cf837 --- /dev/null +++ b/testScripts/ports/karisOriginal.singe @@ -0,0 +1,107 @@ +-- 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 new file mode 100644 index 0000000..7201c22 --- /dev/null +++ b/testScripts/ports/karisPort.singe @@ -0,0 +1,106 @@ +-- 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 new file mode 100755 index 0000000..6f7cbd8 --- /dev/null +++ b/testScripts/ports/launch.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Launches every port in the ports folder from its games.dat, headless on a virtual screen and +# the deterministic clock, for a while each, and counts the runs that printed no error +# (FORGE.md section 14.8). Coverage, not a comparison: compare.sh is the comparison. +# +# testScripts/ports/launch.sh [<ports folder>] +# +# SINGE the engine binary (default: the newest build in .builddir) +# SINGE_RUN a directory to run in (default: a fresh one under /tmp) +# SECONDS_EACH how long each port runs (default: 25) +# JOBS ports run at once (default: 2; four 1080p games on Xvfb ran a +# 30 GB machine out of memory) +set -u +ports=${1:-$HOME/claude/singePorts} +repo=$(cd "$(dirname "$0")/../.." && pwd) +binary=${SINGE:-$(ls -t "$repo"/.builddir/linux-gcc/*/singe/Singe-v* "$repo"/.builddir/linux/x86_64/singe/Singe-v* 2>/dev/null | head -1)} +run=${SINGE_RUN:-$(mktemp -d /tmp/forgePorts.XXXXXX)} +each=${SECONDS_EACH:-25} +jobs=${JOBS:-2} +if [[ -z "$binary" || ! -x "$binary" ]]; then + echo "launch.sh: no engine binary; set SINGE" >&2 + exit 2 +fi +binary=$(realpath "$binary") # The runs change directory. +# SDL's offscreen driver has a 1024x768 desktop, smaller than the KarisFramework games ask for. +export SDL_VIDEODRIVER=x11 SDL_AUDIO_DRIVER=dummy +one() { + local script=$1 video=$2 + local stem=$(basename "$script" .port.singe) + local work=$run/$stem + rm -rf "$work" + mkdir -p "$work" + ( cd "$run" && timeout -s INT "$each" xvfb-run -a -s "-screen 0 1920x1200x24" "$binary" -w -p -k --deterministic -d "$work" -v "$ports/$video" "$ports/$script" ) > "$work/run.log" 2>&1 + local rc=$? + # ffmpeg's hwaccel fallback notice and the X server going away under the timeout's INT are + # the harness, not the game. + local bad=$(grep -a "rror\|Author:\|raceback\|FAIL\|does not exist\|Unable to" "$work/run.log" | grep -vc "hwaccel\|XIO:") + if [[ "$bad" = 0 && ( $rc = 124 || $rc = 0 ) ]]; then + echo "$stem clean" + else + echo "$stem rc=$rc problems=$bad: $(grep -a "rror\|Author:\|raceback\|does not exist\|Unable to" "$work/run.log" | grep -v "hwaccel\|XIO:" | head -2 | tr '\n' ' ')" + fi +} +export -f one +export run each binary ports +for dat in "$ports"/*/games.dat; do + paste -d' ' <(grep -o 'SCRIPT *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/') <(grep -o 'VIDEO *= *"[^"]*"' "$dat" | sed 's/.*"\(.*\)"/\1/') +done | xargs -P "$jobs" -L 1 bash -c 'one $0 $1' | tee "$run/results.txt" +echo "launch.sh: $(grep -c ' clean$' "$run/results.txt") of $(wc -l < "$run/results.txt") ports ran clean; logs under $run" diff --git a/testScripts/ports/ledger.md b/testScripts/ports/ledger.md new file mode 100644 index 0000000..61fe662 --- /dev/null +++ b/testScripts/ports/ledger.md @@ -0,0 +1,242 @@ +# The ledger (FORGE.md section 17) + +Generated by `util/forgeLedger.py` on 2026-09-28 from the converter's report for each +description it made; do not edit by hand. **forge** is a field of Forge's own model, +**framework** something Forge plays under the framework's names or numbers, **lua** +something the game's own Lua still does. Rows are sorted by status, then by how many +titles use them: the top of the table is what most blocks titles from being Forge's own. +A hook from the add-ons file most titles ship unchanged (19 of them) is the framework's +stock one, and counts as framework. The globals are every global the game's script left +that nothing names: most are the framework's own state and picture numbers, not data. + +76 titles reported; 0 failed to report; 5 descriptions made by another tool (not covered). + +## Capabilities + +| Status | Capability | Titles | Some of them | Details | +|---|---|---|---|---| +| lua | script | 49 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | AlteredCarbon.singe; Arcade_Xperience_Vol1.singe; Arcade_Xperience_Vol2.singe; Arcade_Xperience_Vol3.singe; Asterix.singe; Astroboy.singe; BD13.singe; Chantze.singe; ... | +| lua | vocabulary | 27 | crimepatrol, drugwars, drugwars, crimepatrol, timetraveler, lastbountyhunter, ... | Vocabulary.singe | +| lua | levelFunctions | 11 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | Puss_in_Boots.singe; Sdq.singe; conan_singe_2.singe; daitarn_singe_2.singe; dlair.singe; dragon_singe_2.singe; freedomfighter.singe; hq-timegal.singe; ... | +| lua | hook.startConf | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | +| lua | hook.swapLevel | 6 | DL2e, DLe, SAe, BD13, Esh_Aurunmilla, hayate_1080 | Script/addons.singe | +| lua | hook.doLevelSelect | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | +| lua | hook.moveCursor | 4 | Chantze, Triad, f13, SuckerPunch | Script/addons.singe | +| lua | hook.specialScore | 3 | DL2e, DLe, BD13 | Script/addons.singe | +| lua | hook.swapDeath | 3 | SAe, BD13, hayate_1080 | Script/addons.singe | +| lua | hook.AllowLvlEnd | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.AllowLvlQuartz | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.LvlMapComplete | 2 | f13, SuckerPunch | Script/addons.singe | +| lua | hook.ShowComplete | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.TestDips | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.TestState | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.loadSavePlus | 2 | DL2e, BD13 | Script/addons.singe | +| lua | hook.mapConf | 2 | Chantze, Triad | Script/addons.singe | +| lua | hook.writeSavePlus | 2 | DL2e, BD13 | Script/addons.singe | +| framework | setting.* | 75 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | +| framework | global.* | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| framework | judge.press.direction | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | DOWN; LEFT; RIGHT; UP | +| framework | judge.press.button | 35 | AlteredCarbon, Asterix, Cliff, DL2e, DLe, Fire_and_Ice, ... | BUTTON1; BUTTON2; BUTTON3 | +| framework | judge.skip | 30 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, Fire_and_Ice, ... | SKIP | +| framework | judge.mash.one | 25 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | MASH; MASHMAX; MASHMIN | +| framework | hook.LevelMapComplete | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.doLevelSelect | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.moveCursor | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, LINEA, ... | empty; stock add-ons | +| framework | hook.swapScene | 21 | AlteredCarbon, Asterix, Cliff, Fire_and_Ice, Fire_and_Ice_v2, f13, ... | empty; stock add-ons | +| framework | judge.chord.buttonDirection | 20 | AlteredCarbon, Asterix, Chantze, Triad, Fire_and_Ice, Fire_and_Ice_v2, ... | ACTDOWN; ACTLEFT; ACTRIGHT; ACTUP | +| framework | option.style | 18 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | checkpoints; held; segments; trophies | +| framework | judge.hold.direction | 16 | AlteredCarbon, Fire_and_Ice_v2, f13, LINEA, mononoke, oeil-pour-oeil, ... | HOLDDOWN; HOLDLEFT; HOLDRIGHT; HOLDUP | +| framework | judge.mash.run | 15 | AlteredCarbon, Fire_and_Ice_v2, f13, mononoke, SuckerPunch, Titan_AE, ... | RUN; RUNMAX; RUNMIN | +| framework | judge.press.diagonal | 14 | Fire_and_Ice_v2, LINEA, mononoke, oeil-pour-oeil, SuckerPunch, the-elder-scrolls, ... | DOWNLEFT; DOWNRIGHT; UPLEFT; UPRIGHT | +| framework | judge.hold.button | 12 | AlteredCarbon, LINEA, oeil-pour-oeil, the-elder-scrolls, Tron, Arcade_Xperience_Vol2, ... | HOLDBUT | +| framework | judge.jump | 9 | DL2e, DLe, Hayate, SAe, BD13, Esh_Aurunmilla, ... | TOLEVEL; TOSCENE; WAY; WAYOUT | +| framework | judge.path | 9 | DL2e, DLe, Hayate, SAe, BD13, StarshipTroopers, ... | PATH | +| framework | persist.dieAndRetryRecords | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | | +| framework | judge.circle | 5 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Danmachi, mazinga | LOOP; LOOPLEFT; LOOPRIGHT | +| framework | judge.double | 5 | AlteredCarbon, Fire_and_Ice_v2, SuckerPunch, Tron, mazinga | DOUBLE | +| framework | judge.letGo | 5 | Fire_and_Ice_v2, mononoke, Tron, Danmachi, Esh_Aurunmilla | LETGO | +| framework | judge.mash.two | 5 | LINEA, Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy | MASH2MAX; MASH2MIN | +| framework | judge.count | 3 | LINEA, Danmachi, mazinga | MULTI | +| framework | judge.timed | 3 | DLe, SAe, badlands_lite | TIMED | +| framework | hook.swapDeath | 2 | Esh_Aurunmilla, Sintel | empty | +| framework | judge.choose | 2 | Timegal, FumaConspiracy | CHOOSE | +| framework | judge.unnamed | 2 | LINEA, Arcade_Xperience_Vol2 | kind 156 (UL); kind 157 (UR); kind 158 (DL); kind 159 (DR); kind 160 (LU); kind 162 (LD); kind 167 (LDB); kind 181 (DRU); ... | +| framework | hook.loadSavePlus | 1 | Sintel | empty | +| framework | hook.specialScore | 1 | Sintel | empty | +| framework | hook.startConf | 1 | Sintel | empty | +| framework | hook.swapLevel | 1 | Sintel | empty | +| framework | hook.writeSavePlus | 1 | Sintel | empty | +| framework | judge.anything | 1 | BD13 | ANYTHING | +| framework | judge.hold | 1 | mazinga | HOLD | +| framework | judge.sequence | 1 | LINEA | DU; LR; RL; UD | +| framework | judge.yesNo | 1 | SAe | YESNO | +| framework | order.trophies | 1 | Sdq | | +| framework | scoring.percents | 1 | Sdq | | +| framework | tweak.introHold | 1 | freedomfighter | | +| framework | tweak.rightSound | 1 | freedomfighter | | +| forge | persist.boards | 76 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, conan_singe_2, ... | | +| forge | deaths.clips | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | deaths.randomCount | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.missCost | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.regainAfter | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | lifeBar.size | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.levels | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.play | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.tiers | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | persist.saves | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.attract | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.clear | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.continue | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.enterName | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.extraDeath | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameIntro | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameOver | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.gameOverAlt | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.getReady | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.newHighScore | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | screen.title | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | secret.allowed | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.controls | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.options | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.rankings | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.save | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.special | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.trophy | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.victory | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | video.fps | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | video.relativeFrames | 38 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.hints | 37 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.scene | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | still.secret | 36 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | order.mapStart | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.deathPenalty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.extraLifeEvery | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.game | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.level | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.move | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.noDeathBonus | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.perDifficulty | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | scoring.secret | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.extreme | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.hard | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | windowDelay.normal | 35 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | | +| forge | family.qte.windows | 31 | AlteredCarbon, Asterix, Chantze, Triad, Cliff, DL2e, ... | 3.31c; 3.32b; Mazescater 1.00 | +| forge | still.easy | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.normal | 31 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.extreme | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | still.hard | 30 | AlteredCarbon, Chantze, Triad, Cliff, DL2e, DLe, ... | | +| forge | family.alg | 25 | crimepatrol, drugwars, drugwars, crimepatrol, lastbountyhunter, lastbountyhunter, ... | | +| forge | option.gameOverAfterContinue | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | +| forge | option.secretInputs | 11 | Arcade_Xperience_Vol1, Arcade_Xperience_Vol2, Arcade_Xperience_Vol3, Astroboy, Danmachi, Esh_Aurunmilla, ... | | +| forge | family.qte.segments | 9 | conan_singe_2, daitarn_singe_2, dragon_singe_2, dlair, Puss_in_Boots, jack_singe_2, ... | 1.1; 2.2 | +| forge | family.qte.held | 7 | BD13, FBC, FumaConspiracy, Sonic_the_Hedgehog_1996, hayate_1080, mazinga, ... | 4.0 | +| forge | scene.swaps | 6 | Chantze, Triad, DL2e, SuckerPunch, Tron, BD13 | read from swapScene | +| forge | screen.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | still.newGame | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | still.quit | 6 | BD13, FBC, FumaConspiracy, hayate_1080, mazinga, Sintel | | +| forge | family.qte.checkpoints | 1 | hq-timegal | | +| forge | family.qte.trophies | 1 | Sdq | 2.21 | +| forge | family.timetraveler | 1 | timetraveler | | +| forge | family.videoland | 1 | Rollercoaster | | +| forge | option.alternateMashRate | 1 | LINEA | | +| forge | option.doubleKind | 1 | LINEA | | +| forge | option.hintSeconds | 1 | LINEA | | +| forge | option.mashAnySwitch | 1 | LINEA | | +| forge | still.difficult | 1 | Titan_AE | | +| forge | still.rankingsAlt | 1 | BD13 | | + +## Settings under the framework's names (69) + +By how many titles use each: dip_LivesPerCredit 75, dip_Difficulty 74, dip_CoinsPerCredit 58, dip_LimitContinue 50, dip_ShowAction 49, dip_GameType 48, dip_StartLevel 48, dip_Rewind 47, dip_Diffshow 39, dip_Extravid 39, dip_Hints 39, dip_Res 39, dip_StartScene 39, CoinPos 38, LivPos 38, ScorePos 38, ShowLCD 38, ShowLevel 38, ShowTitle 38, ShowTop 38, dip_Display 38, dip_FontQ 38, dip_MashtoRun 38, dip_Model 38, dip_PlayStyle 38, dip_ScType 38, fontSize 38, fontType 38, mycolor1 38, mycolor2 38, dip_HoldtoLoop 36, dip_LivType 36, dip_Scanlines 34, dip_Undertaker 25, dip_Crosshair 24, dip_ReloadTrigger 19, dip_Showdown 17, dip_Lang 12, dip_AllowContinue 11, dip_Practice 11, dip_Debug 10, dip_HUD 10, dip_Kidmode 10, dip_Movie 10, dip_StartAtPart 10, dip_StartSegment 9, dip_CoinsPerContinue 8, dip_MashRes 7, dip_Next 7, dip_OVLS 7, dip_Tilt 7, mycolor3 7, dip_MaxBullets 5, dip_PoolIndex 5, dip_MustBeatLevel 2, dip_Bilinear 1, dip_Chroma 1, dip_IntroType 1, dip_PlayEveryLevel 1, dip_RevCubeMax 1, dip_ShowHellgate 1, dip_ShowWizard 1, dip_SortStyle 1, dip_Special 1, dip_showAction 1, t1 1, t2 1, t3 1, t4 1. + +## Globals nothing names yet (455) + +By how many titles use each: ARROWSELL 38, ARROWSELR 38, BARFULL 38, BARNONE 38, BONUS 38, BUTTONS 38, CREDIT 38, D1 38, D2 38, D3 38, DA 38, DEATH 38, FREEPLAY 38, ForceRewind 38, GETREADY 38, Group1 38, Group2 38, Group3 38, Group4 38, Hit 38, INSERTCOIN 38, LEVEL 38, LIVES 38, LvlMap 38, MINUS 38, MOVE 38, MovieFPS 38, NOMOVES 38, OKMAP 38, PERFECT 38, PW0 38, PW1 38, PW2 38, PW3 38, PW4 38, PW5 38, PW6 38, PW7 38, PWFULL 38, SCAN 38, SCENES 38, SCORE 38, SECRET 38, SELMAP 38, SKIPINTRO 38, SPECIALS 38, STICK 38, TB 38, TG 38, TIME 38, TN 38, TOPSCORE 38, TOTAL 38, TP 38, TS 38, Tlimit 38, altCfg 38, altState 38, altState2 38, b1PEnd 38, b1PStart 38, b2PEnd 38, b2PStart 38, bAct 38, bAllowSave 38, bCalc 38, bCheckForCredits 38, bCheckMove 38, bExtendedPlay 38, bFlip 38, bGOAlt 38, bGR 38, bIgnoreJoy 38, bInPlayExit 38, bLCDLineWait 38, bLevelComplete 38, bOneDiff 38, bPath 38, bPause 38, bPlayPrompt 38, bRes 38, bResetContinue 38, bSave 38, bShowAction 38, bShowCredits 38, bShowDisk 38, bShowGet 38, bShowLCD 38, bShowLives 38, bShowLvl 38, bShowScore 38, bShowSkip 38, bShowTop 38, bShuffleOrder 38, bSkipIntroClip 38, bSwap 38, bTestHold 38, bTestMash 38, bTestRunL 38, bTestRunR 38, bTime 38, curPath 38, currentFrame 38, currentLevel 38, currentMove 38, diffState 38, dip_Loadsave 38, dip_Slot 38, finalstage 38, flow_GameInit 38, flow_GameRunning 38, flow_VLDPStart 38, fontSizeNew 38, gameflow 38, i1PScore 38, i2P 38, i2PScore 38, iBonus 38, iChoice 38, iCoins 38, iContinues 38, iCredits 38, iDeathCount 38, iExtraLife 38, iLCDline 38, iLCDpointer 38, iLCDwait 38, iLCDx 38, iLastFiller 38, iLenHold 38, iLevel 38, iLevelEnd 38, iLevelStart 38, iLifeBar 38, iLiveSave 38, iLives 38, iMash 38, iMovie 38, iPath 38, iPathA 38, iPathAend 38, iPathAjmp 38, iPathB 38, iPauseFrame 38, iPenal 38, iRightMv 38, iRoll 38, iScDeath 38, iScPlayed 38, iScene 38, iScore 38, iScoreSave 38, iScoreTemp 38, iStageIndex 38, iTempLevel 38, iTop 38, iTotDeath 38, iWrongMv 38, lastHold 38, lenCounter 38, lfs 38, lvlState 38, mashCounter 38, numChoice 38, numTrophy 38, p1BUTTON1 38, p1BUTTON2 38, p1BUTTON3 38, p1COIN1 38, p1COIN2 38, p1CONSOLE 38, p1DOWN 38, p1LEFT 38, p1PAUSE 38, p1RIGHT 38, p1SERVICE 38, p1START1 38, p1START2 38, p1UP 38, posGauge 38, sLCD 38, scene 38, segID 38, sndclear 38, sndcoin 38, sndcredit 38, snddeath 38, sndright 38, sndroll 38, sndvictory 38, sndwrong 38, stage 38, t1 38, t1l 38, t2 38, t2l 38, t3 38, t3l 38, t4 38, t4l 38, thisMove 38, ycoinpos 38, ydskpos 38, ylivpos 38, ylvlpos 38, yscopos 38, offsetDeath 37, offsetMenus 37, offsetMovieEnd 37, AllowMap 35, AllowRandom 35, IngameDiffchoice 35, LvlTrophy3 35, LvlTrophy4 35, miscB 34, miscG 34, miscR 34, offsetMap 34, offsetMapend 34, AllowTier 33, HOLD 31, level2P 31, level2PEnd 31, bScan 30, bShowCubes 20, iMulti 19, LivesPrev 18, ScorePrev 18, LangOpt 14, BASEOVERLAY 12, DOUBLE 12, OVERLAY_FULL 12, OVERLAY_HALF 12, OVERLAY_OVERSIZE 12, iLoopPrev 12, iLoopStep 12, PlayerNum 11, SBCOH 11, SBCOW 11, dip_MinimalOverlay 11, OVERLAY_DEFAULT 9, dip_Lang 8, AllowDie 7, AllowKid 7, AllowLifeBar 7, AllowNormal 7, AllowPercent 7, AllowSurvivor 7, B1 7, B2 7, B3 7, D 7, DL 7, DR 7, HDOWN 7, HLEFT 7, HRIGHT 7, HUP 7, KIMMY 7, L 7, MASH3 7, NB1 7, NB2 7, NB3 7, NDL 7, NDOWN 7, NDR 7, NEMPTY 7, NHDOWN 7, NHLEFT 7, NHOLD 7, NHRIGHT 7, NHUP 7, NLEFT 7, NM 7, NRIGHT 7, NSDOWN 7, NSLEFT 7, NSRIGHT 7, NSUP 7, NUL 7, NUMX 7, NUP 7, NUR 7, PAUSED 7, R 7, SKINDIR 7, TILT 7, U 7, UL 7, UR 7, WAIT 7, WARNING 7, acombo 7, bLvlJump 7, bNoScreen 7, bScnJump 7, bShowFrame 7, bShowNext 7, bShowTilt 7, bShowWarnTilt 7, bTestRunD 7, bTestRunU 7, bUnlockSel 7, dip_LiveType 7, gcombo 7, iCountCombo 7, iTilt 7, iTiltMv 7, iTimer 7, iTrigTilt 7, levelDR 7, mcombo 7, multi 7, n 7, pathLook 7, xCredit 7, xFreePlay 7, xTop 7, MazeGame 6, bAddons 3, devMode 2, dip_Bilinear 2, Allow2Player 1, ArcadeMode 1, BUTTONMASHB2 1, BUTTONMASHB3 1, BUTTONMASHDOWN 1, BUTTONMASHLEFT 1, BUTTONMASHRIGHT 1, BUTTONMASHUP 1, CPHeight 1, CPHeightLo 1, CPwidth 1, CPwidthLo 1, CreditPannel 1, CreditPannelLo 1, LEDBarw 1, LEDBarwLo 1, LEDPannel 1, LPwidth 1, LPwidthLo 1, LivesPannel 1, LivesPannelLo 1, SSDheight 1, SSDheightLo 1, SSDwidth 1, SSDwidthLo 1, ScorePannel 1, ScorePannelLo 1, SpriteMenu 1, a 1, alCfg 1, bBilinear 1, bBranchDeath 1, bLang 1, bShowTitle 1, disable2P 1, disable2PLo 1, disable2Pw 1, disable2PwLo 1, exitmenu 1, exitmenuLo 1, exitmenuh 1, exitmenuhLo 1, exitmenuw 1, exitmenuwLo 1, gap 1, h_OverlayTweak 1, hyplogo 1, hyplogoLo 1, hyplogow 1, hyplogowLo 1, insertCoinb 1, insertCoinbLo 1, insertCoinw 1, insertCoinwLo 1, ledFull 1, ledFullLo 1, ledNone 1, ledNoneLo 1, patch_mazinga 1, press1p 1, press1pLo 1, press1pw 1, press1pwLo 1, sndchange 1, sndenter 1, sndprompt 1, sndselect 1, sndtest 1, sprSSD 1, sprSSDLo 1, w_OverlayTweak 1, waitLook 1, xLEDCredits 1, xLEDCreditsLo 1, xLEDLives 1, xLEDLivesLo 1, xLEDScore 1, xLEDScoreLo 1, xattract 1, xattractLo 1, xattractb 1, xattractbLo 1, xattractw 1, xattractwLo 1, xback 1, xbackLo 1, xbackb 1, xbackbLo 1, xbackw 1, xbackwLo 1, xcontinue 1, xcontinueLo 1, xcontinueb 1, xcontinuebLo 1, xcontinuew 1, xcontinuewLo 1, xnewgame 1, xnewgameLo 1, xnewgameb 1, xnewgamebLo 1, xnewgamew 1, xnewgamewLo 1, xoptions 1, xoptionsLo 1, xoptionsb 1, xoptionsbLo 1, xoptionsw 1, xoptionswLo 1, xpaused 1, xpausedLo 1, xpausedh 1, xpausedhLo 1, xpausedw 1, xpausedwLo 1, xquit 1, xquitLo 1, xquitb 1, xquitbLo 1, xquitw 1, xquitwLo 1, xreturn 1, xreturnLo 1, xreturnb 1, xreturnbLo 1, xreturnw 1, xreturnwLo 1, yLEDCredits 1, yLEDCreditsLo 1, yLEDLives 1, yLEDLivesLo 1, yLEDScore 1, yLEDScoreLo 1. + +## Titles + +Runs without its Lua: `standalone.txt`, last proved 2026-09-24. + +| Title | Family | forge | framework | lua | Runs without its Lua | +|---|---|---|---|---|---| +| Rollercoaster/Rollercoaster | videoland | 2 | 0 | 1 | not on the list | +| hypseus/typing-md2/singe/typing-md2/typing-md2 | alg | 2 | 4 | 1 | not on the list | +| CrimePatrol2DrugWarsHD/Script/drugwars | alg | 2 | 6 | 1 | not on the list | +| CrimePatrolHD/Script/crimepatrol | alg | 2 | 6 | 1 | not on the list | +| CrimePatrol/Script/crimepatrol | alg | 2 | 7 | 1 | not on the list | +| SpacePirates/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | +| SpacePiratesHD/Script/spacepirates | alg | 2 | 7 | 1 | not on the list | +| hypseus/crimepatrol-hd/singe/crimepatrol-hd/crimepatrol-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/drugwars-hd/singe/drugwars-hd/drugwars-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/lbh-hd/singe/lbh-hd/lbh-hd | alg | 2 | 7 | 1 | not on the list | +| hypseus/spacepirates-hd/singe/spacepirates-hd/spacepirates-hd | alg | 2 | 7 | 1 | not on the list | +| CrimePatrol2DrugWars/Script/drugwars | alg | 2 | 8 | 1 | not on the list | +| LastBountyHunter/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | +| LastBountyHunterHD/Script/lastbountyhunter | alg | 2 | 8 | 1 | not on the list | +| Platoon/Script/platoon | alg | 2 | 8 | 1 | not on the list | +| hypseus/maddog2-hd/singe/maddog2-hd/maddog2-hd | alg | 2 | 8 | 1 | not on the list | +| MadDogMcCree/Script/maddog | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCree2/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCree2HD/Script/maddog2 | alg | 2 | 9 | 1 | not on the list | +| MadDogMcCreeHD/Script/maddog | alg | 2 | 9 | 1 | not on the list | +| WhoShotJohnnyRock/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | +| WhoShotJohnnyRockHD/Script/johnnyrock | alg | 2 | 9 | 1 | not on the list | +| hypseus/WSJR_HD/WSJR_HD | alg | 2 | 9 | 1 | not on the list | +| hypseus/johnnyrocknoir/johnnyrocknoir | alg | 2 | 9 | 1 | not on the list | +| hypseus/maddog-hd/singe/maddog-hd/maddog-hd | alg | 2 | 9 | 1 | not on the list | +| hypseus/wsjr-hd/singe/wsjr-hd/wsjr-hd | alg | 2 | 10 | 1 | not on the list | +| HologramTimeTraveler/Script/timetraveler | timetraveler | 2 | 11 | 1 | not on the list | +| NinjaHayateHD/Hayate | qte.windows | 52 | 266 | 1 | yes | +| TimeGal/Timegal | qte.windows | 52 | 268 | 1 | yes | +| CliffHanger/Cliff | qte.windows | 52 | 269 | 1 | yes | +| Asterix/Asterix | qte.windows | 48 | 273 | 1 | yes | +| FireAndIce/Fire_and_Ice | qte.windows | 52 | 273 | 1 | yes | +| TheElderScrolls/the-elder-scrolls | qte.windows | 48 | 274 | 1 | yes | +| TitanAE/Titan_AE | qte.windows | 52 | 278 | 1 | yes | +| hypseus/badlands_lite/singe/badlands_lite/badlands_lite | qte.windows | 54 | 278 | 1 | yes | +| AlteredCarbon/AlteredCarbon | qte.windows | 52 | 281 | 1 | yes | +| OeilPourOeil/oeil-pour-oeil | qte.windows | 48 | 281 | 1 | yes | +| hypseus/Survival/singe/Survival/Survival | qte.windows | 54 | 281 | 1 | yes | +| FireAndIce/Fire_and_Ice_v2 | qte.windows | 52 | 284 | 1 | yes | +| Tron/Tron | qte.windows | 54 | 284 | 1 | yes | +| hypseus/Freddy/singe/Freddy/Freddy | qte.windows | 54 | 286 | 1 | not on the list | +| hypseus/StarshipTroopers/singe/StarshipTroopers/StarshipTroopers | qte.windows | 54 | 286 | 1 | not on the list | +| Mononoke/mononoke | qte.windows | 52 | 287 | 1 | not on the list | +| hypseus/Sugar_Rush/singe/Sugar_Rush/Sugar_Rush | qte.windows | 54 | 287 | 1 | not on the list | +| hypseus/Arcade_Xperience_Vol1/singe/Arcade_Xperience_Vol1/Arcade_Xperience_Vol1 | qte.windows | 54 | 301 | 1 | yes | +| hypseus/Arcade_Xperience_Vol2/singe/Arcade_Xperience_Vol2/Arcade_Xperience_Vol2 | qte.windows | 54 | 302 | 1 | not on the list | +| hypseus/Astroboy/singe/Astroboy/Astroboy | qte.windows | 54 | 302 | 1 | not on the list | +| hypseus/Arcade_Xperience_Vol3/singe/Arcade_Xperience_Vol3/Arcade_Xperience_Vol3 | qte.windows | 54 | 304 | 1 | not on the list | +| hypseus/Danmachi/singe/Danmachi/Danmachi | qte.windows | 54 | 306 | 1 | not on the list | +| Linea/LINEA | qte.windows | 56 | 331 | 1 | yes | +| hypseus/sintel/singe/Sintel/Sintel | qte.held | 38 | 346 | 1 | not on the list | +| hypseus/FBC/singe/FBC/FBC | qte.held | 54 | 351 | 1 | not on the list | +| hypseus/FumaConspiracy/singe/FumaConspiracy/FumaConspiracy | qte.held | 52 | 351 | 1 | not on the list | +| hypseus/Sonic_the_Hedgehog_1996/singe/Sonic_the_Hedgehog_1996/Sonic_the_Hedgehog_1996 | qte.held | 52 | 354 | 1 | yes | +| hypseus/mazinga/singe/mazinga/mazinga | qte.held | 55 | 364 | 1 | yes | +| hypseus/hq-timegal/hq-timegal | qte.checkpoints | 2 | 10 | 2 | not on the list | +| hypseus/jeeg/singe/jeeg/jeeg | qte.segments | 2 | 13 | 2 | not on the list | +| hypseus/starblazers/starblazers | qte.segments | 2 | 13 | 2 | not on the list | +| Conan/Script/conan_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| Daitarn3/Script/daitarn_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| DragonTrainer/Script/dragon_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| DragonsLairTvShow/Script/dlair | qte.segments | 2 | 14 | 2 | not on the list | +| PussInBoots/Script/Puss_in_Boots | qte.segments | 2 | 14 | 2 | not on the list | +| SamuraiJack/Script/jack_singe_2 | qte.segments | 2 | 14 | 2 | not on the list | +| hypseus/freedomfighter/freedomfighter | qte.segments | 2 | 15 | 2 | not on the list | +| SuperDonQuixote/Script/Sdq | qte.trophies | 2 | 24 | 2 | not on the list | +| hypseus/Esh_Aurunmilla/singe/Esh_Aurunmilla/Esh_Aurunmilla | qte.windows | 50 | 283 | 3 | not on the list | +| DragonsLairEnhanced/DLe | qte.windows | 52 | 267 | 4 | not on the list | +| SpaceAceEnhanced/SAe | qte.windows | 52 | 268 | 4 | not on the list | +| FridayThe13th/f13 | qte.windows | 52 | 271 | 4 | not on the list | +| SuckerPunch/SuckerPunch | qte.windows | 54 | 282 | 4 | yes | +| hypseus/hayate_1080/singe/hayate_1080/hayate_1080 | qte.held | 39 | 455 | 4 | not on the list | +| DragonsLair2Enhanced/DL2e | qte.windows | 53 | 266 | 6 | yes | +| hypseus/BD13/singe/BD13/BD13 | qte.held | 41 | 347 | 7 | not on the list | +| ChantzesStone/Chantze | qte.windows | 53 | 268 | 9 | yes | +| ChantzesStone/Triad | qte.windows | 53 | 268 | 9 | yes | + +## Made by another tool + +ActionMax/38AmbushAlley.forge, ActionMax/BlueThunder.forge, ActionMax/Hydrosub2021.forge, ActionMax/PopsGhostly.forge, ActionMax/SonicFury.forge. diff --git a/testScripts/ports/shotDiff.py b/testScripts/ports/shotDiff.py new file mode 100755 index 0000000..92e586e --- /dev/null +++ b/testScripts/ports/shotDiff.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# The Picture row's check (FORGE.md 14.18): the screenshots an original and its port took at the +# same moments (compare.sh with PORT_SHOTS=N and SINGE_SHOTS=<dir>), paired by number, and how much +# of each picture differs. A pixel differs when any channel is more than TOLERANCE apart, since +# the two runs decode the same video but may round a scaled frame differently. +# +# testScripts/ports/shotDiff.py <shots dir> [<out dir>] +# +# prints one line a pair and a summary; with <out dir>, writes each pair side by side with the +# differing pixels marked in the third panel, for a person to look at. +import os +import re +import sys + +import numpy +from PIL import Image + +TOLERANCE = 24 # Per channel, out of 255: video rounding, not a missing prompt +MARK = (255, 0, 255) +PAIR = re.compile(r"^(original|port)-shot-((?:run\d+-)?\d+)-(\w+)\.png$") + + +def main(): + shots = sys.argv[1] if len(sys.argv) > 1 else sys.exit("usage: shotDiff.py <shots dir> [<out dir>]") + out = sys.argv[2] if len(sys.argv) > 2 else None + found = {} + + for name in os.listdir(shots): + match = PAIR.match(name) + if match: + found.setdefault((match.group(2), match.group(3)), {})[match.group(1)] = os.path.join(shots, name) + if out: + os.makedirs(out, exist_ok=True) + worst = 0.0 + for (number, why), pair in sorted(found.items()): + if "original" not in pair or "port" not in pair: + print("%s %-6s only the %s took it" % (number, why, "original" if "original" in pair else "port")) + continue + a = numpy.asarray(Image.open(pair["original"]).convert("RGB"), dtype=numpy.int16) + b = numpy.asarray(Image.open(pair["port"]).convert("RGB"), dtype=numpy.int16) + if a.shape != b.shape: + print("%s %-6s sizes differ: %s and %s" % (number, why, a.shape, b.shape)) + continue + differs = (numpy.abs(a - b) > TOLERANCE).any(axis=2) + percent = 100.0 * differs.mean() + worst = max(worst, percent) + print("%s %-6s %6.2f%% of the picture differs" % (number, why, percent)) + if out: + marked = a.copy() + marked[differs] = MARK + side = numpy.concatenate([a, b, marked], axis=1).astype(numpy.uint8) + Image.fromarray(side).save(os.path.join(out, "%s-%s.png" % (number, why))) + print("%d pairs; the worst differs in %.2f%% of its picture" % (len(found), worst)) + + +if __name__ == "__main__": + main() diff --git a/testScripts/ports/shots.sh b/testScripts/ports/shots.sh new file mode 100755 index 0000000..7bfadc2 --- /dev/null +++ b/testScripts/ports/shots.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Runs an original and its port through compare.sh with the drive's screenshots and service walk +# on, and gathers the pictures side by side under screenshots/ports/<game>/ in the repository: +# original-shot-<moment>.png beside port-shot-<moment>.png for the attract loop, the service +# screen, a level just after a shot, the continue, and the board (FORGE.md section 14.15). +# +# testScripts/ports/shots.sh <family> <game> [frames] +# +# The comparison's own verdict (the traces agreeing, service walk included) is printed as well. +set -u +family=${1:?usage: shots.sh <family> <game> [frames]} +game=${2:?usage: shots.sh <family> <game> [frames]} +frames=${3:-8000} +repo=$(cd "$(dirname "$0")/../.." && pwd) +name=${game##*/} +name=${name%.singe} +out=$repo/screenshots/ports/$name +rm -rf "$out" +SINGE_SHOTS=$out PORT_SHOTS=1 PORT_SERVICE=1 PORT_FRAMES=$frames "$repo/testScripts/ports/compare.sh" "$family" "$game" +status=$? +ls "$out" 2>/dev/null | sed "s|^|$out/|" +exit $status diff --git a/testScripts/ports/standalone.txt b/testScripts/ports/standalone.txt new file mode 100644 index 0000000..7331b23 --- /dev/null +++ b/testScripts/ports/standalone.txt @@ -0,0 +1,22 @@ +# 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 new file mode 100755 index 0000000..41e72d7 --- /dev/null +++ b/testScripts/ports/sweep.sh @@ -0,0 +1,101 @@ +#!/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 new file mode 100644 index 0000000..b07b3a2 --- /dev/null +++ b/testScripts/ports/timetravelerDrive.singe @@ -0,0 +1,200 @@ +-- Drives Hologram Time Traveler (the original or its port) from what the game shows, the same +-- way for both. The driver file defines what it takes: a view() of the game's state and +-- ttPress(switch, down) to press or let go of a switch; this file decides. The trace lines +-- it prints are what compare.sh diffs. +-- +-- The policy: a coin and START at the attract loop; in a level, the move the row asks for +-- three frames into its window (a turn first where the row wants a turn and a shot), a wrong +-- one on every fifth move and none on every seventh with misses on; a cube against the first +-- death with misses on; at the map, the trader once with misses on (a coin buys a cube) and +-- the timer's own choice otherwise; a pull at the hellgate's lever; a coin and START at the +-- continue; a letter at the board. + +TT_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local perfect = (os.getenv("PORT_PERFECT") == "1") +local frames = 0 +local lastLine = nil +local pending = nil -- { switch } pressed this frame, let go the next. +local answered = {} -- Windows answered, by level/segment/move/lives. +local tried = {} -- Moves met before, by level/segment/move. +local coined = false +local started = false +local cubeUsed = false +local traded = false +local pulled = false +local lettered = false +local continues = 0 +local turnAt = nil -- The frame the turn went in, for the shot after it. + +local SWITCH_OF = nil + + +function ttTraceSeek(frame) + debugPrint(string.format("TRACE seek %d", frame)) +end + + +local function tap(switch) + ttPress(switch, true) + pending = switch +end + + +-- view: { flow, screen, step, alt, level, seg, move, moves, frame, score, lives, cubes, credits, +-- window = { from, to }, correct (the row's move, or nil for the tutorial's choice), +-- turned (the turn is in), dying, wizard, mapWait, mapCursor, trader, hellgate, +-- continuing, board } +function ttDrive(view) + if pending then + ttPress(pending, false) + pending = nil + end + frames = frames + 1 + if SWITCH_OF == nil then + SWITCH_OF = { [view.UP] = SWITCH_UP, [view.DOWN] = SWITCH_DOWN, [view.LEFT] = SWITCH_LEFT, [view.RIGHT] = SWITCH_RIGHT, [view.ACTION] = SWITCH_BUTTON1 } + end + -- (altState is left out: the original's LCD drawing steps it while the attract loop shows.) + local line = string.format("TRACE flow %s screen %s step %s level %s seg %s move %s score %s lives %s cubes %s credits %s", + tostring(view.flow), tostring(view.screen), tostring(view.step), tostring(view.level), tostring(view.seg), + tostring(view.move), tostring(view.score), tostring(view.lives), tostring(view.cubes), tostring(view.credits)) + + if line ~= lastLine then + debugPrint(line .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= TT_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- The attract loop: a coin, then START once the credit shows. + if view.intro then + if (frames > 200) and not coined then + coined = true + tap(SWITCH_COIN1) + return + end + if coined and (frames > 300) and (frames % 40 == 0) and (view.credits > 0) then + tap(SWITCH_START1) + end + return + end + -- The continue: a coin, then START, taken once. + if view.continuing then + if (continues < 1) and (frames % 30 == 0) then + if view.credits == 0 then + tap(SWITCH_COIN1) + else + continues = continues + 1 + tap(SWITCH_START1) + end + end + return + end + -- The map: the trader once (with misses on), else the timer's own choice. + if view.mapWait then + if (not perfect) and (not traded) and (view.mapCursor == view.TRADER) then + traded = true + tap(SWITCH_BUTTON1) + end + return + end + -- The trader: a coin buys a cube. + if view.trader then + if (frames % 20 == 0) then + tap(SWITCH_COIN1) + end + return + end + -- The hellgate: one pull at the lever with misses on (a roll can take every life); the + -- perfect run lets the clip pass. + if view.hellgate then + if (not perfect) and not pulled then + pulled = true + tap(SWITCH_BUTTON1) + end + return + end + pulled = false + -- The board: one letter, and the rest to the clip's end. + if view.board then + if not lettered then + lettered = true + tap(SWITCH_BUTTON1) + end + return + end + -- A death: a cube against the first, with misses on. + if view.dying then + if (not perfect) and (not cubeUsed) and (view.cubes > 0) then + cubeUsed = true + tap(SWITCH_BUTTON2) + end + return + end + -- A level's window. + if view.window then + local moveKey = tostring(view.level) .. "/" .. tostring(view.seg) .. "/" .. tostring(view.move) + local key = moveKey .. "/" .. tostring(view.lives) + local retry = (tried[moveKey] ~= nil) + local at = view.frame + + if (at >= view.window[1] + 3) and (at <= view.window[2]) and not answered[key] then + local correct = view.correct + + if correct == nil then + -- The tutorial's choice: the sign post's right skips it, nothing plays it. + answered[key] = true + tried[moveKey] = true + if perfect then + tap(SWITCH_RIGHT) + end + return + end + if correct == view.NOMOVE then + -- A row that wants nothing. + answered[key] = true + return + end + local missed = (not perfect) and (view.move % 7 == 0) and (view.move % 5 ~= 0) and not retry + local wrong = (not perfect) and (view.move % 5 == 0) and not retry + + if missed then + answered[key] = true + tried[moveKey] = true + return + end + if (correct == view.TURNRIGHTSHOOT) or (correct == view.TURNLEFTSHOOT) then + -- A turn, then the shot two frames on. + if not view.turned then + if turnAt == nil then + turnAt = at + tap(wrong and SWITCH_UP or ((correct == view.TURNRIGHTSHOOT) and SWITCH_RIGHT or SWITCH_LEFT)) + if wrong then + answered[key] = true + tried[moveKey] = true + turnAt = nil + end + end + elseif at >= turnAt + 2 then + answered[key] = true + tried[moveKey] = true + turnAt = nil + tap(SWITCH_BUTTON1) + end + return + end + answered[key] = true + tried[moveKey] = true + local switch = SWITCH_OF[correct] + + if wrong then + switch = (correct == view.UP) and SWITCH_DOWN or SWITCH_UP + end + if switch then + tap(switch) + end + end + end +end diff --git a/testScripts/ports/timetravelerOriginal.singe b/testScripts/ports/timetravelerOriginal.singe new file mode 100644 index 0000000..69112de --- /dev/null +++ b/testScripts/ports/timetravelerOriginal.singe @@ -0,0 +1,66 @@ +-- Hologram Time Traveler as it ships, driven by timetravelerDrive.singe and traced. PORT_GAME +-- names the title's folder under the library and PORT_SCRIPT its script under it; run with -v +-- the title's video. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "HologramTimeTraveler" +local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe")) + +dofile(here .. "timetravelerDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + ttTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + ttTraceSeek(frame) + return realSearch(frame) +end +dofile(script) +singeGetScriptPath = realScriptPath + +local realUpdate = onOverlayUpdate + + +function ttPress(switch, down) + if down then + onInputPressed(switch) + else + onInputReleased(switch) + end +end + + +local function view() + local window, correct = nil, nil + + if (currentLevel == levelNormal) and (lvlState == lvlRunning) and move and move[currentMove] and (currentMove <= totalMoves) then + window = { move[currentMove][inputFrmStart], move[currentMove][inputFrmEnd] } + correct = move[currentMove][correctMove] + end + return { flow = gameflow, screen = currentLevel, step = lvlState, alt = altState, level = iCurPos, seg = segID, move = currentMove, moves = totalMoves, + frame = discGetFrame(), score = iScore, lives = iLives, cubes = iRevCubes, credits = iCredits, + window = window, correct = correct, turned = bTurn, + intro = (gameflow == flow_GameRunning) and (currentLevel == levelIntro), + dying = (currentLevel == levelNormal) and (lvlState == lvlPlayDeath), + mapWait = (currentLevel == levelMenuScreen) and (lvlState == lvlRunning), mapCursor = iCurPos, + trader = (currentLevel == levelTrader) and ((lvlState == lvlRunning) or (lvlState == branch01)), + hellgate = (currentLevel == levelHellgate) and (lvlState == branch01), + continuing = (currentLevel == levelContinue) and (lvlState == lvlRunning), + board = (currentLevel == levelHighScore) and (lvlState == lvlRunning), + UP = UP, DOWN = DOWN, LEFT = LEFT, RIGHT = RIGHT, ACTION = ACTION, NOMOVE = NOMOVE, TURNRIGHTSHOOT = TURNRIGHTSHOOT, TURNLEFTSHOOT = TURNLEFTSHOOT, TRADER = levelTrader } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + ttDrive(view()) + + return result +end diff --git a/testScripts/ports/timetravelerPort.singe b/testScripts/ports/timetravelerPort.singe new file mode 100644 index 0000000..c715bed --- /dev/null +++ b/testScripts/ports/timetravelerPort.singe @@ -0,0 +1,63 @@ +-- The Forge port of Hologram Time Traveler, driven and traced as the original is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "HologramTimeTraveler" +local name = (os.getenv("PORT_SCRIPT") or (game .. "/Script/timetraveler.singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "timetravelerDrive.singe") + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + ttTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + ttTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +function ttPress(switch, down) + if down then + authorSwitchDown(switch, nil) + else + authorSwitchUp(switch, nil) + end +end + + +local function view() + local q = authorEntity("game").q + local window, correct = nil, nil + + if (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlRunning) and q.move and q.move[q.currentMove] and (q.currentMove <= q.totalMoves) then + window = { q.move[q.currentMove][q.inputFrmStart], q.move[q.currentMove][q.inputFrmEnd] } + correct = q.move[q.currentMove][q.correctMove] + end + return { flow = q.gameflow, screen = q.currentLevel, step = q.lvlState, alt = q.altState, level = q.iCurPos, seg = q.segID, move = q.currentMove, moves = q.totalMoves, + frame = discGetFrame(), score = q.iScore, lives = q.iLives, cubes = q.iRevCubes, credits = q.iCredits, + window = window, correct = correct, turned = q.bTurn, + intro = (q.gameflow == q.flow_GameRunning) and (q.currentLevel == q.levelIntro), + dying = (q.currentLevel == q.levelNormal) and (q.lvlState == q.lvlPlayDeath), + mapWait = (q.currentLevel == q.levelMenuScreen) and (q.lvlState == q.lvlRunning), mapCursor = q.iCurPos, + trader = (q.currentLevel == q.levelTrader) and ((q.lvlState == q.lvlRunning) or (q.lvlState == q.branch01)), + hellgate = (q.currentLevel == q.levelHellgate) and (q.lvlState == q.branch01), + continuing = (q.currentLevel == q.levelContinue) and (q.lvlState == q.lvlRunning), + board = (q.currentLevel == q.levelHighScore) and (q.lvlState == q.lvlRunning), + UP = q.UP, DOWN = q.DOWN, LEFT = q.LEFT, RIGHT = q.RIGHT, ACTION = q.ACTION, NOMOVE = q.NOMOVE, TURNRIGHTSHOOT = q.TURNRIGHTSHOOT, TURNLEFTSHOOT = q.TURNLEFTSHOOT, TRADER = q.levelTrader } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + ttDrive(view()) + + return result +end diff --git a/testScripts/ports/videolandDrive.singe b/testScripts/ports/videolandDrive.singe new file mode 100644 index 0000000..72d08cf --- /dev/null +++ b/testScripts/ports/videolandDrive.singe @@ -0,0 +1,120 @@ +-- Drives Adventures in Videoland: Rollercoaster (the original or its port) from what the game +-- shows, the same way for both. The driver file defines what it takes: a view() of the game's +-- state and videolandKey(keysym, scancode) to press a key; this file decides. The trace lines +-- it prints are what compare.sh diffs. +-- +-- The policy: a name at the name prompt, any key where the game waits for one, and the +-- walkthrough's lines typed one character a frame whenever the game waits for a line; with +-- PORT_PERFECT=1 the walkthrough is the solution alone, otherwise it opens with the unknown +-- words, the inventory play, and two of the deaths (the restaurant's meal and the coaster's +-- car) before solving the game from the start again. + +VIDEOLAND_FRAMES = tonumber(os.getenv("PORT_FRAMES")) or 60000 + +local perfect = (os.getenv("PORT_PERFECT") == "1") + +-- The solution: the coins, the bear from the gallery, the uniform from the closet, the toolkit, +-- the book past the guard, the bear to the dancer for the ticket, the radio from the booth, the +-- batteries out of the bear, the jammer made and powered, and the tower. +local SOLUTION = { "E", "TAKE COINS", "W", "W", "S", "SHOOT", "N", "N", "E", "TAKE UNIFORM", "WEAR UNIFORM", "W", "S", "W", "TAKE TOOLKIT", + "E", "E", "S", "S", "TAKE BOOK", "READ BOOK", "N", "N", "E", "S", "YES", "S", "TAKE TICKET", "W", + "E", "E", "PLAY", "RADIO", "OPEN BEAR", "TAKE BATTERIES", "MAKE JAMMER", "PUT BATTERIES", "IN JAMMER", + "W", "W", "W", "W", "S", "S", "JAM" } + +-- The detours: words the game does not know, the inventory, a meal that ends the game and a +-- yes to play again, a walk to the top of the coaster and the car, and a yes again. +local DETOURS = { "DANCE", "HELP", "I", "LOOK", "TAKE ALL", "FIND BATTERIES", "KILL SABOTEUR", "N", "MAYBE", "YES", "HUH", "YES", + "E", "TAKE COINS", "DROP COINS", "TAKE COINS", "PUT COINS", "DOWN", "TAKE COINS", "GO NORTH", "W", "W", "N", + "EXAMINE BOX", "BREAK DOOR", "VISIT DANCER", "READ TICKET", "S", "W", "N", "N", "YES" } + +local lines = {} +local at = 1 +local typing = nil -- The line being typed, and how much of it is in. +local typed = 0 +local settle = 0 -- Frames to wait before the next line. +local frames = 0 +local pressedFor = nil -- The wait for any key already answered, by what follows it. +local lastLine = nil + +if not perfect then + for _, line in ipairs(DETOURS) do + lines[#lines + 1] = line + end +end +for _, line in ipairs(SOLUTION) do + lines[#lines + 1] = line +end +table.insert(lines, 1, "SCOTT") + +-- The states that wait for a typed line (the game's numbers): the name, the restaurant, the +-- tent, the parser, the play-again, the prize, and the put-where. +local WAITING = { [2] = true, [10] = true, [11] = true, [15] = true, [17] = true, [20] = true, [23] = true } + + +function videolandTraceSeek(frame) + debugPrint(string.format("TRACE seek %d", frame)) +end + + +-- view: { state, next, room, turns, keyboard, keyhit, delay, still, endFrame, frame, prints, +-- last (the newest line of the screen), input } +function videolandDrive(view) + frames = frames + 1 + local line = string.format("TRACE state %s next %s room %s turns %s kb %s hit %s delay %s still %s end %s prints %s last %s", + tostring(view.state), tostring(view.next), tostring(view.room), tostring(view.turns), tostring(view.keyboard), tostring(view.keyhit), + tostring(view.delay), tostring(view.still), tostring(view.endFrame), tostring(view.prints), tostring(view.last)) + + if line ~= lastLine then + debugPrint(line .. " disc " .. tostring(view.frame)) + lastLine = line + end + if frames >= VIDEOLAND_FRAMES then + debugPrint("TRACE end after " .. frames .. " frames") + singeQuit() + return + end + -- Any key where the game waits for one, once per wait (two waits follow each other at the + -- story's end, told apart by what comes next). A full stop: the keyboard is still on for + -- the frame the wait begins in, and a letter or a space pressed then would go into the line. + if view.keyhit then + if pressedFor ~= view.next then + pressedFor = view.next + videolandKey(46, SCANCODE.PERIOD.value) + end + return + end + pressedFor = nil + if typing then + -- The line in, a character a frame, then the return key. + typed = typed + 1 + if typed <= string.len(typing) then + local c = typing:sub(typed, typed) + + if c == " " then + videolandKey(32, SCANCODE.SPACE.value) + else + videolandKey(string.byte(string.lower(c)), SCANCODE[c].value) + end + else + videolandKey(13, SCANCODE.RETURN.value) + typing = nil + settle = 3 + end + return + end + if settle > 0 then + settle = settle - 1 + return + end + if os.getenv("PORT_DEBUG") == "1" and (frames % 100 == 0) then + debugPrint(string.format("DRIVE probe kb %s input [%s] next %s at %s line %s typing %s settle %s", tostring(view.keyboard), tostring(view.input), tostring(view.next), tostring(at), tostring(lines[at]), tostring(typing), tostring(settle))) + end + -- (Not while a delay runs: the keyboard is on through the bomb's, and a line typed then + -- is taken before the play-again prompt and leaves the game in no state at all.) + if view.keyboard and (view.delay == 0) and (view.input == "") and WAITING[view.next] and lines[at] then + typing = lines[at] + typed = 0 + at = at + 1 + debugPrint("DRIVE line " .. typing) + end +end diff --git a/testScripts/ports/videolandOriginal.singe b/testScripts/ports/videolandOriginal.singe new file mode 100644 index 0000000..dfeabe3 --- /dev/null +++ b/testScripts/ports/videolandOriginal.singe @@ -0,0 +1,58 @@ +-- Adventures in Videoland: Rollercoaster as it ships, driven by videolandDrive.singe and +-- traced. PORT_GAME names the title's folder under the library and PORT_SCRIPT its script +-- under it; run with -v the title's framefile. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "Rollercoaster" +local script = library .. "/" .. (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe")) + +dofile(here .. "videolandDrive.singe") + +SINGE_LEGACY_SPRITE_ARGS = true +-- The game seeds its random draws from os.time at load: pinned, as the port's is. +os.time = function(...) return 12345 end +local realScriptPath = singeGetScriptPath +singeGetScriptPath = function() return script end +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + videolandTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + videolandTraceSeek(frame) + return realSearch(frame) +end +dofile(script) +singeGetScriptPath = realScriptPath + +-- Every line the game prints, counted (the wrapped lines too). +local prints = 0 +local realPrint = screenPrint +screenPrint = function(what) + prints = prints + 1 + return realPrint(what) +end + +local realUpdate = onOverlayUpdate + + +-- A key, as the engine hands one to a script in the whole keyboard mode. +function videolandKey(keysym, scancode) + onKeyPressed(keysym, scancode) +end + + +local function view() + return { state = STATE, next = NEXT_STATE, room = L, turns = T, keyboard = (KEYBOARD_ON == ON), keyhit = (KEYHIT_ON == ON), delay = DELAY, + still = (SHOW_JPG ~= -1), endFrame = (END_FRAME ~= -1) and END_FRAME or nil, frame = discGetFrame(), prints = prints, last = SCREEN[SCREEN_LINES], input = INPUT } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + videolandDrive(view()) + + return result +end diff --git a/testScripts/ports/videolandPort.singe b/testScripts/ports/videolandPort.singe new file mode 100644 index 0000000..62f90da --- /dev/null +++ b/testScripts/ports/videolandPort.singe @@ -0,0 +1,57 @@ +-- The Forge port of Adventures in Videoland: Rollercoaster, driven and traced as the original +-- is. +local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\])") or "") +local library = os.getenv("SINGE_LIBRARY") or (os.getenv("HOME") .. "/claude/singetest") +local game = os.getenv("PORT_GAME") or "Rollercoaster" +local name = (os.getenv("PORT_SCRIPT") or (game .. "/" .. game .. ".singe")):gsub("%.singe$", "") +local stem = name:match("([^/\\]+)$") + +dofile("Forge/AuthorCompile.singe") +dofile(here .. "videolandDrive.singe") + +-- The game seeds its random draws from os.time at load; the original's driver pins it, and so +-- does this, to the same number. +os.time = function(...) return 12345 end + +local realSkip = discSkipToFrame +discSkipToFrame = function(frame) + videolandTraceSeek(frame) + return realSkip(frame) +end +local realSearch = discSearch +discSearch = function(frame) + videolandTraceSeek(frame) + return realSearch(frame) +end +dofile(authorBuild(library .. "/" .. name .. ".forge", singeGetDataPath() .. stem .. ".port.singe")) + +local realUpdate = onOverlayUpdate + + +-- A key, as the engine hands one to the runtime in the whole keyboard mode: its keysym on the +-- switch channel, then the key itself. The return and backspace keys' keysyms are switch +-- numbers too, and the runtime drops those while the key is down, which a driven run cannot +-- show it; they go by the key alone, as they arrive in play. +function videolandKey(keysym, scancode) + if (keysym ~= 13) and (keysym ~= 8) then + authorSwitchDown(keysym, nil) + end + authorKeyDown(keysym, scancode) +end + + +local function view() + local q = authorEntity("game").q + + return { state = q.state, next = q.next, room = q.L, turns = q.T, keyboard = q.keyboard, keyhit = q.keyhit, delay = q.delay, + still = (q.still ~= nil), endFrame = q.endFrame, frame = discGetFrame(), prints = q.prints, last = q.screen[q.lines], input = q.input } +end + + +onOverlayUpdate = function(...) + local result = realUpdate(...) + + videolandDrive(view()) + + return result +end diff --git a/testScripts/run.sh b/testScripts/run.sh new file mode 100755 index 0000000..8ce02bd --- /dev/null +++ b/testScripts/run.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Runs Forge's test scenes headless against a Singe binary and says which passed: +# +# testScripts/run.sh every scene +# testScripts/run.sh 52 58 83 just these +# SINGE=/path/to/Singe testScripts/run.sh +# +# Each runs from a fresh folder holding Forge/ (a copy of forge/) and testScripts/, the way a game +# directory holds a game, with --deterministic so a run is the same every time (scene 94 excepted). A scene fails when +# it prints a FAIL line, its script errors, or it does not quit within the time allowed. Logs and +# screenshots stay in build/tests/<scene>/. Nothing appears on screen. +set -uo pipefail + +here=$(cd "$(dirname "$0")/.." && pwd) +singe=${SINGE:-$(ls -t "$here"/../singe/.builddir/Singe-v*-Linux-x86_64 2>/dev/null | head -1)} +out=$here/build/tests +limit=600 + +if [[ -z $singe || ! -x $singe ]]; then + echo "error: no Singe binary; set SINGE" >&2 + exit 1 +fi +scenes=("$@") +if [[ ${#scenes[@]} == 0 ]]; then + mapfile -t scenes < <(ls "$here"/testScripts/scene*.singe | sed 's/.*scene\([0-9]*\)\.singe/\1/' | sort -n) +fi + +run="$out/run" +rm -rf "$run" +mkdir -p "$run" +cp -r "$here/forge" "$run/Forge" +cp -r "$here/testScripts" "$run/testScripts" +passed=0 +failed=() +for n in "${scenes[@]}"; do + log="$out/scene$n" + rm -rf "$log" + mkdir -p "$log" + # The disc each scene wants: the menu's own video, the kit's clip, or none. + disc=() + case $n in + 59|68|96|98) disc=(-v Singe/menuBackground.mkv);; + 87|88) disc=(-v "$run/Forge/kit/clip.mkv");; + esac + # Scene 94 waits for a clip to finish playing, which only the mixer's own clock says; under + # --deterministic the frames outrun it. + clock=(--deterministic) + if [[ $n == 94 ]]; then + clock=() + fi + (cd "$run" && SDL_VIDEODRIVER=offscreen SDL_AUDIO_DRIVER=disk SDL_DISKAUDIOFILE=/dev/null timeout -k 10 $limit "$singe" -k "${clock[@]}" "${disc[@]}" -d "$log/data" "testScripts/scene$n.singe" > "$log/log.txt" 2>&1) + status=$? + if [[ $status != 0 ]] || grep -q -E 'FAIL|Error running script|Error executing' "$log/log.txt"; then + failed+=("$n") + echo "scene$n FAILED (exit $status): $(grep -m1 -E 'FAIL|Error' "$log/log.txt" | cut -c1-160)" + else + passed=$((passed + 1)) + echo "scene$n ok" + fi +done +echo "$passed passed, ${#failed[@]} failed${failed[*]:+: ${failed[*]}}" +[[ ${#failed[@]} == 0 ]] diff --git a/testScripts/scene100.singe b/testScripts/scene100.singe new file mode 100644 index 0000000..e4bdedb --- /dev/null +++ b/testScripts/scene100.singe @@ -0,0 +1,89 @@ +-- 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/scene52.singe b/testScripts/scene52.singe new file mode 100644 index 0000000..4ad493c --- /dev/null +++ b/testScripts/scene52.singe @@ -0,0 +1,102 @@ +-- 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 new file mode 100644 index 0000000..af9c7ef --- /dev/null +++ b/testScripts/scene53.singe @@ -0,0 +1,51 @@ +-- 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 new file mode 100644 index 0000000..436be90 --- /dev/null +++ b/testScripts/scene54.singe @@ -0,0 +1,168 @@ +-- 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 new file mode 100644 index 0000000..6ea271c --- /dev/null +++ b/testScripts/scene55.singe @@ -0,0 +1,178 @@ +-- 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 new file mode 100644 index 0000000..f530bc5 --- /dev/null +++ b/testScripts/scene56.singe @@ -0,0 +1,116 @@ +-- 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 new file mode 100644 index 0000000..a823ee8 --- /dev/null +++ b/testScripts/scene57.singe @@ -0,0 +1,244 @@ +-- 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 new file mode 100644 index 0000000..1f14e8b --- /dev/null +++ b/testScripts/scene58.singe @@ -0,0 +1,71 @@ +-- 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 new file mode 100644 index 0000000..8b87931 --- /dev/null +++ b/testScripts/scene59.singe @@ -0,0 +1,93 @@ +-- 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 new file mode 100644 index 0000000..2cbbe5e --- /dev/null +++ b/testScripts/scene60.singe @@ -0,0 +1,148 @@ +-- 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 new file mode 100644 index 0000000..698ec8b --- /dev/null +++ b/testScripts/scene61.singe @@ -0,0 +1,105 @@ +-- 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 new file mode 100644 index 0000000..fe9b9b4 --- /dev/null +++ b/testScripts/scene62.singe @@ -0,0 +1,64 @@ +-- 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 new file mode 100644 index 0000000..9aff325 --- /dev/null +++ b/testScripts/scene63.singe @@ -0,0 +1,139 @@ +-- 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 new file mode 100644 index 0000000..f28bede --- /dev/null +++ b/testScripts/scene64.singe @@ -0,0 +1,145 @@ +-- 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 new file mode 100644 index 0000000..caa4865 --- /dev/null +++ b/testScripts/scene65.singe @@ -0,0 +1,147 @@ +-- 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 new file mode 100644 index 0000000..f89d6fd --- /dev/null +++ b/testScripts/scene66.singe @@ -0,0 +1,55 @@ +-- 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 new file mode 100644 index 0000000..29c650c --- /dev/null +++ b/testScripts/scene67.singe @@ -0,0 +1,64 @@ +-- 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 new file mode 100644 index 0000000..75293f3 --- /dev/null +++ b/testScripts/scene68.singe @@ -0,0 +1,75 @@ +-- 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 new file mode 100644 index 0000000..1119df3 --- /dev/null +++ b/testScripts/scene69.singe @@ -0,0 +1,179 @@ +-- 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 new file mode 100644 index 0000000..703d5d6 --- /dev/null +++ b/testScripts/scene70.singe @@ -0,0 +1,82 @@ +-- 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 new file mode 100644 index 0000000..2273f7c --- /dev/null +++ b/testScripts/scene71.singe @@ -0,0 +1,49 @@ +-- 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 new file mode 100644 index 0000000..81e49f4 --- /dev/null +++ b/testScripts/scene72.singe @@ -0,0 +1,96 @@ +-- 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 new file mode 100644 index 0000000..af9089a --- /dev/null +++ b/testScripts/scene73.singe @@ -0,0 +1,53 @@ +-- 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 new file mode 100644 index 0000000..1ce3632 --- /dev/null +++ b/testScripts/scene74.singe @@ -0,0 +1,51 @@ +-- 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 new file mode 100644 index 0000000..01531aa --- /dev/null +++ b/testScripts/scene75.singe @@ -0,0 +1,79 @@ +-- 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 new file mode 100644 index 0000000..94152d9 --- /dev/null +++ b/testScripts/scene76.singe @@ -0,0 +1,49 @@ +-- 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 new file mode 100644 index 0000000..ff7f403 --- /dev/null +++ b/testScripts/scene77.singe @@ -0,0 +1,52 @@ +-- 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 new file mode 100644 index 0000000..bb8abbd --- /dev/null +++ b/testScripts/scene78.singe @@ -0,0 +1,43 @@ +-- 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 new file mode 100644 index 0000000..372578c --- /dev/null +++ b/testScripts/scene79.singe @@ -0,0 +1,51 @@ +-- 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 new file mode 100644 index 0000000..8dafff4 --- /dev/null +++ b/testScripts/scene80.singe @@ -0,0 +1,131 @@ +-- 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 new file mode 100644 index 0000000..a6f349a --- /dev/null +++ b/testScripts/scene81.singe @@ -0,0 +1,73 @@ +-- 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 new file mode 100644 index 0000000..14acba5 --- /dev/null +++ b/testScripts/scene82.singe @@ -0,0 +1,134 @@ +-- 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 new file mode 100644 index 0000000..2bcc154 --- /dev/null +++ b/testScripts/scene83.singe @@ -0,0 +1,99 @@ +-- 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 new file mode 100644 index 0000000..1bf70f7 --- /dev/null +++ b/testScripts/scene84.singe @@ -0,0 +1,186 @@ +-- 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 new file mode 100644 index 0000000..f478251 --- /dev/null +++ b/testScripts/scene85.singe @@ -0,0 +1,130 @@ +-- 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 new file mode 100644 index 0000000..dcc7261 --- /dev/null +++ b/testScripts/scene86.singe @@ -0,0 +1,232 @@ +-- 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 new file mode 100644 index 0000000..29ef1d2 --- /dev/null +++ b/testScripts/scene87.singe @@ -0,0 +1,208 @@ +-- 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 new file mode 100644 index 0000000..4dcdb6d --- /dev/null +++ b/testScripts/scene88.singe @@ -0,0 +1,254 @@ +-- 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 new file mode 100644 index 0000000..6ccfe96 --- /dev/null +++ b/testScripts/scene89.singe @@ -0,0 +1,353 @@ +-- 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 new file mode 100644 index 0000000..6a59bcf --- /dev/null +++ b/testScripts/scene90.singe @@ -0,0 +1,246 @@ +-- 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 new file mode 100644 index 0000000..ab9e2f7 --- /dev/null +++ b/testScripts/scene91.singe @@ -0,0 +1,308 @@ +-- 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 new file mode 100644 index 0000000..8b327c5 --- /dev/null +++ b/testScripts/scene92.singe @@ -0,0 +1,175 @@ +-- 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 new file mode 100644 index 0000000..013a34c --- /dev/null +++ b/testScripts/scene93.singe @@ -0,0 +1,51 @@ +-- 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 new file mode 100644 index 0000000..31f13fa --- /dev/null +++ b/testScripts/scene94.singe @@ -0,0 +1,54 @@ +-- 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 new file mode 100644 index 0000000..6f45f3b --- /dev/null +++ b/testScripts/scene95.singe @@ -0,0 +1,56 @@ +-- 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 new file mode 100644 index 0000000..07c3443 --- /dev/null +++ b/testScripts/scene96.singe @@ -0,0 +1,62 @@ +-- Forge: the overlay bits the ActionMax port needed -- a text look's font, size, and anchors; +-- a sprite in copies; a pointer-following crosshair; discPause and discPlay; pointerX. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/overlayBits.forge", singeGetDataPath() .. "overlayBits.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local heldAt = nil +local heldFrames = 0 + + +function onOverlayUpdate() + frames = frames + 1 + if frames == 5 then + authorSetPointer(1, 100, 200, true) + elseif frames == 20 then + authorSetPointer(1, 600, 200, true) + elseif frames == 30 then + AUTHOR_VARS.coins = 5 + authorKeyDown(0, SCANCODE.SPACE.value) + elseif (frames > 32) and (frames < 60) then + heldAt = heldAt or discGetFrame() + if discGetFrame() == heldAt then + heldFrames = heldFrames + 1 + end + elseif frames == 60 then + authorKeyDown(0, SCANCODE.RETURN.value) + elseif frames == 62 then + singeScreenshot() + end + local r = gameUpdate() + + if frames == 10 then + local cross = authorEntity("cross") + local cx, cy = authorPosition(cross) + + if (AUTHOR_VARS.side ~= "left") or (cross.vars.text ~= nil) then debugPrint("BITS FAIL the pointer on the left was not seen: " .. tostring(AUTHOR_VARS.side)) end + if (math.abs(cx - 100) > 1) or (math.abs(cy - 200) > 1) then debugPrint(string.format("BITS FAIL the crosshair is at %.0f,%.0f, not on the pointer", cx, cy)) end + elseif frames == 25 then + if AUTHOR_VARS.side ~= "right" then debugPrint("BITS FAIL the pointer on the right was not seen") end + elseif frames == 90 then + local coins = authorEntity("coins") + local mid = authorEntity("mid") + local w, h = authorSize(authorEntity("title")) + + debugPrint(string.format("BITS RESULT side=%s copies=%s held=%d frames at %s, now %d, title %dx%d, mid says %s", AUTHOR_VARS.side, tostring(coins.vars.copies), heldFrames, tostring(heldAt), discGetFrame(), w, h, mid.vars.text)) + if coins.vars.copies ~= 5 then debugPrint("BITS FAIL the coins did not follow the var") end + if heldFrames < 20 then debugPrint("BITS FAIL discPause did not hold the disc") end + if discGetFrame() <= (heldAt or 0) then debugPrint("BITS FAIL discPlay did not let the disc go") end + if h < 30 then debugPrint("BITS FAIL the title's own font was not used (36 points)") end + if mid.vars.text ~= "pointer on the right" then debugPrint("BITS FAIL the centred text did not say where the pointer is") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene97.singe b/testScripts/scene97.singe new file mode 100644 index 0000000..22539d8 --- /dev/null +++ b/testScripts/scene97.singe @@ -0,0 +1,85 @@ +-- Forge: a game's own vocabulary. The description names a file of looks, behaviours, +-- conditions, and actions that this game alone knows; the editor offers them while the game is +-- open and forgets them when it closes; the compiler checks against them; the built game +-- loads them and plays by them. +FORGE_LIBRARY = true +dofile("Forge/Forge.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +local work = singeGetDataPath() .. "vocab.forge" + +-- The editor: the vocabulary is there while the game is open, and gone when it is closed. +authorCopy("testScripts/author/vocab.forge", work) +authorCopy("testScripts/author/vocab.singe", singeGetDataPath() .. "vocab.singe") +if not forgeBegin(work) then + debugPrint("VOCAB FAIL the editor could not open the game") +end +if AUTHOR.behaviours.bob and AUTHOR.conditions.bobbing and AUTHOR.actions.setBob then + debugPrint("VOCAB the editor knows the game's own bob, bobbing, and setBob") +else + debugPrint("VOCAB FAIL the editor did not load the game's vocabulary") +end +local built = forgeBuild(singeGetDataPath() .. "vocabGame.singe") +local file = built and io.open(singeGetDataPath() .. "vocabGame.singe", "r") +local text = file and file:read("a") or "" + +if file then + file:close() +end +if text:find('authorVocabulary("vocab.singe")', 1, true) then + debugPrint("VOCAB the built game loads the vocabulary") +else + debugPrint("VOCAB FAIL the built game does not name the vocabulary") +end +forgeClose() +if AUTHOR.behaviours.bob or AUTHOR.conditions.bobbing or AUTHOR.actions.setBob then + debugPrint("VOCAB FAIL closing the game did not forget its vocabulary") +else + debugPrint("VOCAB closing the game forgot its vocabulary") +end + +-- The game: built and played, the buoy bobs and the rules use the game's own words. +dofile(singeGetDataPath() .. "vocabGame.singe") + +local gameUpdate = onOverlayUpdate +local frames = 0 +local lowest = 1000 +local highest = 0 +local lowest2 = 1000 +local highest2 = 0 + + +function onOverlayUpdate() + frames = frames + 1 + local r = gameUpdate() + local buoy = authorEntity("buoy") + local rock = authorEntity("rock") + local _, by = authorPosition(buoy) + local _, ry = authorPosition(rock) + + if frames <= 90 then + lowest = math.min(lowest, by) + highest = math.max(highest, by) + elseif frames > 92 then + lowest2 = math.min(lowest2, by) + highest2 = math.max(highest2, by) + end + if frames == 91 then + authorKeyDown(0, SCANCODE.SPACE.value) + elseif frames == 60 then + singeScreenshot() + elseif frames == 180 then + debugPrint(string.format("VOCAB RESULT buoy %.0f..%.0f then %.0f..%.0f, rock at %.0f, seen %d frames bobbing", lowest, highest, lowest2, highest2, ry, AUTHOR_VARS.seen)) + if (highest - lowest) < 60 or (highest - lowest) > 82 then debugPrint("VOCAB FAIL the buoy did not bob 40 either way") end + if (highest2 - lowest2) < 150 then debugPrint("VOCAB FAIL setBob did not raise the bob to 100") end + if math.abs(ry - 240) > 1 then debugPrint("VOCAB FAIL the rock moved") end + if AUTHOR_VARS.seen < 170 then debugPrint("VOCAB FAIL the bobbing condition was not true every frame") end + singeQuit() + end + + return r +end diff --git a/testScripts/scene98.singe b/testScripts/scene98.singe new file mode 100644 index 0000000..dfbe7da --- /dev/null +++ b/testScripts/scene98.singe @@ -0,0 +1,56 @@ +-- Forge: mash and hold as native kinds of a branching track's move. The first window is +-- mashed six times and taken; the second is held long enough and taken; the third is mashed +-- only three times and missed. +dofile("Forge/AuthorCompile.singe") + +local font = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(font) +fontQuality(FONT_QUALITY_BLENDED) + +dofile(authorBuild("testScripts/author/mash.forge", singeGetDataPath() .. "mash.singe")) + +local gameUpdate = onOverlayUpdate +local frames = 0 +local taps = 0 +local held = false + + +function onOverlayUpdate() + local disc = discGetFrame() + + frames = frames + 1 + -- Six taps in the first window, three in the third; a tap is a press one frame and a + -- release the next. + if ((disc >= 125) and (disc < 150) and (taps < 6)) or ((disc >= 285) and (disc < 305) and (taps >= 6) and (taps < 9)) then + if frames % 2 == 0 then + authorKeyDown(0, SCANCODE.RIGHT.value) + taps = taps + 1 + else + authorKeyUp(0, SCANCODE.RIGHT.value) + end + end + -- UP held through the second window. + if (disc >= 205) and (disc < 250) and not held then + authorKeyDown(0, SCANCODE.UP.value) + held = true + elseif (disc >= 250) and held then + authorKeyUp(0, SCANCODE.UP.value) + held = false + end + local r = gameUpdate() + + if disc == 140 then + singeScreenshot() + end + -- The stand-in video loops at its end; the run ends before it does. + if (disc >= 370) or (frames > 1200) then + debugPrint(string.format("MASH RESULT taken=%d missed=%d opened=%s", AUTHOR_VARS.taken, AUTHOR_VARS.missed, AUTHOR_VARS.opened)) + if AUTHOR_VARS.taken ~= 2 then debugPrint("MASH FAIL the mashed and held windows were not both taken") end + if AUTHOR_VARS.missed ~= 1 then debugPrint("MASH FAIL the under-mashed window was not missed") end + if AUTHOR_VARS.opened ~= "RIGHT UP RIGHT " then debugPrint("MASH FAIL the windows did not open in order") end + singeQuit() + end + + return r +end diff --git a/testScripts/strip.png b/testScripts/strip.png new file mode 100644 index 0000000..b76e484 --- /dev/null +++ b/testScripts/strip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0db95bbc1d8fa6a4cd12a98eac1a28b68dd5795bc3c28380f8786906b4636971 +size 1201 diff --git a/testScripts/tracker.it b/testScripts/tracker.it new file mode 100644 index 0000000..ec71894 --- /dev/null +++ b/testScripts/tracker.it @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:424d76d2ac417a070c7fe81ee92671361c4a8e5ba6917a43010747f0262f15a5 +size 2300 diff --git a/testScripts/tracker.s3m b/testScripts/tracker.s3m new file mode 100644 index 0000000..bc4bf86 Binary files /dev/null and b/testScripts/tracker.s3m differ diff --git a/testScripts/tracker.xm b/testScripts/tracker.xm new file mode 100644 index 0000000..c7f4a0e --- /dev/null +++ b/testScripts/tracker.xm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3e611f314fc1eaff5ff0f1aab9b41fc4064dddcb85ddd460bae0bd51bdde479 +size 1549 diff --git a/testScripts/tune.mp3 b/testScripts/tune.mp3 new file mode 100644 index 0000000..e015326 --- /dev/null +++ b/testScripts/tune.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b7f564d0511be978ab9a02f4d5027bb15c2274bb94747d1b6678c33371733b2 +size 72769 diff --git a/testScripts/tutorialDrive.singe b/testScripts/tutorialDrive.singe new file mode 100644 index 0000000..2f3b2b1 --- /dev/null +++ b/testScripts/tutorialDrive.singe @@ -0,0 +1,192 @@ +-- 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 + local id + + 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. The same goes for + -- the game's id, which the first save makes up at random: it is set aside while the two are + -- compared and put back for the build. + FORGE.game.source = nil + id = FORGE.game.id + FORGE.game.id = 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 + FORGE.game.id = id + built = forgeBuild(TUTORIAL_OUT .. TUTORIAL.name .. ".singe") + forgeClose() + + return built +end diff --git a/util/forgeCredits.lua b/util/forgeCredits.lua new file mode 100644 index 0000000..f08ca44 --- /dev/null +++ b/util/forgeCredits.lua @@ -0,0 +1,191 @@ +-- The original author's credits for a Forge port of an existing game: the fields of the game's own +-- games.dat entry (developer, publisher, year, platform, genre, creator, source, description), the +-- "KEY: value" lines of its main script's header comment as notes, and the licence files beside +-- the script, which a release carries. Nothing is invented: a field the original does not state +-- is left out. +-- +-- lua5.4 util/forgeCredits.lua <description.forge> <game directory> <main script> +-- +-- writes or replaces the description's credits block, just after its title, and touches nothing +-- else in it. util/forgePortKaris.lua loads this file for creditsFor, so a converted description +-- carries its credits from the start. + +local CREDIT_KEYS = { + { field = "developer", key = "DEVELOPER" }, + { field = "publisher", key = "PUBLISHER" }, + { field = "year", key = "YEAR" }, + { field = "platform", key = "PLATFORM" }, + { field = "genre", key = "GENRE" }, + { field = "creator", key = "CREATOR" }, + { field = "source", key = "SOURCE" }, + { field = "description", key = "DESCRIPTION" } +} +local LICENCE_PATTERNS = { "^copying", "^licen[cs]e", "^gpl" } +local GAMES_DAT_DEPTH = 2 -- How many folders above the script games.dat may be. + + +local function readFile(path) + local handle = io.open(path, "rb") + local text = nil + + if handle then + text = handle:read("a") + handle:close() + end + + return text +end + + +-- The games.dat entry for the script: beside it or a folder or two above, the entry whose SCRIPT +-- names this script, or the only entry there is. +local function gamesEntry(dir, main) + local folder = dir + + for _ = 0, GAMES_DAT_DEPTH do + local text = readFile(folder .. "/games.dat") + + if text then + local sandbox = {} + local chunk = load(text, "games.dat", "t", sandbox) + + if chunk and pcall(chunk) and (type(sandbox.GAMES) == "table") then + for _, entry in ipairs(sandbox.GAMES) do + local script = tostring(entry.SCRIPT or "") + + if script:lower():sub(-#main) == main:lower() then + return entry + end + end + if #sandbox.GAMES == 1 then + return sandbox.GAMES[1] + end + end + end + folder = folder:match("^(.*)[/\\][^/\\]+$") or folder + end + + return nil +end + + +-- The header comment's "KEY: value" lines, as the author wrote them: program name, author, who +-- converted it and for what. +local function headerNotes(dir, main) + local text = readFile(dir .. "/" .. main) or "" + local block = text:match("%-%-%[=*%[(.-)%]=*%]") + local notes = {} + + for line in (block or ""):gmatch("[^\r\n]+") do + local key, value = line:match("^%s*([A-Z][A-Z0-9 /%-]*[A-Z0-9])%s*:%s*(.-)%s*$") + + if key and (value ~= "") then + notes[#notes + 1] = key:sub(1, 1) .. key:sub(2):lower() .. ": " .. value + end + end + + return notes +end + + +-- The licence files beside the script, by name. +local function licences(dir) + local found = {} + local handle = io.popen('ls -1 "' .. dir .. '" 2>/dev/null') + + if handle then + for name in handle:lines() do + for _, pattern in ipairs(LICENCE_PATTERNS) do + if name:lower():match(pattern) then + found[#found + 1] = name + break + end + end + end + handle:close() + end + table.sort(found) + + return found +end + + +-- The credits block as description source, one line, or nil when there is nothing to credit. +function creditsFor(dir, main) + local entry = gamesEntry(dir, main) or {} + local parts = {} + local notes = headerNotes(dir, main) + local files = licences(dir) + + for _, credit in ipairs(CREDIT_KEYS) do + local value = entry[credit.key] + + if type(value) == "number" then + parts[#parts + 1] = string.format("%s = %d", credit.field, value) + elseif (type(value) == "string") and (value ~= "") then + parts[#parts + 1] = string.format("%s = %q", credit.field, value) + end + end + if #notes > 0 then + local quoted = {} + + for _, note in ipairs(notes) do + quoted[#quoted + 1] = string.format("%q", note) + end + parts[#parts + 1] = "notes = { " .. table.concat(quoted, ", ") .. " }" + end + if #files > 0 then + local quoted = {} + + for _, name in ipairs(files) do + quoted[#quoted + 1] = string.format("%q", name) + end + parts[#parts + 1] = "licenses = { " .. table.concat(quoted, ", ") .. " }" + end + if #parts == 0 then + return nil + end + + return "\tcredits = { " .. table.concat(parts, ", ") .. " }," +end + + +-- The description with its credits line put just after the title, replacing one already there. +local function withCredits(text, line) + local out = {} + local after = false + + for row in (text .. "\n"):gmatch("(.-)\n") do + if not row:match("^\tcredits = {") then + out[#out + 1] = row + if (not after) and line and row:match("^\ttitle%s*=") then + out[#out + 1] = line + after = true + end + end + end + while (#out > 0) and (out[#out] == "") do + out[#out] = nil + end + + return table.concat(out, "\n") .. "\n", after +end + + +if arg and arg[0] and arg[0]:match("forgeCredits%.lua$") then + local description = arg[1] or error("a description") + local dir = arg[2] or error("the game's directory") + local main = arg[3] or error("its main script") + local text = readFile(description) or error("cannot read " .. description) + local line = creditsFor(dir, main) + local result, put = withCredits(text, line) + + if line and not put then + error("no title line in " .. description) + end + local handle = io.open(description, "wb") + + handle:write(result) + handle:close() + print(description .. ": " .. (line and line:gsub("^\t", "") or "nothing to credit")) +end diff --git a/util/forgeKit.py b/util/forgeKit.py new file mode 100644 index 0000000..1b51e93 --- /dev/null +++ b/util/forgeKit.py @@ -0,0 +1,371 @@ +#!/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 new file mode 100644 index 0000000..4782ddb --- /dev/null +++ b/util/forgeLedger.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# The ledger of FORGE.md section 17: every capability the library's converted titles use, which +# titles use it, and how the converter maps it, gathered from util/forgePortKaris.lua's report +# (FORGE_LEDGER) for each description it made. Nothing is run but the converter; no game plays. +# +# python3 util/forgeLedger.py [<out>] (default: testScripts/ports/ledger.md) +# +# SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) +# +# A status is the converter's own: "forge" is a field of Forge's model, "framework" something +# Forge plays under the framework's names or numbers, "lua" something the game's own Lua still does. +# The runs-without-its-Lua column is testScripts/ports/standalone.txt as last proved, with its date. +import collections +import datetime +import hashlib +import os +import re +import subprocess +import sys +import tempfile + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported")) +OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "testScripts", "ports", "ledger.md") +STANDING = os.path.join(REPO, "testScripts", "ports", "standalone.txt") +MADE_BY = "by the port converter" +SCRIPT = re.compile(r'^\s*script\s*=\s*"([^"]+)"', re.M) +STATUSES = ("lua", "framework", "forge") +EXAMPLES = 6 + + +def descriptions(): + """Every description the converter made, as (title, directory, main script, name).""" + found = [] + for root, dirs, files in os.walk(LIBRARY): + dirs.sort() + for name in sorted(f for f in files if f.endswith(".forge")): + path = os.path.join(root, name) + text = open(path, errors="replace").read() + if MADE_BY not in text: + continue + script = SCRIPT.search(text) + if not script: + continue + title = os.path.relpath(path, LIBRARY)[:-len(".forge")] + found.append((title, root, script.group(1), name[:-len(".forge")])) + return found + + +def others(): + """The descriptions another tool made, named so the ledger says what it does not cover.""" + missing = [] + for root, dirs, files in os.walk(LIBRARY): + for name in files: + path = os.path.join(root, name) + if name.endswith(".forge") and MADE_BY not in open(path, errors="replace").read(): + missing.append(os.path.relpath(path, LIBRARY)) + return sorted(missing) + + +def addonsHash(directory): + path = os.path.join(directory, "Script", "addons.singe") + return hashlib.md5(open(path, "rb").read()).hexdigest() if os.path.exists(path) else None + + +def report(directory, script, name): + with tempfile.NamedTemporaryFile(suffix=".ledger", delete=False) as handle: + path = handle.name + try: + done = subprocess.run(["lua5.4", os.path.join("util", "forgePortKaris.lua"), directory, script, name], + cwd=REPO, env=dict(os.environ, FORGE_LEDGER=path), capture_output=True, text=True, timeout=120) + lines = [line.rstrip("\n").split("\t") for line in open(path) if line.strip()] + return lines, (done.stderr.strip().splitlines() or [""])[-1] if done.returncode else None + finally: + os.remove(path) + + +def main(): + standing = set() + proved = "never" + if os.path.exists(STANDING): + standing = {line.strip() for line in open(STANDING) if line.strip() and not line.startswith("#")} + proved = datetime.date.fromtimestamp(os.path.getmtime(STANDING)).isoformat() + + uses = collections.defaultdict(set) # (status, capability) -> titles + details = collections.defaultdict(set) # (status, capability) -> details + perTitle = {} + failed = [] + titles = descriptions() + # The add-ons most titles ship unchanged are the framework's own: their hooks are the stock + # ones, not the game's Lua. + hashes = collections.Counter(h for h in (addonsHash(row[1]) for row in titles) if h) + stock = hashes.most_common(1)[0][0] if hashes else None + for title, directory, script, name in titles: + lines, error = report(directory, script, name) + if error is not None: + failed.append((title, error)) + continue + if stock and addonsHash(directory) == stock: + lines = [["framework", capability, "stock add-ons"] if capability.startswith("hook.") else [status, capability, detail] for status, capability, detail in lines] + counts = collections.Counter() + family = "?" + for status, capability, detail in lines: + uses[(status, capability)].add(title) + if detail: + details[(status, capability)].add(detail) + counts[status] += 1 + if capability.startswith("family."): + family = capability[len("family."):] + folder = title.split("/")[1] if title.startswith("hypseus/") else title.split("/")[0] + perTitle[title] = (family, counts, "yes" if folder in standing else "not on the list") + + out = [] + out.append("# The ledger (FORGE.md section 17)") + out.append("") + out.append("Generated by `util/forgeLedger.py` on %s from the converter's report for each" % datetime.date.today().isoformat()) + out.append("description it made; do not edit by hand. **forge** is a field of Forge's own model,") + out.append("**framework** something Forge plays under the framework's names or numbers, **lua**") + out.append("something the game's own Lua still does. Rows are sorted by status, then by how many") + out.append("titles use them: the top of the table is what most blocks titles from being Forge's own.") + out.append("A hook from the add-ons file most titles ship unchanged (%d of them) is the framework's" % (hashes[stock] if stock else 0)) + out.append("stock one, and counts as framework. The globals are every global the game's script left") + out.append("that nothing names: most are the framework's own state and picture numbers, not data.") + out.append("") + out.append("%d titles reported; %d failed to report; %d descriptions made by another tool (not covered)." % (len(perTitle), len(failed), len(others()))) + out.append("") + + # The capabilities, with the globals and settings folded into one row each per status: there + # are hundreds, and their names are listed in the section after. + folded = collections.defaultdict(set) + for (status, capability), titles in uses.items(): + if capability.startswith("global.") or capability.startswith("setting."): + folded[(status, capability.split(".")[0] + ".*")] |= titles + else: + folded[(status, capability)] |= titles + out.append("## Capabilities") + out.append("") + out.append("| Status | Capability | Titles | Some of them | Details |") + out.append("|---|---|---|---|---|") + for (status, capability), titles in sorted(folded.items(), key=lambda item: (STATUSES.index(item[0][0]), -len(item[1]), item[0][1])): + names = sorted(titles) + more = ", ..." if len(names) > EXAMPLES else "" + shown = ", ".join(title.split("/")[-1] for title in names[:EXAMPLES]) + more + found = sorted(details.get((status, capability), [])) + note = "; ".join(found[:8]) + ("; ..." if len(found) > 8 else "") + out.append("| %s | %s | %d | %s | %s |" % (status, capability, len(names), shown, note)) + out.append("") + + for prefix, heading in (("setting.", "Settings under the framework's names"), ("global.", "Globals nothing names yet")): + rows = sorted(((capability, len(titles)) for (status, capability), titles in uses.items() if capability.startswith(prefix)), key=lambda row: (-row[1], row[0])) + out.append("## %s (%d)" % (heading, len(rows))) + out.append("") + out.append("By how many titles use each: " + ", ".join("%s %d" % (capability[len(prefix):], count) for capability, count in rows) + ".") + out.append("") + + out.append("## Titles") + out.append("") + out.append("Runs without its Lua: `standalone.txt`, last proved %s." % proved) + out.append("") + out.append("| Title | Family | forge | framework | lua | Runs without its Lua |") + out.append("|---|---|---|---|---|---|") + for title, (family, counts, standalone) in sorted(perTitle.items(), key=lambda item: (item[1][1]["lua"], item[1][1]["framework"], item[0])): + out.append("| %s | %s | %d | %d | %d | %s |" % (title, family, counts["forge"], counts["framework"], counts["lua"], standalone)) + out.append("") + if failed: + out.append("## Failed to report") + out.append("") + for title, error in failed: + out.append("* %s: %s" % (title, error)) + out.append("") + out.append("## Made by another tool") + out.append("") + out.append(", ".join(others()) + ".") + out.append("") + + with open(OUT, "w") as handle: + handle.write("\n".join(out)) + print("wrote %s: %d titles, %d capabilities, %d failed" % (OUT, len(perTitle), len(folded), len(failed))) + + +if __name__ == "__main__": + main() diff --git a/util/forgePortActionMax.py b/util/forgePortActionMax.py new file mode 100644 index 0000000..824f6aa --- /dev/null +++ b/util/forgePortActionMax.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# Writes a Forge description for each ActionMax game beside its original script, from the +# original's own numbers: the parameter file (video lengths, the sensor spot, the thresholds) +# and the sprites' sizes, laid out as Emulator.singe lays them out. Text the emulator renders +# to sprites is measured with its font here, which can sit a pixel from what SDL_ttf measures. +# +# python3 util/forgePortActionMax.py ~/claude/singetest/ActionMax +# +# The descriptions are data: types, rooms (one per state of the emulator), and rules. Run one +# with the port scene in testScripts/ports/, or open it in Forge. + +import os +import re +import sys + +from PIL import Image, ImageFont + +WIDTH = 360 # The overlay the emulator lays itself out on: the engine's default over its +HEIGHT = 240 # video, which every number in it -- the sensor spot included -- assumes. +GAMES = ["38AmbushAlley", "BlueThunder", "Hydrosub2021", "PopsGhostly", "SonicFury"] +TITLES = { + "38AmbushAlley": ".38 Ambush Alley", + "BlueThunder": "Blue Thunder", + "Hydrosub2021": "Hydrosub: 2021", + "PopsGhostly": "Rescue of Pops Ghostly, The", + "SonicFury": "Sonic Fury", +} +AMMO = 5 # Misses allowed in limited ammo mode. +SCORE_SHOWN = 4 # Seconds the floating score stays, as the emulator's heartbeat counts. +LIGHT_SHOWN = 2 # Seconds the light stays lit after a good hit. +SCORE_HEIGHT = 13 # The emulator's line height for the last game's figures. + + +def parameters(path): + values = {} + for line in open(path): + m = re.match(r"^\s*(\w+)\s*=\s*(\"?)([^\"\s]+)\2\s*$", line) + if m: + key, value = m.group(1), m.group(3) + values[key] = value if m.group(2) else int(value) + return values + + +def size(folder, name): + with Image.open(os.path.join(folder, name)) as im: + return im.size + + +def textSize(folder, font, points, text): + face = ImageFont.truetype(os.path.join(folder, font), points) + left, top, right, bottom = face.getbbox(text) + ascent, descent = face.getmetrics() + return right - left, ascent + descent + + +def lua(value): + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return repr(value) if isinstance(value, float) else str(value) + return '"' + value.replace('"', '\\"') + '"' + + +def describe(folder, game): + p = parameters(os.path.join(folder, game + ".singe")) + half = WIDTH / 2 + quarter = half / 2 + intro = p["lengthIntro"] + length = p["lengthGame"] + menu = p["lengthMenu"] + logoW, logoH = size(folder, "sprite_ActionMax.png") + boxW, boxH = size(folder, "sprite_" + game + ".png") + lightW, lightH = size(folder, "sprite_LightOff.png") + bulletW, bulletH = size(folder, "sprite_Bullet.png") + crossW, crossH = size(folder, "sprite_Crosshair.png") + pullW, pullH = textSize(folder, "font_BlueStone.ttf", 20, "Pull Trigger to Start!") + lastW, lastH = textSize(folder, "font_chemrea.ttf", 16, "LAST GAME SCORE") + # Emulator.singe's setup state, number for number. + logoTop = 5 + logoHeight = logoTop + logoH + boxTop = (HEIGHT - (logoHeight + pullH)) / 2 - boxH / 2 + logoHeight + lastGameLeft = quarter - lastW / 2 + lastGameTop = logoHeight + logoTop + scoreTop = lastGameTop + SCORE_HEIGHT + 10 + menuVideo = intro + length + 2 + menuEnd = intro + length + menu + + def entity(kind, ident, x, y): + return "\t\t\t{ type = %s, id = %s, x = %s, y = %s }" % (lua(kind), lua(ident), lua(x), lua(y)) + + def text(name, words, r, g, b, font=None, points=None, anchor=None): + look = "look = { kind = \"text\", text = %s, r = %d, g = %d, b = %d" % (lua(words), r, g, b) + if font: + look += ", font = %s, size = %d" % (lua(font), points) + if anchor: + look += ", anchor = %s" % lua(anchor) + return "\t\t%s = { %s } }," % (name, look) + + types = [ + "\t\tlogo = { look = { kind = \"sprite\", file = \"sprite_ActionMax.png\" } },", + "\t\tboxArt = { look = { kind = \"sprite\", file = %s } }," % lua("sprite_" + game + ".png"), + "\t\tlightOff = { look = { kind = \"sprite\", file = \"sprite_LightOff.png\" } },", + "\t\tlightOn = { look = { kind = \"sprite\", file = \"sprite_LightOn.png\" } },", + "\t\tbullets = { look = { kind = \"sprite\", file = \"sprite_Bullet.png\", step = %d }, vars = { copies = 0 } }," % (-bulletW), + "\t\tcross = { look = { kind = \"sprite\", file = \"sprite_Crosshair.png\" }, behaviours = { { kind = \"pointer\", player = 1 } } },", + "\t\tsensor = { look = { kind = \"none\" },", + "\t\t behaviours = { { kind = \"lightSensor\", x = %d, y = %d, left = %d, top = %d, high = %d, low = %d, trigger = \"SWITCH_BUTTON3\", player = 1 }," + % (p["sensorX"], p["sensorY"], p["sensorLeft"], p["sensorTop"], p["highThreshold"], p["lowThreshold"]), + "\t\t { kind = \"sound\", fire = \"sound_Gunshot.wav\", hit = \"sound_GoodHit.wav\", miss = \"sound_BadHit.wav\" } } },", + "\t\tjingle = { look = { kind = \"none\" }, behaviours = { { kind = \"sound\", spawn = \"sound_ActionMax.wav\" } } },", + text("pull", "Pull Trigger to Start!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"), + text("ready", "Get Ready!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"), + text("lastGame", "LAST GAME SCORE", 255, 255, 255, "font_chemrea.ttf", 16, "top"), + text("figure", "", 200, 200, 200, "font_chemrea.ttf", 16), + text("choose", "Select Game Type", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("standard1", "Standard", 255, 255, 0, "font_chemrea.ttf", 32, "feet"), + text("standard2", "Game", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("limited1", "Limited", 255, 255, 0, "font_chemrea.ttf", 32, "feet"), + text("limited2", "Ammo", 255, 255, 0, "font_chemrea.ttf", 32, "top"), + text("over", "GAME OVER", 255, 0, 0, "font_chemrea.ttf", 48, "feet"), + text("floating", "", 255, 0, 0, "font_LED_Real.ttf", 32), + ] + # The last game's figures, five lines from the emulator's title state. + figures = [ + ("fired", " Shots Fired: ", "shots", 0), + ("good", " Good Hits: ", "good", 1), + ("bad", " Bad Hits: ", "bad", 2), + ("shot", " Shot Score: ", "good - bad", 3), + ("percent", " Game Score: ", "percent", 5), + ] + titleEntities = [ + entity("logo", "logo", half, logoTop + logoH / 2), + entity("boxArt", "boxArt", quarter + half, boxTop + boxH / 2), + entity("lastGame", "lastGame", quarter, lastGameTop), + entity("pull", "pull", half, HEIGHT), + entity("cross", "cross", 0, 0), + entity("sensor", "sensor", 0, 0), + ] + for ident, _, _, line in figures: + titleEntities.append(entity("figure", ident, lastGameLeft, scoreTop + SCORE_HEIGHT * line)) + figureRules = [] + for ident, label, expr, _ in figures: + if expr == "percent": + expr = "shots > 0 and floor((good - bad) / shots * 100) or 0" + words = "%s .. (%s) .. \"%%\"" % (lua(label), expr) + else: + words = "%s .. (%s)" % (lua(label), expr.replace("fired", "shots")) + figureRules.append("\t\t{ note = %s, on = \"frame\", room = \"title\", act = { { \"setText\", entity = %s, text = %s } } }," + % (lua("The last game's " + ident), lua(ident), lua(words))) + out = [] + out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game]) + out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the") + 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 new file mode 100644 index 0000000..53df526 --- /dev/null +++ b/util/forgePortKaris.lua @@ -0,0 +1,1177 @@ +-- 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 new file mode 100644 index 0000000..d3fb75f --- /dev/null +++ b/util/forgePorts.py @@ -0,0 +1,321 @@ +#!/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 new file mode 100644 index 0000000..7afa7c7 --- /dev/null +++ b/util/forgeShots.py @@ -0,0 +1,38 @@ +#!/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 new file mode 100644 index 0000000..d22f16c --- /dev/null +++ b/util/forgeStandalone.py @@ -0,0 +1,156 @@ +#!/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 new file mode 100644 index 0000000..558d61e --- /dev/null +++ b/util/forgeVocabulary.lua @@ -0,0 +1,71 @@ +-- 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")