From 721a0d120a59c80e52e1e884c18cc3b6e408ce70 Mon Sep 17 00:00:00 2001 From: Scott Duensing Date: Tue, 22 Sep 2026 21:57:42 -0500 Subject: [PATCH] Manuals are starting to come together. --- CHANGELOG | 545 +++++++------ build-docs.sh | 13 +- docs/Forge.adoc | 43 +- docs/Learn.adoc | 230 ++++++ docs/Manual.adoc | 81 +- docs/images/learn/01-hello.png | 3 + docs/images/learn/02-numbers.png | 3 + docs/images/learn/03-decisions.png | 3 + docs/images/learn/04-repeating.png | 3 + docs/images/learn/05-functions.png | 3 + docs/images/learn/06-tables.png | 3 + docs/images/learn/07-a-game.png | 3 + docs/images/learn/08-when-it-goes-wrong.png | 3 + docs/images/learn/09-pictures.png | 3 + docs/images/learn/10-sound.png | 3 + docs/images/learn/11-hitting-things.png | 3 + docs/images/learn/12-score.png | 3 + docs/images/learn/13-game-folder.png | 3 + docs/images/learn/14-video.png | 3 + docs/images/learn/15-threaded.png | 3 + docs/images/learn/16-branching.png | 3 + docs/images/learn/17-light-guns.png | 3 + docs/images/learn/18-qte.png | 3 + docs/images/learn/19-text.png | 3 + docs/images/learn/20-gui.png | 3 + docs/images/learn/21-subtitles.png | 3 + docs/images/learn/22-3d.png | 3 + docs/images/learn/23-models.png | 3 + docs/images/learn/24-physics.png | 3 + docs/images/learn/25-particles.png | 3 + docs/images/learn/26-navigation.png | 3 + docs/images/learn/27-midi.png | 3 + docs/images/learn/28-online.png | 3 + docs/images/learn/29-shipping.png | 3 + docs/images/learn/30-cabinet.png | 3 + docs/images/singeLogo.png | 3 + docs/learn/01-hello.singe | 6 + docs/learn/02-numbers.singe | 47 ++ docs/learn/03-decisions.singe | 82 ++ docs/learn/04-repeating.singe | 32 + docs/learn/05-functions.singe | 112 +++ docs/learn/06-tables.singe | 134 ++++ docs/learn/07-a-game.singe | 150 ++++ docs/learn/08-when-it-goes-wrong.singe | 179 +++++ docs/learn/09-pictures.singe | 227 ++++++ docs/learn/10-sound.singe | 266 +++++++ docs/learn/11-hitting-things.singe | 188 +++++ docs/learn/12-score.singe | 249 ++++++ docs/learn/13-game-folder.singe | 249 ++++++ docs/learn/14-video.singe | 69 ++ docs/learn/15-threaded.singe | 67 ++ docs/learn/16-branching.singe | 116 +++ docs/learn/17-light-guns.singe | 123 +++ docs/learn/18-qte.singe | 159 ++++ docs/learn/19-text.singe | 95 +++ docs/learn/20-gui.singe | 121 +++ docs/learn/21-subtitles.singe | 80 ++ docs/learn/22-3d.singe | 76 ++ docs/learn/23-models.singe | 62 ++ docs/learn/24-physics.singe | 162 ++++ docs/learn/25-particles.singe | 116 +++ docs/learn/26-navigation.singe | 146 ++++ docs/learn/27-midi.singe | 115 +++ docs/learn/28-online.singe | 129 +++ docs/learn/29-shipping.singe | 81 ++ docs/learn/30-cabinet.singe | 246 ++++++ docs/learn/art/README.txt | 22 + docs/learn/art/boom.wav | 3 + docs/learn/art/rock.png | 3 + docs/learn/art/ship.png | 3 + docs/learn/art/shoot.wav | 3 + docs/learn/art/shot.png | 3 + docs/learn/art/star.png | 3 + docs/learn/art/walk.png | 3 + docs/learn/pause.rml | 28 + docs/learn/subtitles.srt | 12 + docs/lessons/01-hello.adoc | 273 +++++++ docs/lessons/02-numbers.adoc | 404 ++++++++++ docs/lessons/03-decisions.adoc | 505 ++++++++++++ docs/lessons/04-repeating.adoc | 395 +++++++++ docs/lessons/05-functions.adoc | 690 ++++++++++++++++ docs/lessons/06-tables.adoc | 737 +++++++++++++++++ docs/lessons/07-a-game.adoc | 840 ++++++++++++++++++++ docs/lessons/08-when-it-goes-wrong.adoc | 472 +++++++++++ docs/lessons/09-pictures.adoc | 753 ++++++++++++++++++ docs/lessons/10-sound.adoc | 589 ++++++++++++++ docs/lessons/11-hitting-things.adoc | 488 ++++++++++++ docs/lessons/12-score.adoc | 480 +++++++++++ docs/lessons/13-game-folder.adoc | 315 ++++++++ docs/lessons/14-video.adoc | 482 +++++++++++ docs/lessons/15-threaded.adoc | 405 ++++++++++ docs/lessons/16-branching.adoc | 520 ++++++++++++ docs/lessons/17-light-guns.adoc | 550 +++++++++++++ docs/lessons/18-qte.adoc | 589 ++++++++++++++ docs/lessons/19-text.adoc | 491 ++++++++++++ docs/lessons/20-gui.adoc | 579 ++++++++++++++ docs/lessons/21-subtitles.adoc | 478 +++++++++++ docs/lessons/22-3d.adoc | 426 ++++++++++ docs/lessons/23-models.adoc | 431 ++++++++++ docs/lessons/24-physics.adoc | 524 ++++++++++++ docs/lessons/25-particles.adoc | 527 ++++++++++++ docs/lessons/26-navigation.adoc | 590 ++++++++++++++ docs/lessons/27-midi.adoc | 477 +++++++++++ docs/lessons/28-online.adoc | 532 +++++++++++++ docs/lessons/29-shipping.adoc | 464 +++++++++++ docs/lessons/30-cabinet.adoc | 717 +++++++++++++++++ src/singe.c | 29 +- util/learnKit.py | 147 ++++ 108 files changed, 20573 insertions(+), 298 deletions(-) create mode 100644 docs/Learn.adoc create mode 100644 docs/images/learn/01-hello.png create mode 100644 docs/images/learn/02-numbers.png create mode 100644 docs/images/learn/03-decisions.png create mode 100644 docs/images/learn/04-repeating.png create mode 100644 docs/images/learn/05-functions.png create mode 100644 docs/images/learn/06-tables.png create mode 100644 docs/images/learn/07-a-game.png create mode 100644 docs/images/learn/08-when-it-goes-wrong.png create mode 100644 docs/images/learn/09-pictures.png create mode 100644 docs/images/learn/10-sound.png create mode 100644 docs/images/learn/11-hitting-things.png create mode 100644 docs/images/learn/12-score.png create mode 100644 docs/images/learn/13-game-folder.png create mode 100644 docs/images/learn/14-video.png create mode 100644 docs/images/learn/15-threaded.png create mode 100644 docs/images/learn/16-branching.png create mode 100644 docs/images/learn/17-light-guns.png create mode 100644 docs/images/learn/18-qte.png create mode 100644 docs/images/learn/19-text.png create mode 100644 docs/images/learn/20-gui.png create mode 100644 docs/images/learn/21-subtitles.png create mode 100644 docs/images/learn/22-3d.png create mode 100644 docs/images/learn/23-models.png create mode 100644 docs/images/learn/24-physics.png create mode 100644 docs/images/learn/25-particles.png create mode 100644 docs/images/learn/26-navigation.png create mode 100644 docs/images/learn/27-midi.png create mode 100644 docs/images/learn/28-online.png create mode 100644 docs/images/learn/29-shipping.png create mode 100644 docs/images/learn/30-cabinet.png create mode 100644 docs/images/singeLogo.png create mode 100644 docs/learn/01-hello.singe create mode 100644 docs/learn/02-numbers.singe create mode 100644 docs/learn/03-decisions.singe create mode 100644 docs/learn/04-repeating.singe create mode 100644 docs/learn/05-functions.singe create mode 100644 docs/learn/06-tables.singe create mode 100644 docs/learn/07-a-game.singe create mode 100644 docs/learn/08-when-it-goes-wrong.singe create mode 100644 docs/learn/09-pictures.singe create mode 100644 docs/learn/10-sound.singe create mode 100644 docs/learn/11-hitting-things.singe create mode 100644 docs/learn/12-score.singe create mode 100644 docs/learn/13-game-folder.singe create mode 100644 docs/learn/14-video.singe create mode 100644 docs/learn/15-threaded.singe create mode 100644 docs/learn/16-branching.singe create mode 100644 docs/learn/17-light-guns.singe create mode 100644 docs/learn/18-qte.singe create mode 100644 docs/learn/19-text.singe create mode 100644 docs/learn/20-gui.singe create mode 100644 docs/learn/21-subtitles.singe create mode 100644 docs/learn/22-3d.singe create mode 100644 docs/learn/23-models.singe create mode 100644 docs/learn/24-physics.singe create mode 100644 docs/learn/25-particles.singe create mode 100644 docs/learn/26-navigation.singe create mode 100644 docs/learn/27-midi.singe create mode 100644 docs/learn/28-online.singe create mode 100644 docs/learn/29-shipping.singe create mode 100644 docs/learn/30-cabinet.singe create mode 100644 docs/learn/art/README.txt create mode 100644 docs/learn/art/boom.wav create mode 100644 docs/learn/art/rock.png create mode 100644 docs/learn/art/ship.png create mode 100644 docs/learn/art/shoot.wav create mode 100644 docs/learn/art/shot.png create mode 100644 docs/learn/art/star.png create mode 100644 docs/learn/art/walk.png create mode 100644 docs/learn/pause.rml create mode 100644 docs/learn/subtitles.srt create mode 100644 docs/lessons/01-hello.adoc create mode 100644 docs/lessons/02-numbers.adoc create mode 100644 docs/lessons/03-decisions.adoc create mode 100644 docs/lessons/04-repeating.adoc create mode 100644 docs/lessons/05-functions.adoc create mode 100644 docs/lessons/06-tables.adoc create mode 100644 docs/lessons/07-a-game.adoc create mode 100644 docs/lessons/08-when-it-goes-wrong.adoc create mode 100644 docs/lessons/09-pictures.adoc create mode 100644 docs/lessons/10-sound.adoc create mode 100644 docs/lessons/11-hitting-things.adoc create mode 100644 docs/lessons/12-score.adoc create mode 100644 docs/lessons/13-game-folder.adoc create mode 100644 docs/lessons/14-video.adoc create mode 100644 docs/lessons/15-threaded.adoc create mode 100644 docs/lessons/16-branching.adoc create mode 100644 docs/lessons/17-light-guns.adoc create mode 100644 docs/lessons/18-qte.adoc create mode 100644 docs/lessons/19-text.adoc create mode 100644 docs/lessons/20-gui.adoc create mode 100644 docs/lessons/21-subtitles.adoc create mode 100644 docs/lessons/22-3d.adoc create mode 100644 docs/lessons/23-models.adoc create mode 100644 docs/lessons/24-physics.adoc create mode 100644 docs/lessons/25-particles.adoc create mode 100644 docs/lessons/26-navigation.adoc create mode 100644 docs/lessons/27-midi.adoc create mode 100644 docs/lessons/28-online.adoc create mode 100644 docs/lessons/29-shipping.adoc create mode 100644 docs/lessons/30-cabinet.adoc create mode 100644 util/learnKit.py diff --git a/CHANGELOG b/CHANGELOG index 0bcc0874c..5db3716ae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,13 +4,22 @@ SINGE 3.00 Unreleased + + API Changes ----------- +- overlayGetFontWidth and overlayGetFontHeight report the size in pixels of one + character cell of the built-in console font. overlayPrint counts in cells + while every other drawing call counts in pixels, and until now nothing said + how big a cell is, so a script converting between the two had to measure the + font by eye and hard-code the answer. + - onInputPressed and onInputReleased receive a second argument when the switch came from a mouse or a light gun: the device's index, as Hypseus passes it, so a two-gun game can tell its players apart. A switch from the keyboard or a pad passes nothing there. + - singeScreenshot takes an optional file name, so a script that makes pictures on purpose can name them. @@ -25,8 +34,10 @@ API Changes - In MODE_FULL a pad or mouse button reaches the script as the switch it is mapped to, as the manual always said; it arrived as a keysym of 0. + - A sprite scaled to nothing (a bullet shrinking to zero) draws as a pixel instead of ending the engine with "Unable to transform sprite". + - --deterministic stepped the disc one video frame per engine frame, whatever the step, and the overlay throttle's strict comparison ran the script every other iteration of the virtual clock, so a game that @@ -37,6 +48,7 @@ API Changes with the virtual clock too, so a game that seeds its generator from the wall clock when a game starts (the one-player Space Pirates) repeats; os.time with a date table is Lua's own. + - fontSelect given no font (nil, or an index that was never loaded) keeps the current font and warns instead of ending the script, as Hypseus lets it pass; The Last Bounty Hunter names a font it never @@ -52,71 +64,6 @@ API Changes TAB lost it. It goes to the GUI only while a text field is being typed in. -- 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 ActionMax games are Forge descriptions written by - util/forgePortActionMax.py, and play the same as the emulator under - the same inputs (testScripts/ports/). On the way: a lightSensor - behaviour, a pointer behaviour, fonts and anchors on the text look, - sprites drawn in copies, discPlay and discPause, pointerX and pointerY - in expressions, an event belonging to the room it happened in, and a - built game finding files beside its description. A game can bring a - vocabulary of its own: a description names a file of Lua - (vocabulary = "Vocabulary.singe") that adds looks, behaviours, - conditions, actions, layers, or events to the manifest for that game - alone; the editor loads it when the game opens and forgets it when - the game closes, the compiler checks against it, and the built game - carries it into a release. The ActionMax light sensor lives in one - now, beside those five games, and not in Forge. The Hypseus library's - eleven Karis Framework 3.32b titles (Arcade Xperience 1 to 3, Astroboy, - Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's - Aurunmilla, and Badlands Lite) are ported by the same converter, which - finds the framework where a game keeps it and notes its version; the - rest of that library is surveyed in FORGE.md 14.9. A branch on the - branching track can want its move mashed (mash = 6) or held (hold = - 20). The seven Kimmy Script Engine titles (Brain Dead 13, Future Boy - Conan, Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Ninja Hayate - 1080) are ported by the same converter and a kimmy behaviour that - plays that engine's loop, and play as their originals do (FORGE.md - 14.11). - util/forgePorts.py - assembles the ported games into a project folder of their own - (~/claude/singePorts), one game to a folder with its description, the - compiled port, the runtime, its files, and a games.dat. A description is a - .forge file now (it was .game, which is the packed release's, and the - chooser had to sniff which was which). The seventeen KarisFramework - games are ported too: util/forgePortKaris.lua reads a game's own - script, and the qte behaviour plays the framework's loop over the - framework's tables, running the game's script and add-ons in a shim; - every one plays as its original under the same driver. Flat - bodies are the size their looks are drawn at (they were half), and a - platformer stands on its point. - - spriteFlip mirrors a sprite left to right, top to bottom, or both, for every draw of it, its frames included, so art that faces one way is enough for a character that walks both ways. @@ -152,62 +99,6 @@ API Changes the command line keep their values. Every game in a library therefore behaves the same whether it was started from the menu or from a shell. -- A game can be described rather than written. Forge/Author.singe and - Forge/AuthorCompile.singe take a table of layers, entities, behaviours - and rules and compile it into an ordinary Singe game: the rules become - real Lua, so nothing walks a table every frame and the result can be - read and edited by hand. There is no notion of genre in it -- a game - declares which of the engine's layers it uses, and that is the whole - difference between a platformer and a quick-time event over video. The - vocabulary of conditions and actions is declared in a table rather than - built into the compiler, so new kinds of game are entries rather than - releases, and the "lua" action is the deliberate way out when a rule - needs something the vocabulary cannot say. See the manual. - -- Forge runs as a game rather than only as a library: started from the - menu it opens on a chooser (its own descriptions, a new game from a - starter, a copy of anything dropped into its directory), the keys, the - mouse and the pad reach the editor, P saves, builds and plays what is on - screen and comes back to it, and ESC closes or leaves. Entities can be - added, duplicated, deleted and typed field by field -- position, look, - behaviours and their parameters, all from the manifest -- and renaming - one renames it in every rule. Conditions and actions are picked from - the vocabulary with their help beside them, rules reorder, a rule's - note is editable, and U undoes forty steps. The compiler loads the - runtime from Forge/, where it lives, so it no longer fails looking for - a Singe/Author.singe that never shipped. - -- An editor for those descriptions, Forge, which is itself - a Singe game: the canvas is the same overlay at the same coordinates the - game will be played in, so what is placed is what is seen. Entity list - and details are an RmlUi document, the canvas beside them is drawn into - the overlay and picked the way a light gun game picks a target, and the - two compose because a button is offered to the GUI first while pointer - motion is never consumed. A description survives a round trip through - it: load, save, load again, and it compiles to the same game. Forge is - distributed on its own and no part of it ships inside Singe -- not the - editor, not the compiler, not the runtime. A game Forge builds carries - its own copy of that runtime, so it runs on a machine that has never had - Forge on it and cannot change behaviour because the engine moved on. - -- A game released from Forge is standalone. forgeExport writes the - compiled script, a games.dat, the description it came from and a copy of - the runtime into a directory of its own, taken out of Forge. --pack turns - that directory into a .game. The game locates its own directory with - debug.getinfo rather than trusting DIR, which names the directory of the - script the engine was launched with -- not this one when a game is reached - by dofile. - -- The editor edits rules, not only entity positions: Tab swaps the panel - between the entities and the event sheet, the selected rule opens in - place with its conditions and actions under it, and conditions and - actions are added from the same manifest the compiler reads, so the rule - editor needs no change when the vocabulary grows. It is driven by keys - as well as the pointer, which is how the bundled menu has always worked - and what a cabinet needs. ENTER types a value into the selected - condition or action and moves to its next one, ESC puts it back; a - number typed in comes back a number. - - The name now expands to "SINGE Is Not a Game Emulator", in the banner, the manual, INSTALL, and the Windows file description. @@ -769,17 +660,6 @@ API Changes singeGetAudioDelay(), the games.dat key AUDIO_DELAY, and --audiodelay=MS; positive values mean the audio is heard late. -- The menu has an audio delay calibration screen for the machine's own - delay (DAC, receiver, Bluetooth, display). Press the service key on - the game list; a click plays once a second and the screen flashes. - Adjust with the stick until the flash and the click coincide, then - press button 1 to save. The value is kept in machine.cfg in the data - root and applied to every game on the machine, from the menu or the - command line, on top of any per-game AUDIO_DELAY. Scripts can read and - set it with singeGetAudioCalibration() / singeSetAudioCalibration(), - read the measured device queue with singeGetAudioLatency(), and now - have singeGetTicks() for a wall clock in milliseconds (os.clock() is - processor time and drifts). - Inside a packed game a relative name with a ".." component resolves nowhere: io.open returns nil and "NAME reaches outside the game", io.lines, io.input and io.output raise it, lfs.mkdir and lfs.rmdir @@ -1102,6 +982,151 @@ API Changes submissions, and flags outliers for a person rather than pretending to verify them. +- Subtitles carried inside the video file: discGetSubtitleTracks(), + discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads + one track out of the disc's own container and loads it exactly as + srtLoad loads a .srt beside the game. SubRip, WebVTT, ASS and MOV + text are read, with ASS override tags stripped; picture subtitles + (VobSub, PGS) are counted so the numbering matches other players, but + cannot be read, because there are no words in them. + + +New Features +------------ + +- 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 ActionMax games are Forge descriptions written by + util/forgePortActionMax.py, and play the same as the emulator under + the same inputs (testScripts/ports/). On the way: a lightSensor + behaviour, a pointer behaviour, fonts and anchors on the text look, + sprites drawn in copies, discPlay and discPause, pointerX and pointerY + in expressions, an event belonging to the room it happened in, and a + built game finding files beside its description. A game can bring a + vocabulary of its own: a description names a file of Lua + (vocabulary = "Vocabulary.singe") that adds looks, behaviours, + conditions, actions, layers, or events to the manifest for that game + alone; the editor loads it when the game opens and forgets it when + the game closes, the compiler checks against it, and the built game + carries it into a release. The ActionMax light sensor lives in one + now, beside those five games, and not in Forge. The Hypseus library's + eleven Karis Framework 3.32b titles (Arcade Xperience 1 to 3, Astroboy, + Danmachi, Freddy, Starship Troopers, Sugar Rush, Survival, Esh's + Aurunmilla, and Badlands Lite) are ported by the same converter, which + finds the framework where a game keeps it and notes its version; the + rest of that library is surveyed in FORGE.md 14.9. A branch on the + branching track can want its move mashed (mash = 6) or held (hold = + 20). The seven Kimmy Script Engine titles (Brain Dead 13, Future Boy + Conan, Mazinga Z, Fuma Conspiracy, Sonic 1996, Sintel, Ninja Hayate + 1080) are ported by the same converter and a kimmy behaviour that + plays that engine's loop, and play as their originals do (FORGE.md + 14.11). + util/forgePorts.py + assembles the ported games into a project folder of their own + (~/claude/singePorts), one game to a folder with its description, the + compiled port, the runtime, its files, and a games.dat. A description is a + .forge file now (it was .game, which is the packed release's, and the + chooser had to sniff which was which). The seventeen KarisFramework + games are ported too: util/forgePortKaris.lua reads a game's own + script, and the qte behaviour plays the framework's loop over the + framework's tables, running the game's script and add-ons in a shim; + every one plays as its original under the same driver. Flat + bodies are the size their looks are drawn at (they were half), and a + platformer stands on its point. + +- A game can be described rather than written. Forge/Author.singe and + Forge/AuthorCompile.singe take a table of layers, entities, behaviours + and rules and compile it into an ordinary Singe game: the rules become + real Lua, so nothing walks a table every frame and the result can be + read and edited by hand. There is no notion of genre in it -- a game + declares which of the engine's layers it uses, and that is the whole + difference between a platformer and a quick-time event over video. The + vocabulary of conditions and actions is declared in a table rather than + built into the compiler, so new kinds of game are entries rather than + releases, and the "lua" action is the deliberate way out when a rule + needs something the vocabulary cannot say. See the manual. + +- Forge runs as a game rather than only as a library: started from the + menu it opens on a chooser (its own descriptions, a new game from a + starter, a copy of anything dropped into its directory), the keys, the + mouse and the pad reach the editor, P saves, builds and plays what is on + screen and comes back to it, and ESC closes or leaves. Entities can be + added, duplicated, deleted and typed field by field -- position, look, + behaviours and their parameters, all from the manifest -- and renaming + one renames it in every rule. Conditions and actions are picked from + the vocabulary with their help beside them, rules reorder, a rule's + note is editable, and U undoes forty steps. The compiler loads the + runtime from Forge/, where it lives, so it no longer fails looking for + a Singe/Author.singe that never shipped. + +- An editor for those descriptions, Forge, which is itself + a Singe game: the canvas is the same overlay at the same coordinates the + game will be played in, so what is placed is what is seen. Entity list + and details are an RmlUi document, the canvas beside them is drawn into + the overlay and picked the way a light gun game picks a target, and the + two compose because a button is offered to the GUI first while pointer + motion is never consumed. A description survives a round trip through + it: load, save, load again, and it compiles to the same game. Forge is + distributed on its own and no part of it ships inside Singe -- not the + editor, not the compiler, not the runtime. A game Forge builds carries + its own copy of that runtime, so it runs on a machine that has never had + Forge on it and cannot change behaviour because the engine moved on. + +- A game released from Forge is standalone. forgeExport writes the + compiled script, a games.dat, the description it came from and a copy of + the runtime into a directory of its own, taken out of Forge. --pack turns + that directory into a .game. The game locates its own directory with + debug.getinfo rather than trusting DIR, which names the directory of the + script the engine was launched with -- not this one when a game is reached + by dofile. + +- The editor edits rules, not only entity positions: Tab swaps the panel + between the entities and the event sheet, the selected rule opens in + place with its conditions and actions under it, and conditions and + actions are added from the same manifest the compiler reads, so the rule + editor needs no change when the vocabulary grows. It is driven by keys + as well as the pointer, which is how the bundled menu has always worked + and what a cabinet needs. ENTER types a value into the selected + condition or action and moves to its next one, ESC puts it back; a + number typed in comes back a number. + +- The menu has an audio delay calibration screen for the machine's own + delay (DAC, receiver, Bluetooth, display). Press the service key on + the game list; a click plays once a second and the screen flashes. + Adjust with the stick until the flash and the click coincide, then + press button 1 to save. The value is kept in machine.cfg in the data + root and applied to every game on the machine, from the menu or the + command line, on top of any per-game AUDIO_DELAY. Scripts can read and + set it with singeGetAudioCalibration() / singeSetAudioCalibration(), + read the measured device queue with singeGetAudioLatency(), and now + have singeGetTicks() for a wall clock in milliseconds (os.clock() is + processor time and drifts). + - One menu, two renderers. Singe/MenuClassic.singe is gone: it was the whole menu written a second time, and the two copies duplicated their game list, their selection, their menu.dat handling and their games.dat @@ -1159,47 +1184,6 @@ API Changes layout globals, so a service screen can walk a pad without knowing the numbers. -- Subtitles carried inside the video file: discGetSubtitleTracks(), - discGetSubtitleLanguage(track) and srtLoadTrack(track), which reads - one track out of the disc's own container and loads it exactly as - srtLoad loads a .srt beside the game. SubRip, WebVTT, ASS and MOV - text are read, with ASS override tags stripped; picture subtitles - (VobSub, PGS) are counted so the numbering matches other players, but - cannot be read, because there are no words in them. - - - -Fixes ------ - -- The menu's grid, horizon and sun were drawn black and unlit through the - intro, which kept the lights off them but not the blast's glow behind - them: a black line across a flash is a line, and the whole grid showed - through the explosion as its outline. They are not drawn at all until - the grid comes up out of the dark. The recording of the backdrop is - re-made with it. - -- A PNG with 16-bit samples and no alpha channel (crate.png in the test - scripts is one) drew with coloured fringes: SDL_image put libpng's - big-endian 48-bit pixels into a surface the machine reads - little-endian, swapping only the 64-bit kind, so every sample's low - byte was taken for its colour. The vendored loader swaps both now. - -- musicPlay answers the handle it was given, as Hypseus's does. Its - games keep that (sndHandle = musicPlay(music)) to stop the piece, and - three of them (Freddy, Starship Troopers, Sugar Rush) died on the map - screen with "musicStop: Argument 1 must be a number" because Singe's - answered nothing. musicStop takes a nil handle as none. - -- A machine with no enumerable mouse keeps its pointer. ManyMouse reads - /dev/input/event* directly where there is no X server, and those nodes - are root:input, so an account that was never added to the "input" group - enumerated nothing -- and Singe then switched off all mouse input, - including SDL's own pointer, which single-mouse mode is the only user - of. On a KMSDRM cabinet or handheld that left the mouse and the light - gun dead with only "No mice detected" to explain it. Only --manymouse - needs those devices now, and the message names the group. - - The menu's intro is drawn, not played, and runs straight into the menu. A charge goes off, the Singe dragon and name fly out of it toward the camera with the dragon's wings beating, the tagline fades in, the dragon @@ -1235,6 +1219,24 @@ Fixes the overlay it is drawn on, so a larger recording gets a larger tagline rather than the menu's scaled up. +- The Singe logo is on the cover of all three books. + +- A third book: "Learn to Program with Singe", docs/Learn.adoc, built by + ./build-docs.sh beside the manual and the Forge book. Thirty lessons that + take somebody who has never written a line of code from one word on a black + screen to a finished, packaged, cabinet-ready game. Part one teaches + programming with no artwork and no video at all; part two adds sprites, + sound, collision, saving, and a game folder; part three covers video, + including the threaded model, branching, light guns, and quick-time events; + part four covers fonts, the GUI, subtitles, 3D, models, physics, particles, + navigation, MIDI, the online service, packing, and cabinets. Every lesson + ships its finished script in docs/learn, and every one of those scripts was + run in the engine rather than only read. The pictures are screenshots of + those same scripts. The art the lessons need is drawn and synthesised by + util/learnKit.py into docs/learn/art; everything else they use is already + unpacked into the Singe folder on a first run, so a reader downloads + nothing. + - The service tools are dressed like the rest of the menu. Each tool was a sheet of white text on black; it is now a card over the moving backdrop in the game page's own colours -- the panel gradient, the @@ -1292,6 +1294,120 @@ Fixes looks identical -- no part moves by so much as a thousandth -- but it can now be animated without rigging it again in script each time. +- The manual's 3D Scenes, Physics and Particles chapters are rewritten as + sections in a learning order with worked examples, every reference + entry now shows each form the call accepts with a realistic example, + and prose spelling is American throughout. Manual.pdf and Manual.html + are rendered into .builddir/ by the build and by build-docs.sh alike, + and the binary embeds the PDF from there. + +- Forge's description format is new (FORGE.md section 4): kinds with + instances, rooms that remember themselves, rules triggered by events + (collisions between kinds, hits, deaths, timers, disc frames) as well as + every frame, vars and states on every instance, expressions where a + number goes, any-of groups, timers, sound, a HUD document bound to vars, + actions that take time run as coroutines, and hitbox tracks keyed to + disc frames or time. A shoot-em-up and a light gun game over video + prove it (testScripts/author, scenes 58 and 59). The editor gained + kinds, rooms, event pickers, and a timeline that scrubs the disc's video + under the cursor (scene 60). Nothing of the first slice's format loads; + it was never released. + +- Forge builds 3D games (FORGE.md section 5): a scene3d layer, model, + mesh, light, billboard, and text looks, the character controller moving + relative to a camera that is fixed, follows, orbits, sits in first + person, or rides a rail of points with stops, enemies that seek, an + animator playing a clip per state, targets with zones on named bones so + a gun's ray says which part it hit, ragdolls on death, bodies and + triggers. A rail shooter in the manner of House of the Dead and a 3D + platformer prove it (scenes 61 and 62), and the editor shows a 3D room + as the scene itself under an orbiting camera (scene 63). + +- Forge builds adventure games (FORGE.md section 6): rooms that remember + themselves, walk areas baked to a navigation mesh in a painted room or + a modelled one, hotspots, a verb line and a text parser with the most + specific rule winning, an inventory, dialogue trees, cut-scenes across + rooms with fades, depth sorting and scaling, and whole-game saves. The + same rules run a two-room adventure in 2D and in 3D (scenes 64 and 65). + +- Forge builds racing games and tower defences (FORGE.md section 7): + vehicles on the engine's vehicle physics with an opponent that drives a + track on its own, walkers that follow a target or patrol a lane, turrets + that fire at what is in range, instances placed by a click on the floor, + thrust, and hinge, ball, and slider joints (scenes 66 and 67). + +- Forge builds branching video games and carries the arcade plumbing + (FORGE.md section 8): windows on the disc that want a move, with the + disc sent to a success or a failure clip, the score bezel mirroring the + game's vars, coins, continues, a results card with the best score kept, + and scores sent to the master service's board (scene 68). + +- Forge's editor gained a search in every picker, a file picker for any + field that names a file, polygon drawing for walk areas and hotspot + outlines, a dialogue outline panel, and play-from-this-room (FORGE.md + section 9, scene 69). + +- Forge's long tail (FORGE.md section 10): sprite sheets stepped by + state, tile maps from a sheet, MIDI notes as events, water that bodies + float in, boats under their propellers, and soft bodies (scenes 70 and + 71). Every kind of game in FORGE.md's catalogue now has its vocabulary. + +- Forge's sprites turn (a var, a spin behaviour, or a projectile aiming + along its flight), a particles look streams from an instance, the + checker names misspelt fields, and 2D particle bursts are drawn at all + (they never had been). + +- Forge builds painted rooms in 3D with occluders, and its 3D viewport + gained axis gizmos with a turn handle; entities carry a rotation, and + a sprite faces left through a frame range of its own (FORGE.md section + 12, scenes 70, 79, and 80). + +- Forge's catalogue is written out (FORGE.md section 11): a first-person + shooter, breakout, a maze, third-person action, a space shooter, a + rhythm game, and bowling join the samples (scenes 72 to 78), and what + they found is fixed: a gun's ray passes through what is not a target, + events raised while the first room is built reach the rules, and a + rule on a key or a room can run once per instance of a kind. + +- Forge has its own manual, docs/Forge.adoc, built to Forge.html and + Forge.pdf beside the Singe manual; the engine's manual keeps a pointer. + The build packs Forge.game itself, with the manual and four samples + that need no art inside, and Forge's first run copies Forge.pdf out to + its data directory and says where. + R redoes what U undid, forty steps deep. + + +Fixes +----- + +- The menu's grid, horizon and sun were drawn black and unlit through the + intro, which kept the lights off them but not the blast's glow behind + them: a black line across a flash is a line, and the whole grid showed + through the explosion as its outline. They are not drawn at all until + the grid comes up out of the dark. The recording of the backdrop is + re-made with it. + +- A PNG with 16-bit samples and no alpha channel (crate.png in the test + scripts is one) drew with coloured fringes: SDL_image put libpng's + big-endian 48-bit pixels into a surface the machine reads + little-endian, swapping only the 64-bit kind, so every sample's low + byte was taken for its colour. The vendored loader swaps both now. + +- musicPlay answers the handle it was given, as Hypseus's does. Its + games keep that (sndHandle = musicPlay(music)) to stop the piece, and + three of them (Freddy, Starship Troopers, Sugar Rush) died on the map + screen with "musicStop: Argument 1 must be a number" because Singe's + answered nothing. musicStop takes a nil handle as none. + +- A machine with no enumerable mouse keeps its pointer. ManyMouse reads + /dev/input/event* directly where there is no X server, and those nodes + are root:input, so an account that was never added to the "input" group + enumerated nothing -- and Singe then switched off all mouse input, + including SDL's own pointer, which single-mouse mode is the only user + of. On a KMSDRM cabinet or handheld that left the mouse and the light + gun dead with only "No mice detected" to explain it. Only --manymouse + needs those devices now, and the message names the group. + - A game whose art is missing shows that it is missing, rather than taking the engine down with it. An empty or wrong decorator path could open something that is not a file, report a nonsense length, and throw @@ -1344,13 +1460,6 @@ Fixes and tablets reporting absolute positions through ManyMouse are mapped into the picture like the mouse. -- The manual's 3D Scenes, Physics and Particles chapters are rewritten as - sections in a learning order with worked examples, every reference - entry now shows each form the call accepts with a realistic example, - and prose spelling is American throughout. Manual.pdf and Manual.html - are rendered into .builddir/ by the build and by build-docs.sh alike, - and the binary embeds the PDF from there. - - controllerGetButton() never worked; it rejected every framework button code. @@ -1536,6 +1645,7 @@ Fixes 64-bit Raspberry Pi OS (bullseye or newer) binary and a macOS 13 or newer universal (Apple silicon and Intel) binary. The toolchains repository is no longer needed. See INSTALL. + - On a plain video disc, discSearch, discSkipToFrame, discSkipForward, discSkipBackward, discStepForward and discStepBackward clamp at the first and last frame instead of wrapping around the disc. @@ -1704,81 +1814,6 @@ Fixes or scriptExecute is reported like any other bad API argument -- the script line and the call named -- rather than as a bare exit message. -- 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. - SINGE 2.10 diff --git a/build-docs.sh b/build-docs.sh index 6b29147ec..f8d560561 100755 --- a/build-docs.sh +++ b/build-docs.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash -# Build the Singe manual from docs/Manual.adoc and the Forge manual from docs/Forge.adoc. -# Produces .builddir/Manual.html, Manual.pdf, Forge.html and Forge.pdf. The version comes from -# CMakeLists.txt. +# Build the Singe manual from docs/Manual.adoc, the Forge manual from docs/Forge.adoc, and the +# beginner's course from docs/Learn.adoc. Produces .builddir/Manual.html, Manual.pdf, Forge.html, +# Forge.pdf, Learn.html and Learn.pdf. The version comes from CMakeLists.txt. set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) src=$here/docs/Manual.adoc forge=$here/docs/Forge.adoc +learn=$here/docs/Learn.adoc -for doc in "$src" "$forge"; do +for doc in "$src" "$forge" "$learn"; do if [[ ! -f $doc ]]; then echo "error: $doc not found" >&2 exit 1 @@ -31,6 +32,8 @@ asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html" asciidoctor-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf" asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html" asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf" +asciidoctor -a revnumber="$version" "$learn" -o "$out/Learn.html" +asciidoctor-pdf -a revnumber="$version" "$learn" -o "$out/Learn.pdf" echo "built:" -ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Forge.html" "$out/Forge.pdf" +ls -la "$out/Manual.html" "$out/Manual.pdf" "$out/Forge.html" "$out/Forge.pdf" "$out/Learn.html" "$out/Learn.pdf" diff --git a/docs/Forge.adoc b/docs/Forge.adoc index 4d9ba1336..1a14c3133 100644 --- a/docs/Forge.adoc +++ b/docs/Forge.adoc @@ -7,6 +7,7 @@ Scott Duensing :toclevels: 3 :sectnums: :sectnumlevels: 3 +:title-logo-image: image:singeLogo.png[Singe, pdfwidth=2.6in, align=center] :source-highlighter: rouge :icons: font :experimental: @@ -35,7 +36,7 @@ the result against the description the tutorial ends with, which ships in thread can open the finished game and compare. The pictures come from those scenes too. -=== Tutorial 1: Ten minutes +=== Tutorial 1: Ten Minutes image::tutorials/01-playing.png[The starter game, playing, 600] @@ -131,7 +132,7 @@ in the chooser is this file. include::../assets/Forge/tutorials/01-tenMinutes.forge[] ---- -=== Tutorial 2: A platformer +=== Tutorial 2: A Platformer image::tutorials/02-playing.png[Coins, spikes, and a door, 600] @@ -232,7 +233,7 @@ is the three types, their entities, the three rules, and the second room. include::../assets/Forge/tutorials/02-platformer.forge[] ---- -=== Tutorial 3: Sprites and sound +=== Tutorial 3: Sprites and Sound image::tutorials/03-playing.png[The hero from the sheet, 600] @@ -312,7 +313,7 @@ states are in <<_instances_vars_and_states>> and include::../assets/Forge/tutorials/03-sprites.forge[] ---- -=== Tutorial 4: A shoot-em-up +=== Tutorial 4: A Shoot-Em-Up image::tutorials/04-playing.png[Rocks, 600] @@ -413,7 +414,7 @@ runs once per instance of a type, with that instance as `self`, and a include::../assets/Forge/tutorials/04-shmup.forge[] ---- -=== Tutorial 5: A quick-time event +=== Tutorial 5: A Quick-Time Event image::tutorials/05-playing.png[A window answered, 600] @@ -494,7 +495,7 @@ released game, `games.dat` names the video, as it does for any Singe game. include::../assets/Forge/tutorials/05-qte.forge[] ---- -=== Tutorial 6: A light gun game +=== Tutorial 6: A Light Gun Game image::tutorials/06-playing.png[Draw, 600] @@ -580,7 +581,7 @@ with a light gun needs -- calibration, two guns, the recoil -- is in include::../assets/Forge/tutorials/06-gun.forge[] ---- -=== Tutorial 7: A point-and-click adventure +=== Tutorial 7: A Point-and-Click Adventure image::tutorials/07-playing.png[The yard, 600] @@ -802,7 +803,7 @@ viewport in <<_the_3d_viewport>>. include::../assets/Forge/tutorials/08-scene.forge[] ---- -=== Tutorial 9: A rail shooter +=== Tutorial 9: A Rail Shooter image::tutorials/09-playing.png[The dead yard, 600] @@ -921,7 +922,7 @@ in <<_games_in_the_scene>>; painted rooms in include::../assets/Forge/tutorials/09-rail.forge[] ---- -=== Tutorial 10: Releasing your game +=== Tutorial 10: Releasing Your Game image::tutorials/10-playing.png[Coin Run, 600] @@ -1085,7 +1086,7 @@ 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 +=== 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; @@ -1152,7 +1153,7 @@ 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 +=== 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 }`), @@ -1255,7 +1256,7 @@ which is what House of the Dead does at every doorway. { at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" } } } ---- -=== Games in the scene +=== 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 @@ -1280,7 +1281,7 @@ 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 +=== 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 @@ -1441,7 +1442,7 @@ 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 +=== 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, @@ -1472,7 +1473,7 @@ 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 +=== Arcade Plumbing: Branching Video, Lives, Credits, the Board A `branches` track is Dragon's Lair: each branch is a window of disc frames, the `move` (a key) or `switch` it wants, and the frames the disc goes to on @@ -1492,7 +1493,7 @@ 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 +=== 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 @@ -1532,7 +1533,7 @@ 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 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 @@ -1562,7 +1563,7 @@ empty, is reported by the checker as *empty* and compiles to `nil`. include::ForgeVocabulary.adoc[] [[own-vocabulary]] -=== Your own vocabulary +=== Your Own Vocabulary A game can bring words of its own. A description may name a file of Lua -- @@ -1674,7 +1675,7 @@ 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 +=== 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 @@ -1811,7 +1812,7 @@ 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 +=== 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 @@ -1861,7 +1862,7 @@ 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 +=== 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 diff --git a/docs/Learn.adoc b/docs/Learn.adoc new file mode 100644 index 000000000..680e03dd0 --- /dev/null +++ b/docs/Learn.adoc @@ -0,0 +1,230 @@ += Learn to Program with Singe +Scott Duensing +:revnumber: 3.00 +:revdate: 2026 +:doctype: book +:toc: left +:toclevels: 3 +:title-logo-image: image:singeLogo.png[Singe, pdfwidth=2.6in, align=center] +:source-highlighter: rouge +:icons: font +:experimental: +:imagesdir: images + +[preface] +== Before You Begin + +This book teaches you to program. It assumes you have never written a line of +code in your life, and it does not assume you want to become a programmer -- +only that you want to make a game. + +Every lesson ends with something you can run and play with. Nothing is saved +for later: there are no chapters of theory before the first picture appears on +screen. You will write your first working program in about ten minutes, and it +will be five lines long. + +Singe is a game engine. You can make anything with it that you can make with +any other engine, in two dimensions or three, and most of this book is about +doing exactly that. What sets it apart is that it can also play full motion +video as the world the game happens in, which is how the laserdisc games of +the arcades worked and which almost nothing else does well. That is a +capability, not an obligation: a game only plays video if it asks to, and +nothing you write for the first thirteen lessons will go near it. + +The language is Lua, which Singe uses for everything. Lua was chosen by people +who had to teach it to artists and designers, and it shows. There are no +semicolons to forget, no types to declare, and no compiler to fight. When you +make a mistake, Singe tells you the file, the line, and what it did not +understand. + +=== What You Need + +* *Singe.* One file. Put it in a folder somewhere you can find again. +* *A text editor.* Notepad, TextEdit in plain text mode, Notepad++, VS Code, + or anything else that saves plain text. A word processor will not do: it + saves formatting you cannot see, and Singe cannot read it. +* *Nothing else.* You do not need a laserdisc, a video file, a drawing + program, or any artwork. The whole of part one draws everything itself, and + from part two onward the pictures, the sounds, the video, and the models you + need all come with the book or with the engine. What you need is listed + below. + +=== How to Read This Book + +Type the code in. Do not copy and paste it. Typing it is slower, and that is +the point: you will make small mistakes, and fixing them is how you learn to +read what the computer tells you. Every lesson has a *Try it* section with +changes to make, and those matter as much as the lesson. + +If a lesson goes wrong and you cannot see why, the finished script for every +lesson is in the `learn` folder that comes with this book, named for its +lesson: `01-hello.singe`, and so on. Read it only after you have tried. + +=== How Your Folders Are Laid Out + +Lesson one sets this up and every later lesson assumes it, so it is worth +seeing once now. + +You keep one *work folder* with your copy of Singe in it. Each lesson makes a +new folder inside that, named after the script inside it. + +[literal] +---- +singe/ <- your work folder, with Singe in it + Singe/ <- the engine puts its own files here + data/ <- and anything your games save + hello/ + hello.singe + rocks/ + rocks.singe + art/ +---- + +You run a lesson from the work folder by naming its folder, so `Singe rocks` +runs `rocks/rocks.singe`. That short form works because the folder and the +script share a name. This is the same shape a real installed game has, which +is why the book uses it from the first lesson rather than tidying up later. + +=== What Comes with This Book + +From part two onward the lessons use pictures and sounds. They are in the +`art` folder that comes with the book, and you copy that folder into whichever +lesson folder you are working in, keeping the name. There is a ship, a rock, a +shot, a star, four frames of somebody walking, and two noises. +`art/README.txt` lists them with their sizes. + +Singe itself provides the rest. The first time you run anything, the engine +unpacks a folder called `Singe` into your work folder, and in it are a video, a +font, two 3D models, and a click. The lessons that need those use the ones +already on your disk, so there is nothing to find and nothing to buy. None of +it is pretty. All of it is real, and swapping in something better is usually +one changed file name. + +=== This Is Not the Forge Book + +Singe has a second book, _Forge_, which teaches you to make a game by +describing it instead of programming it. That is a different road to a +different place, for someone who does not want to write code at all. If you +came here to learn to program, you are in the right place. You do not need to +read the Forge book, and this one does not build on it. + +The _Singe Manual_ is the reference: every function, every argument, every +return value. It is not a tutorial and does not try to be one. You will start +reaching for it around lesson six, and by the end of this book you will use it +more than you use this book. + +== The Series + +Thirty lessons in four parts. Each one needs the one before it and nothing +else. + +*Part One: The Ideas.* No artwork, no video, nothing to install. You draw with +the shapes and the text the engine already has, and you learn what a program +is made of. + +. *Hello* -- a script, a function, a string, and something on screen. +. *Numbers That Change* -- variables, arithmetic, and making it move. +. *Making Decisions* -- `if`, comparison, and reading the controls. +. *Doing It Again* -- `for` and `while`, and drawing a hundred of something. +. *Your Own Functions* -- arguments, return values, and why you would bother. +. *Lists of Things* -- tables, `ipairs`, adding and removing. +. *A Game* -- everything so far, playable, in about eighty lines. +. *When It Goes Wrong* -- reading errors, `debugPrint`, and reloading with F5. + +*Part Two: A Real Game.* Artwork, sound, and the shape a finished game takes. + +[start=9] +. *Pictures* -- sprites, loading, drawing, and frames. +. *Sound* -- effects, music, and when to load which. +. *Hitting Things* -- collision, the game kit, and hitboxes. +. *Score, Lives, and Game Over* -- state, saving, and starting again. +. *A Game Folder* -- `games.dat`, artwork for the menu, and running from it. + +*Part Three: Video.* Playing film as the world of the game, and the arcade +conventions that grew up around it. Skip this part if your game does not want +video; nothing after it depends on this part. + +[start=14] +. *Playing Video* -- the disc, frames, seeking, and waiting. +. *The Other Way to Write It* -- the threaded model, and why it suits video. +. *Branching* -- choices, paths, and the shape of a branching video game. +. *Light Guns* -- aiming, firing, and the calibration a cabinet needs. +. *Quick-Time Events* -- prompts, windows, and scoring the player's timing. + +*Part Four: Everything Else.* Each lesson stands alone; take them in any +order. + +[start=19] +. *Text That Looks Good* -- fonts, quality, and measuring. +. *Menus and Screens* -- the GUI, documents, and styling. +. *Subtitles* -- and why they are their own system. +. *Into 3D* -- scenes, cameras, and coordinates. +. *Models and Light* -- glTF, materials, and animation. +. *Physics* -- bodies, forces, joints, and when not to use them. +. *Particles* -- emitters, and making an explosion. +. *Characters That Move Themselves* -- navigation and pathfinding. +. *Music and MIDI* -- tracks, soundfonts, and instruments. +. *Online* -- accounts, high score tables, and the catalogue. +. *Shipping It* -- packing, patching, licences, and the release. +. *In a Cabinet* -- controls, service menus, bezels, and the things that only + matter on real hardware. + +include::lessons/01-hello.adoc[] + +include::lessons/02-numbers.adoc[] + +include::lessons/03-decisions.adoc[] + +include::lessons/04-repeating.adoc[] + +include::lessons/05-functions.adoc[] + +include::lessons/06-tables.adoc[] + +include::lessons/07-a-game.adoc[] + +include::lessons/08-when-it-goes-wrong.adoc[] + +include::lessons/09-pictures.adoc[] + +include::lessons/10-sound.adoc[] + +include::lessons/11-hitting-things.adoc[] + +include::lessons/12-score.adoc[] + +include::lessons/13-game-folder.adoc[] + +include::lessons/14-video.adoc[] + +include::lessons/15-threaded.adoc[] + +include::lessons/16-branching.adoc[] + +include::lessons/17-light-guns.adoc[] + +include::lessons/18-qte.adoc[] + +include::lessons/19-text.adoc[] + +include::lessons/20-gui.adoc[] + +include::lessons/21-subtitles.adoc[] + +include::lessons/22-3d.adoc[] + +include::lessons/23-models.adoc[] + +include::lessons/24-physics.adoc[] + +include::lessons/25-particles.adoc[] + +include::lessons/26-navigation.adoc[] + +include::lessons/27-midi.adoc[] + +include::lessons/28-online.adoc[] + +include::lessons/29-shipping.adoc[] + +include::lessons/30-cabinet.adoc[] diff --git a/docs/Manual.adoc b/docs/Manual.adoc index a317260b0..fabebe39f 100644 --- a/docs/Manual.adoc +++ b/docs/Manual.adoc @@ -7,6 +7,7 @@ Scott Duensing :toclevels: 3 :sectnums: :sectnumlevels: 3 +:title-logo-image: image:images/singeLogo.png[Singe, pdfwidth=2.6in, align=center] :source-highlighter: rouge :icons: font :experimental: @@ -101,7 +102,7 @@ is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see <>). [#menurenderers] -==== The two renderers +==== The Two Renderers A GUI document is drawn through the GPU device the 3D scene uses, so on a machine without one there is no document to build. The menu handles that @@ -130,7 +131,7 @@ writes the line back out, so it survives. That is how the overlay path is tested, and how a cabinet builder who prefers the old look gets it. [#onlineservice] -==== Online: accounts, games and scores +==== Online: Accounts, Games, and Scores The bundled menu can sign the cabinet in to a master service (by default `master.singeengine.com`), which does three things: it holds the account, it @@ -362,7 +363,7 @@ directly: a per cent is `32767 / 100` raw units, so Singe's `DEAD_ZONE` of `controls.cfg`; `DEAD_ZONES` wins over both, being the most specific. [[joymouse]] -==== Driving the Mouse With a Stick +==== Driving the Mouse with a Stick A light gun game on a cabinet with a stick and no mouse is unplayable unless the stick can move the cursor. `--joymouse` makes it do so: the **left stick of @@ -465,7 +466,7 @@ One option, `--deterministic`, takes a value or leaves it out, and its key does the same: `deterministic = true` uses the default step, `deterministic = 20` names one, and `deterministic = false` leaves the option off. -==== What wins +==== What Wins . The built in default. . The settings file. @@ -687,7 +688,7 @@ The artwork is drawn over the whole window, whatever shape either of them is, so it stretches with the window exactly as the picture does. Give it the aspect ratio you expect to play at. -==== The cutout +==== The Cutout Beside the image, and named after it, a sidecar file says where the picture goes: `bezels/cabinet.png` is described by `bezels/cabinet.cfg`. It is a Lua @@ -716,7 +717,7 @@ behind the picture by default, so an opaque middle would simply be covered, but `--bezelflip` puts the artwork in front and then only its transparency lets the game through. -==== Draw order +==== Draw Order Without `--bezelflip` the artwork is the first thing in the frame: the picture, the overlay, the GUIs, the 3D scene and the particles all draw over it. With @@ -736,7 +737,7 @@ game written for a particular bezel can recognise it. Screen space GUIs and the score panel always draw above the artwork, since the point of them is to sit on it. -==== The Sinden border +==== The Sinden Border A Sinden light gun watches the screen for a bright border. With bezel artwork loaded that border belongs at the *edge of the window*, not around the picture, @@ -751,7 +752,7 @@ direction: a gun cabinet whose artwork is only a thin frame may still want the border around the picture, and a cabinet with no artwork at all may want it at the screen's edge. -==== The score panel +==== The Score Panel Sixteen of the Hypseus Singe games can drive an arcade scoreboard: credits, and a score and a life count for each of two players. In Singe that scoreboard is @@ -967,7 +968,7 @@ repeatable but not the same as each other, which is how a test asks for a different but equally reproducible run. It may be set in a settings file as `deterministic` like any other option. -==== Reference screenshots +==== Reference Screenshots The scenes in `testScripts` each take a screenshot or two at a fixed frame number and quit by themselves, so in this mode they make a set of reference pictures a @@ -1813,7 +1814,7 @@ nodeSetMesh(monitor, meshPlane(3.0, 2.0), screen) nodeSetRotation(monitor, 90, 0, 0) ---- -==== Reloading, Performance and Limits +==== Reloading, Performance, and Limits A GUI belongs to the script that made it. Quitting, `scriptPush`, `scriptExecute` and a reload (see <>) @@ -1953,7 +1954,7 @@ if srtLoad("subs/english.srt") then end ---- -==== The file format +==== The File Format SubRip (`.srt`) is a plain text file of numbered cues: @@ -1976,7 +1977,7 @@ What is *not* supported: the positioning coordinates some tools append to the ti `srtLoad` answers `false` and loads nothing when the file cannot be read, when nothing in it parses, or when there is no disc -- so a broken file is a `false` a game can act on and not a crash. It prints a warning naming the file in each case. -==== The timing +==== The Timing Timestamps are turned into *disc frame numbers* as the file is loaded, using the frame rate of the disc that is loaded at the time, and it is the frame number the engine matches every drawn frame. Two things follow from that. @@ -3149,7 +3150,7 @@ color and alpha at birth and at death, and the particle fades between them over its life; `emitterSetSize` does the same for its size, with a third value for how fast it spins. -==== Trails, Collisions, Lit and Soft Particles +==== Trails, Collisions, Lit, and Soft Particles `emitterSetTrail(emitter, length, width)` keeps the last few positions of every particle and draws a ribbon through them, fading toward the tail: @@ -3362,7 +3363,7 @@ late; the video is held back to match. A negative value handles the rare case of audio arriving early. [#menucalibration] -==== Calibrating from the menu +==== Calibrating from the Menu The bundled menu has a calibration screen for that downstream delay. Press the key mapped to `INPUT_SERVICE` (the `9` key by default) on the game list and @@ -11948,6 +11949,54 @@ function onOverlayUpdate() end ---- +[#overlaygetfontheight] +==== overlayGetFontHeight + +[source,text] +---- +result = overlayGetFontHeight() +---- + +Returns the height in pixels of one character cell of the built-in console font, which is what `overlayPrint` counts in. The console font is a single embedded image of 256 glyphs loaded once before any script runs, so this never changes while the program is running. Any arguments are ignored. + +*Returns:* integer height of one cell in pixels. + +*Since:* 3.00 +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- How many rows of console text fit, whatever the overlay size is. +ROWS = overlayGetHeight() // overlayGetFontHeight() +overlayPrint(0, ROWS - 1, "along the bottom") +---- + +[#overlaygetfontwidth] +==== overlayGetFontWidth + +[source,text] +---- +result = overlayGetFontWidth() +---- + +Returns the width in pixels of one character cell of the built-in console font, which is what `overlayPrint` counts in. Use it with `overlayGetWidth` to work out how many columns fit, or to place console text against something drawn in pixels. Divide with `//` rather than `/`: Lua's ordinary division gives a fractional number, and a cell position must be whole. Any arguments are ignored. + +*Returns:* integer width of one cell in pixels. + +*Since:* 3.00 +*See also:* <>, <>, <> + +.Example +[source,lua] +---- +-- Center a line of console text without hard-coding the number of columns. +local columns = overlayGetWidth() // overlayGetFontWidth() +local text = "GAME OVER" + +overlayPrint((columns - #text) // 2, 10, text) +---- + [#overlaygetheight] ==== overlayGetHeight @@ -12073,7 +12122,7 @@ Prints a string with the engine's built-in fixed-width console font. Unlike ever * `column`, `row` -- character cell, `0` at the top left. * `text` -- the string to print. -*Notes:* The console font is meant for diagnostics and service screens. Use `fontPrint` or `fontToSprite` for colored or proportional text. +*Notes:* The console font is meant for diagnostics and service screens. Use `fontPrint` or `fontToSprite` for colored or proportional text. The size of a cell in pixels is `overlayGetFontWidth` by `overlayGetFontHeight`, so `overlayGetWidth() // overlayGetFontWidth()` is how many columns fit. *Since:* 1.x *See also:* <>, <>, <> @@ -16596,7 +16645,7 @@ srtPosition(70) ---- [#onlinescores] -=== Online high scores +=== Online High Scores A game posts a score in one line: diff --git a/docs/images/learn/01-hello.png b/docs/images/learn/01-hello.png new file mode 100644 index 000000000..76c0e3e5b --- /dev/null +++ b/docs/images/learn/01-hello.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ea1a6c5e949950a7ee6feed645b35ed188f0f5faa1bd18d939d5a8b862eb225 +size 1302 diff --git a/docs/images/learn/02-numbers.png b/docs/images/learn/02-numbers.png new file mode 100644 index 000000000..3599f0b6c --- /dev/null +++ b/docs/images/learn/02-numbers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b42713ca3b1a404b3f2997fff56dcf1d7d5e8e6326c525f7c4f8b3dba660d34c +size 2523 diff --git a/docs/images/learn/03-decisions.png b/docs/images/learn/03-decisions.png new file mode 100644 index 000000000..57a7888cc --- /dev/null +++ b/docs/images/learn/03-decisions.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fe0d4c50be7ca22a3bb939c01d8232371a935af4a8db5f53dbcb68b838e0a8a +size 2714 diff --git a/docs/images/learn/04-repeating.png b/docs/images/learn/04-repeating.png new file mode 100644 index 000000000..3e220877d --- /dev/null +++ b/docs/images/learn/04-repeating.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:164d4198c76a2a4efff98ba7b00aa04ed3c46f16a377f176afd508c9113bac65 +size 3968 diff --git a/docs/images/learn/05-functions.png b/docs/images/learn/05-functions.png new file mode 100644 index 000000000..2b90c7f34 --- /dev/null +++ b/docs/images/learn/05-functions.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed473124786e8b5ee32e4409d0a42370fa3eaf9eff0b27a279898f3eacae5390 +size 3481 diff --git a/docs/images/learn/06-tables.png b/docs/images/learn/06-tables.png new file mode 100644 index 000000000..e4fd37c97 --- /dev/null +++ b/docs/images/learn/06-tables.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d95576ca672e1f769a10d48e047869bd5af167560a5707b03c25de3f778220e +size 10511 diff --git a/docs/images/learn/07-a-game.png b/docs/images/learn/07-a-game.png new file mode 100644 index 000000000..e8a57eed5 --- /dev/null +++ b/docs/images/learn/07-a-game.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d04e53b5d196314662190e096eebcc371d375a1c8b694ef0912c81db41e6c1f +size 18664 diff --git a/docs/images/learn/08-when-it-goes-wrong.png b/docs/images/learn/08-when-it-goes-wrong.png new file mode 100644 index 000000000..e1fddb30c --- /dev/null +++ b/docs/images/learn/08-when-it-goes-wrong.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60ae8d39c944bd66bd6feb0f126f37f0bcfa71e7c15623c47c26a302cda249b4 +size 23735 diff --git a/docs/images/learn/09-pictures.png b/docs/images/learn/09-pictures.png new file mode 100644 index 000000000..49d7bfe8c --- /dev/null +++ b/docs/images/learn/09-pictures.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a81b197138a95fb34ede9ae93ac3e1366a88418c0397dda68273f2224f8b4308 +size 37271 diff --git a/docs/images/learn/10-sound.png b/docs/images/learn/10-sound.png new file mode 100644 index 000000000..fa68f347b --- /dev/null +++ b/docs/images/learn/10-sound.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f1f1d43ba88d8f1994ef330b6345e796dfbb82dd339c2bb7c96a27263770cf2 +size 35780 diff --git a/docs/images/learn/11-hitting-things.png b/docs/images/learn/11-hitting-things.png new file mode 100644 index 000000000..d1b3ce0ce --- /dev/null +++ b/docs/images/learn/11-hitting-things.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db51b92b4184226221cf8008f43b8650cd2e2ff07719a768dd898e958e71fcad +size 16313 diff --git a/docs/images/learn/12-score.png b/docs/images/learn/12-score.png new file mode 100644 index 000000000..ebd3edac0 --- /dev/null +++ b/docs/images/learn/12-score.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb469d7ebddff0f3534face0eca16b473b262feff7512a557b9f552b9920a366 +size 4545 diff --git a/docs/images/learn/13-game-folder.png b/docs/images/learn/13-game-folder.png new file mode 100644 index 000000000..d3ffa8911 --- /dev/null +++ b/docs/images/learn/13-game-folder.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:271fe80b6e9419251746eedd297f0af933e63ed2b040087f8df666d89570b42f +size 4545 diff --git a/docs/images/learn/14-video.png b/docs/images/learn/14-video.png new file mode 100644 index 000000000..219c7cd80 --- /dev/null +++ b/docs/images/learn/14-video.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eafb0a791f82d24d07696c5cce6d3f8cd50d40d02d19b2f1fb2fe49345dc8b51 +size 135165 diff --git a/docs/images/learn/15-threaded.png b/docs/images/learn/15-threaded.png new file mode 100644 index 000000000..e4d6c3530 --- /dev/null +++ b/docs/images/learn/15-threaded.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90a980ca8a46825191523d03fc3600a8a319dc8fb22a5243e453e545ce8a2424 +size 134731 diff --git a/docs/images/learn/16-branching.png b/docs/images/learn/16-branching.png new file mode 100644 index 000000000..cde17cf34 --- /dev/null +++ b/docs/images/learn/16-branching.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5cba98655c67eb57c1e120212d1267f3e3b069e1b1c35a96d65dccd645fa2d2 +size 149801 diff --git a/docs/images/learn/17-light-guns.png b/docs/images/learn/17-light-guns.png new file mode 100644 index 000000000..753893d1a --- /dev/null +++ b/docs/images/learn/17-light-guns.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87c8c92632e76e8a45a9f19c3891c1e73e59e88eb781e7f2c9b3b3d4c0fe1249 +size 74032 diff --git a/docs/images/learn/18-qte.png b/docs/images/learn/18-qte.png new file mode 100644 index 000000000..4175080b5 --- /dev/null +++ b/docs/images/learn/18-qte.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc2f299e3b43574db54871853b0fa05aa32a21cca109d7a1274f0e7d4479b87c +size 69363 diff --git a/docs/images/learn/19-text.png b/docs/images/learn/19-text.png new file mode 100644 index 000000000..4705c684f --- /dev/null +++ b/docs/images/learn/19-text.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f576378536de806a8a350250d381f85052be6e125d7567ef57e4f8531da93aa +size 182905 diff --git a/docs/images/learn/20-gui.png b/docs/images/learn/20-gui.png new file mode 100644 index 000000000..02d42c577 --- /dev/null +++ b/docs/images/learn/20-gui.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47c6d064f0076addab026f98b33bb350ccb8908ead554d990f7924f82f40b07a +size 156458 diff --git a/docs/images/learn/21-subtitles.png b/docs/images/learn/21-subtitles.png new file mode 100644 index 000000000..f26ba612a --- /dev/null +++ b/docs/images/learn/21-subtitles.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3be0b2eeb333320730dab28b0bfa3121e0b791c886ba9af15ff5a19f8a59895f +size 135047 diff --git a/docs/images/learn/22-3d.png b/docs/images/learn/22-3d.png new file mode 100644 index 000000000..546afa124 --- /dev/null +++ b/docs/images/learn/22-3d.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0c8ed1cbc1a076e51da164ceee34208e4d523b289dcefe2d7916a8b13bd1f9a +size 20301 diff --git a/docs/images/learn/23-models.png b/docs/images/learn/23-models.png new file mode 100644 index 000000000..520abdb6c --- /dev/null +++ b/docs/images/learn/23-models.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:157eff712770e697d9e25b518cfe62e911793334ad16cd24f6b0ca978dcfa850 +size 112068 diff --git a/docs/images/learn/24-physics.png b/docs/images/learn/24-physics.png new file mode 100644 index 000000000..a787165c6 --- /dev/null +++ b/docs/images/learn/24-physics.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6556bceb5fd0c3f00f47e546b804adc99ec479fa1c65207b178595eb07729cd8 +size 58009 diff --git a/docs/images/learn/25-particles.png b/docs/images/learn/25-particles.png new file mode 100644 index 000000000..d3f6a0a3b --- /dev/null +++ b/docs/images/learn/25-particles.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d52022cd17eaecc19b50b63a0913ea902ba3ca1d379b8473eaf3581d2d000e68 +size 18427 diff --git a/docs/images/learn/26-navigation.png b/docs/images/learn/26-navigation.png new file mode 100644 index 000000000..47eee48fa --- /dev/null +++ b/docs/images/learn/26-navigation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b08e478a27ce45afdd3ab3b1761d1abd6bbc925dccf3cfb0c65868a36ba5977 +size 97620 diff --git a/docs/images/learn/27-midi.png b/docs/images/learn/27-midi.png new file mode 100644 index 000000000..3a3ef5636 --- /dev/null +++ b/docs/images/learn/27-midi.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9086c900859f55ab441e60913aed3d81eb3c5925ed7ade221265cf465350fd2a +size 24025 diff --git a/docs/images/learn/28-online.png b/docs/images/learn/28-online.png new file mode 100644 index 000000000..48306e721 --- /dev/null +++ b/docs/images/learn/28-online.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e9850078b11ab36739d3c203fb7811b887df64d888daea6c4b12487bfbafa26 +size 13087 diff --git a/docs/images/learn/29-shipping.png b/docs/images/learn/29-shipping.png new file mode 100644 index 000000000..e07655590 --- /dev/null +++ b/docs/images/learn/29-shipping.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adc9f2020384af19f40bd1d45a9e8998eba3d6b3eb4b4e700b18b413b04550d2 +size 37590 diff --git a/docs/images/learn/30-cabinet.png b/docs/images/learn/30-cabinet.png new file mode 100644 index 000000000..33089a7ed --- /dev/null +++ b/docs/images/learn/30-cabinet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:295c7361f299356789678e2520969f397a0173177768d9719b562ff222c79fa6 +size 162118 diff --git a/docs/images/singeLogo.png b/docs/images/singeLogo.png new file mode 100644 index 000000000..73029f8a7 --- /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/learn/01-hello.singe b/docs/learn/01-hello.singe new file mode 100644 index 000000000..2a1e6203e --- /dev/null +++ b/docs/learn/01-hello.singe @@ -0,0 +1,6 @@ +-- Lesson 1: Hello. The finished script, exactly as the lesson ends. +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Hello!") + return OVERLAY_UPDATED +end diff --git a/docs/learn/02-numbers.singe b/docs/learn/02-numbers.singe new file mode 100644 index 000000000..f22aa1ed8 --- /dev/null +++ b/docs/learn/02-numbers.singe @@ -0,0 +1,47 @@ +-- Lesson 2: Numbers That Change + +CELL_WIDTH = overlayGetFontWidth() +CELL_HEIGHT = overlayGetFontHeight() +TOP_ROW = 2 + +word = "Hello!" +wordX = 0 +wordY = TOP_ROW +speedX = 0.25 +speedY = 0.125 +frames = 0 + +lastColumn = overlayGetWidth() // CELL_WIDTH - string.len(word) +lastRow = overlayGetHeight() // CELL_HEIGHT - 1 + + +function onOverlayUpdate() + frames = frames + 1 + wordX = wordX + speedX + wordY = wordY + speedY + + if wordX < 0 then + wordX = 0 + speedX = -speedX + end + + if wordX > lastColumn then + wordX = lastColumn + speedX = -speedX + end + + if wordY < TOP_ROW then + wordY = TOP_ROW + speedY = -speedY + end + + if wordY > lastRow then + wordY = lastRow + speedY = -speedY + end + + overlayClear() + overlayPrint(0, 0, "frame " .. frames) + overlayPrint(wordX, wordY, word) + return OVERLAY_UPDATED +end diff --git a/docs/learn/03-decisions.singe b/docs/learn/03-decisions.singe new file mode 100644 index 000000000..ece908582 --- /dev/null +++ b/docs/learn/03-decisions.singe @@ -0,0 +1,82 @@ +-- Lesson 3: Making Decisions + +BOX_SIZE = 20 +BOX_SPEED = 2 + +width = overlayGetWidth() +height = overlayGetHeight() +maxX = width - BOX_SIZE - 1 +maxY = height - BOX_SIZE - 1 + +boxX = width // 2 +boxY = height // 2 + +movingLeft = false +movingRight = false +movingUp = false +movingDown = false + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_UP then + movingUp = true + elseif what == SWITCH_DOWN then + movingDown = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + elseif what == SWITCH_UP then + movingUp = false + elseif what == SWITCH_DOWN then + movingDown = false + end +end + + +function onOverlayUpdate() + if movingLeft and not movingRight then + boxX = boxX - BOX_SPEED + elseif movingRight and not movingLeft then + boxX = boxX + BOX_SPEED + end + + if movingUp and not movingDown then + boxY = boxY - BOX_SPEED + elseif movingDown and not movingUp then + boxY = boxY + BOX_SPEED + end + + if boxX < 0 then + boxX = 0 + elseif boxX > maxX then + boxX = maxX + end + + if boxY < 0 then + boxY = 0 + elseif boxY > maxY then + boxY = maxY + end + + overlayClear() + + if movingLeft or movingRight or movingUp or movingDown then + colorForeground(255, 220, 0) + else + colorForeground(80, 160, 255) + end + + overlayBox(boxX, boxY, boxX + BOX_SIZE, boxY + BOX_SIZE) + overlayPrint(0, 0, "x " .. boxX .. " y " .. boxY) + return OVERLAY_UPDATED +end diff --git a/docs/learn/04-repeating.singe b/docs/learn/04-repeating.singe new file mode 100644 index 000000000..ec09bc9d4 --- /dev/null +++ b/docs/learn/04-repeating.singe @@ -0,0 +1,32 @@ +-- Lesson 4: Doing It Again + +LAYERS = 3 +STARS_PER_LAYER = 40 +ACROSS_STEP = 37 +DOWN_STEP = 61 +LAYER_SHIFT = 13 +LAYER_SPEED = 0.5 +LAYER_BRIGHT = 55 + +width = overlayGetWidth() +height = overlayGetHeight() + +drift = 0 + + +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + + for layer = 1, LAYERS do + for star = 1, STARS_PER_LAYER do + starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + shade = math.random(60, 90) + layer * LAYER_BRIGHT + colorForeground(shade, shade, shade) + overlayPlot(starX, starY) + end + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/05-functions.singe b/docs/learn/05-functions.singe new file mode 100644 index 000000000..4a7370bcf --- /dev/null +++ b/docs/learn/05-functions.singe @@ -0,0 +1,112 @@ +-- Lesson 5: Your Own Functions + +local LAYERS = 3 +local STARS_PER_LAYER = 40 +local ACROSS_STEP = 37 +local DOWN_STEP = 61 +local LAYER_SHIFT = 13 +local LAYER_SPEED = 0.5 +local LAYER_BRIGHT = 55 + +local PLAYER_WIDTH = 20 +local PLAYER_HEIGHT = 6 +local PLAYER_SPEED = 3 +local PLAYER_MARGIN = 6 + +local width = overlayGetWidth() +local height = overlayGetHeight() + +local drift = 0 +local playerX = width // 2 +local playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN +local movingLeft = false +local movingRight = false + + +local function clamp(value, low, high) + if value < low then + return low + end + if value > high then + return high + end + return value +end + + +local function shadeFor(layer) + return math.random(60, 90) + layer * LAYER_BRIGHT +end + + +local function starAt(star, layer) + local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + return x, y +end + + +local function drawStar(x, y, shade) + colorForeground(shade, shade, shade) + overlayPlot(x, y) +end + + +local function drawLayer(layer) + for star = 1, STARS_PER_LAYER do + local x, y = starAt(star, layer) + drawStar(x, y, shadeFor(layer)) + end +end + + +local function drawStars() + for layer = 1, LAYERS do + drawLayer(layer) + end +end + + +local function drawPlayer() + colorForeground(80, 255, 120) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT) +end + + +local function movePlayer() + if movingLeft then + playerX = playerX - PLAYER_SPEED + end + if movingRight then + playerX = playerX + PLAYER_SPEED + end + playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1) +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + movePlayer() + drawStars() + drawPlayer() + return OVERLAY_UPDATED +end diff --git a/docs/learn/06-tables.singe b/docs/learn/06-tables.singe new file mode 100644 index 000000000..f32eea1e9 --- /dev/null +++ b/docs/learn/06-tables.singe @@ -0,0 +1,134 @@ +-- Lesson 6: Lists Of Things + +local STAR_COUNT = 120 +local STAR_SHADE = 55 +local BURST_COUNT = 10 + +local PLAYER_WIDTH = 20 +local PLAYER_HEIGHT = 6 +local PLAYER_SPEED = 3 +local PLAYER_MARGIN = 6 + +local width = overlayGetWidth() +local height = overlayGetHeight() + +local stars = {} +local playerX = width // 2 +local playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN +local movingLeft = false +local movingRight = false + + +local function clamp(value, low, high) + if value < low then + return low + end + if value > high then + return high + end + return value +end + + +local function newStar(y) + local speed = math.random(1, 3) + local star = { + x = math.random(0, width - 1), + y = y, + speed = speed, + shade = math.random(60, 90) + speed * STAR_SHADE + } + return star +end + + +local function makeStars() + for number = 1, STAR_COUNT do + table.insert(stars, newStar(math.random(0, height - 1))) + end +end + + +local function addBurst() + for number = 1, BURST_COUNT do + table.insert(stars, newStar(0)) + end +end + + +local function moveStars() + for i = #stars, 1, -1 do + local star = stars[i] + star.y = star.y + star.speed + if star.y >= height then + table.remove(stars, i) + end + end + while #stars < STAR_COUNT do + table.insert(stars, newStar(0)) + end +end + + +local function drawStar(star) + colorForeground(star.shade, star.shade, star.shade) + overlayPlot(star.x, star.y) +end + + +local function drawStars() + for _, star in ipairs(stars) do + drawStar(star) + end +end + + +local function drawPlayer() + colorForeground(80, 255, 120) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT) +end + + +local function movePlayer() + if movingLeft then + playerX = playerX - PLAYER_SPEED + end + if movingRight then + playerX = playerX + PLAYER_SPEED + end + playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1) +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + addBurst() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + movePlayer() + moveStars() + drawStars() + drawPlayer() + overlayPrint(0, 0, "Stars: " .. #stars) + return OVERLAY_UPDATED +end + + +makeStars() diff --git a/docs/learn/07-a-game.singe b/docs/learn/07-a-game.singe new file mode 100644 index 000000000..76573ce43 --- /dev/null +++ b/docs/learn/07-a-game.singe @@ -0,0 +1,150 @@ +-- Learn to Program with Singe -- Lesson 7: A Game + +dofile("Singe/Framework.singe") + +local PLAYER_WIDTH = 40 +local PLAYER_HEIGHT = 8 +local PLAYER_SPEED = 4 +local PLAYER_MARGIN = 4 +local BLOCK_SIZE = 12 +local BLOCK_SPEED = 1.5 +local BLOCK_FASTER = 0.05 +local SPAWN_FRAMES = 40 +local START_LIVES = 3 +local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth() + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN + +local playerX = (screenWidth - PLAYER_WIDTH) / 2 +local goingLeft = false +local goingRight = false +local blocks = {} +local blockSpeed = BLOCK_SPEED +local spawnTimer = 0 +local score = 0 +local lives = START_LIVES +local state = "waiting" + + +function drawGame() + colorForeground(80, 255, 160) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) + colorForeground(255, 90, 90) + for i, block in ipairs(blocks) do + overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) + end + overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) +end + + +function drawOverText() + printCentered(7, "GAME OVER") + printCentered(9, "PRESS SPACE TO PLAY AGAIN") +end + + +function drawWaitingText() + printCentered(6, "DODGE THE BLOCKS") + printCentered(8, "ARROW KEYS TO MOVE") + printCentered(10, "PRESS SPACE TO START") +end + + +function printCentered(row, text) + overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) +end + + +function spawnBlock() + local block = {} + block.x = math.random(0, screenWidth - BLOCK_SIZE) + block.y = -BLOCK_SIZE + table.insert(blocks, block) +end + + +function startGame() + playerX = (screenWidth - PLAYER_WIDTH) / 2 + blocks = {} + blockSpeed = BLOCK_SPEED + spawnTimer = 0 + score = 0 + lives = START_LIVES + state = "playing" +end + + +function updatePlaying() + if goingLeft then + playerX = playerX - PLAYER_SPEED + end + if goingRight then + playerX = playerX + PLAYER_SPEED + end + if playerX < 0 then + playerX = 0 + elseif playerX > screenWidth - PLAYER_WIDTH then + playerX = screenWidth - PLAYER_WIDTH + end + + spawnTimer = spawnTimer - 1 + if spawnTimer <= 0 then + spawnBlock() + spawnTimer = SPAWN_FRAMES + end + + for i = #blocks, 1, -1 do + local block = blocks[i] + block.y = block.y + blockSpeed + if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then + table.remove(blocks, i) + lives = lives - 1 + if lives == 0 then + state = "over" + end + elseif block.y > screenHeight then + table.remove(blocks, i) + score = score + 1 + blockSpeed = blockSpeed + BLOCK_FASTER + end + end +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + goingLeft = true + elseif what == SWITCH_RIGHT then + goingRight = true + elseif what == SWITCH_BUTTON1 and state ~= "playing" then + startGame() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goingLeft = false + elseif what == SWITCH_RIGHT then + goingRight = false + end +end + + +function onOverlayUpdate() + if state == "playing" then + updatePlaying() + end + + overlayClear() + drawGame() + if state == "waiting" then + drawWaitingText() + elseif state == "over" then + drawOverText() + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/08-when-it-goes-wrong.singe b/docs/learn/08-when-it-goes-wrong.singe new file mode 100644 index 000000000..97cdb21c3 --- /dev/null +++ b/docs/learn/08-when-it-goes-wrong.singe @@ -0,0 +1,179 @@ +-- Learn to Program with Singe -- Lesson 8: When It Goes Wrong + +dofile("Singe/Framework.singe") + +local DEBUG = true +local REPORT_FRAMES = 120 + +local PLAYER_WIDTH = 40 +local PLAYER_HEIGHT = 8 +local PLAYER_SPEED = 4 +local PLAYER_MARGIN = 4 +local BLOCK_SIZE = 12 +local BLOCK_SPEED = 1.5 +local BLOCK_FASTER = 0.05 +local SPAWN_FRAMES = 40 +local START_LIVES = 3 +local TEXT_COLUMNS = 60 + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN + +local playerX = (screenWidth - PLAYER_WIDTH) / 2 +local goingLeft = false +local goingRight = false +local blocks = {} +local blockSpeed = BLOCK_SPEED +local spawnTimer = 0 +local score = 0 +local lives = START_LIVES +local state = "waiting" +local reportTimer = 0 + + +function debugLog(text) + if DEBUG then + debugPrint(text) + end +end + + +function debugReport() + debugLog(state .. ": score " .. score .. ", lives " .. lives .. ", blocks " .. #blocks) + debugLog(" speed " .. blockSpeed .. ", left " .. tostring(goingLeft) .. ", right " .. tostring(goingRight)) +end + + +function drawGame() + colorForeground(80, 255, 160) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) + colorForeground(255, 90, 90) + for i, block in ipairs(blocks) do + overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) + end + overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) + if DEBUG then + overlayPrint(1, 3, "BLOCKS " .. #blocks .. " PLAYER " .. playerX) + end +end + + +function drawOverText() + printCentered(7, "GAME OVER") + printCentered(9, "PRESS SPACE TO PLAY AGAIN") +end + + +function drawWaitingText() + printCentered(6, "DODGE THE BLOCKS") + printCentered(8, "ARROW KEYS TO MOVE") + printCentered(10, "PRESS SPACE TO START") +end + + +function printCentered(row, text) + overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) +end + + +function spawnBlock() + local block = {} + block.x = math.random(0, screenWidth - BLOCK_SIZE) + block.y = -BLOCK_SIZE + table.insert(blocks, block) +end + + +function startGame() + playerX = (screenWidth - PLAYER_WIDTH) / 2 + blocks = {} + blockSpeed = BLOCK_SPEED + spawnTimer = 0 + score = 0 + lives = START_LIVES + state = "playing" + debugLog("startGame: lives " .. lives .. ", speed " .. blockSpeed) +end + + +function updatePlaying() + if goingLeft then + playerX = playerX - PLAYER_SPEED + end + if goingRight then + playerX = playerX + PLAYER_SPEED + end + if playerX < 0 then + playerX = 0 + elseif playerX > screenWidth - PLAYER_WIDTH then + playerX = screenWidth - PLAYER_WIDTH + end + + spawnTimer = spawnTimer - 1 + if spawnTimer <= 0 then + spawnBlock() + spawnTimer = SPAWN_FRAMES + end + + for i = #blocks, 1, -1 do + local block = blocks[i] + block.y = block.y + blockSpeed + if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then + table.remove(blocks, i) + lives = lives - 1 + debugLog("hit at y " .. math.floor(block.y) .. ", lives now " .. lives) + if lives == 0 then + state = "over" + debugLog("game over with score " .. score) + end + elseif block.y > screenHeight then + table.remove(blocks, i) + score = score + 1 + blockSpeed = blockSpeed + BLOCK_FASTER + end + end +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + goingLeft = true + elseif what == SWITCH_RIGHT then + goingRight = true + elseif what == SWITCH_BUTTON1 and state ~= "playing" then + startGame() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goingLeft = false + elseif what == SWITCH_RIGHT then + goingRight = false + end +end + + +function onOverlayUpdate() + reportTimer = reportTimer - 1 + if reportTimer <= 0 then + debugReport() + reportTimer = REPORT_FRAMES + end + + if state == "playing" then + updatePlaying() + end + + overlayClear() + drawGame() + if state == "waiting" then + drawWaitingText() + elseif state == "over" then + drawOverText() + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/09-pictures.singe b/docs/learn/09-pictures.singe new file mode 100644 index 000000000..3004d9e3e --- /dev/null +++ b/docs/learn/09-pictures.singe @@ -0,0 +1,227 @@ +-- Lesson 9: Pictures. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_SPEED = 3 +local SHOT_SPEED = 6 +local ROCK_COUNT = 6 +local STAR_COUNT = 40 +local START_LIVES = 3 +local HIT_SCORE = 10 + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local starSprite = spriteLoad(DIR .. "art/star.png") + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local shipWidth = spriteGetWidth(shipSprite) +local shipHeight = spriteGetHeight(shipSprite) +local rockWidth = spriteGetWidth(rockSprite) +local rockHeight = spriteGetHeight(rockSprite) +local shotWidth = spriteGetWidth(shotSprite) +local shotHeight = spriteGetHeight(shotSprite) + +local shipX = (screenWidth - shipWidth) / 2 +local shipY = screenHeight - shipHeight - 4 +local goLeft = false +local goRight = false +local rocks = {} +local shots = {} +local stars = {} +local score = 0 +local lives = START_LIVES +local over = false + + +function drawHud() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(50, 1, "LIVES " .. lives) + if over then + overlayPrint(25, 8, "GAME OVER") + overlayPrint(17, 10, "PRESS 1 TO PLAY AGAIN") + end +end + + +function drawRocks() + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end +end + + +function drawShots() + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end + + +function drawStars() + for _, star in ipairs(stars) do + spriteDraw(starSprite, star.x, star.y) + end +end + + +function newRock(rock) + rock.x = math.random(0, screenWidth - rockWidth) + rock.y = -rockHeight - math.random(0, 160) + rock.speed = math.random(8, 20) / 10 +end + + +function onInputPressed(what) + if over then + if what == SWITCH_START1 then + startGame() + end + return + end + if what == SWITCH_LEFT then + goLeft = true + elseif what == SWITCH_RIGHT then + goRight = true + elseif what == SWITCH_BUTTON1 then + shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY } + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goLeft = false + elseif what == SWITCH_RIGHT then + goRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + updateStars() + if not over then + updateShip() + updateShots() + updateRocks() + end + drawStars() + drawRocks() + drawShots() + spriteDraw(shipSprite, shipX, shipY) + drawHud() + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(shipSprite) + spriteUnload(rockSprite) + spriteUnload(shotSprite) + spriteUnload(starSprite) +end + + +function overlapping(ax, ay, aw, ah, bx, by, bw, bh) + if ax + aw <= bx then + return false + end + if bx + bw <= ax then + return false + end + if ay + ah <= by then + return false + end + if by + bh <= ay then + return false + end + return true +end + + +function startGame() + shipX = (screenWidth - shipWidth) / 2 + score = 0 + lives = START_LIVES + over = false + shots = {} + for i = 1, ROCK_COUNT do + rocks[i] = {} + newRock(rocks[i]) + end +end + + +function updateRocks() + for _, rock in ipairs(rocks) do + rock.y = rock.y + rock.speed + if rock.y > screenHeight then + newRock(rock) + end + if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then + newRock(rock) + lives = lives - 1 + if lives <= 0 then + over = true + end + end + end +end + + +function updateShip() + if goLeft then + shipX = shipX - SHIP_SPEED + end + if goRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > screenWidth - shipWidth then + shipX = screenWidth - shipWidth + end +end + + +function updateShots() + for i = #shots, 1, -1 do + local shot = shots[i] + local gone = false + + shot.y = shot.y - SHOT_SPEED + if shot.y + shotHeight < 0 then + gone = true + end + for _, rock in ipairs(rocks) do + if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then + newRock(rock) + score = score + HIT_SCORE + gone = true + end + end + if gone then + table.remove(shots, i) + end + end +end + + +function updateStars() + for _, star in ipairs(stars) do + star.y = star.y + star.speed + if star.y > screenHeight then + star.y = 0 + star.x = math.random(0, screenWidth - 1) + end + end +end + + +for i = 1, STAR_COUNT do + stars[i] = { x = math.random(0, screenWidth - 1), y = math.random(0, screenHeight - 1), speed = math.random(1, 3) / 4 } +end + +startGame() diff --git a/docs/learn/10-sound.singe b/docs/learn/10-sound.singe new file mode 100644 index 000000000..584605eab --- /dev/null +++ b/docs/learn/10-sound.singe @@ -0,0 +1,266 @@ +-- Lesson 10: Sound. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_SPEED = 3 +local SHOT_SPEED = 6 +local ROCK_COUNT = 6 +local STAR_COUNT = 40 +local START_LIVES = 3 +local HIT_SCORE = 10 + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local starSprite = spriteLoad(DIR .. "art/star.png") + +local shootClip = soundLoad(DIR .. "art/shoot.wav") +local boomClip = soundLoad(DIR .. "art/boom.wav") +local clickClip = soundLoad("Singe/click.wav") +local theme = musicLoad("Singe/menuIntro.flac") + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local shipWidth = spriteGetWidth(shipSprite) +local shipHeight = spriteGetHeight(shipSprite) +local rockWidth = spriteGetWidth(rockSprite) +local rockHeight = spriteGetHeight(rockSprite) +local shotWidth = spriteGetWidth(shotSprite) +local shotHeight = spriteGetHeight(shotSprite) + +local shipX = (screenWidth - shipWidth) / 2 +local shipY = screenHeight - shipHeight - 4 +local goLeft = false +local goRight = false +local rocks = {} +local shots = {} +local stars = {} +local score = 0 +local lives = START_LIVES +local over = false +local dying = false +local deathChannel = -1 + + +function drawHud() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(50, 1, "LIVES " .. lives) + if over then + overlayPrint(25, 8, "GAME OVER") + overlayPrint(17, 10, "PRESS 1 TO PLAY AGAIN") + end +end + + +function drawRocks() + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end +end + + +function drawShots() + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end + + +function drawStars() + for _, star in ipairs(stars) do + spriteDraw(starSprite, star.x, star.y) + end +end + + +function endGame() + dying = false + deathChannel = -1 + over = true + musicStop(theme, 1500) +end + + +function newRock(rock) + rock.x = math.random(0, screenWidth - rockWidth) + rock.y = -rockHeight - math.random(0, 160) + rock.speed = math.random(8, 20) / 10 +end + + +function onInputPressed(what) + if over then + if what == SWITCH_START1 then + soundPlay(clickClip) + startGame() + end + return + end + if what == SWITCH_LEFT then + goLeft = true + elseif what == SWITCH_RIGHT then + goRight = true + elseif what == SWITCH_BUTTON1 then + shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY } + soundPlay(shootClip, 0, 35) + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goLeft = false + elseif what == SWITCH_RIGHT then + goRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + updateStars() + if not over and not dying then + updateShip() + updateShots() + updateRocks() + end + drawStars() + drawRocks() + drawShots() + spriteDraw(shipSprite, shipX, shipY) + drawHud() + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(shipSprite) + spriteUnload(rockSprite) + spriteUnload(shotSprite) + spriteUnload(starSprite) + soundUnload(shootClip) + soundUnload(boomClip) + soundUnload(clickClip) + musicUnload(theme) +end + + +function onSoundCompleted(channel) + if channel == deathChannel then + endGame() + end +end + + +function overlapping(ax, ay, aw, ah, bx, by, bw, bh) + if ax + aw <= bx then + return false + end + if bx + bw <= ax then + return false + end + if ay + ah <= by then + return false + end + if by + bh <= ay then + return false + end + return true +end + + +function startGame() + shipX = (screenWidth - shipWidth) / 2 + score = 0 + lives = START_LIVES + over = false + dying = false + deathChannel = -1 + shots = {} + for i = 1, ROCK_COUNT do + rocks[i] = {} + newRock(rocks[i]) + end + musicSetVolume(50) + musicPlay(theme, -1) +end + + +function updateRocks() + for _, rock in ipairs(rocks) do + rock.y = rock.y + rock.speed + if rock.y > screenHeight then + newRock(rock) + end + if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then + local channel = soundPlay(boomClip) + + newRock(rock) + lives = lives - 1 + if lives <= 0 then + dying = true + deathChannel = channel + if channel < 0 then + endGame() + end + end + end + end +end + + +function updateShip() + if goLeft then + shipX = shipX - SHIP_SPEED + end + if goRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > screenWidth - shipWidth then + shipX = screenWidth - shipWidth + end +end + + +function updateShots() + for i = #shots, 1, -1 do + local shot = shots[i] + local gone = false + + shot.y = shot.y - SHOT_SPEED + if shot.y + shotHeight < 0 then + gone = true + end + for _, rock in ipairs(rocks) do + if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then + newRock(rock) + score = score + HIT_SCORE + gone = true + soundPlay(boomClip, 0, 45) + end + end + if gone then + table.remove(shots, i) + end + end +end + + +function updateStars() + for _, star in ipairs(stars) do + star.y = star.y + star.speed + if star.y > screenHeight then + star.y = 0 + star.x = math.random(0, screenWidth - 1) + end + end +end + + +for i = 1, STAR_COUNT do + stars[i] = { x = math.random(0, screenWidth - 1), y = math.random(0, screenHeight - 1), speed = math.random(1, 3) / 4 } +end + +startGame() diff --git a/docs/learn/11-hitting-things.singe b/docs/learn/11-hitting-things.singe new file mode 100644 index 000000000..1c6e16443 --- /dev/null +++ b/docs/learn/11-hitting-things.singe @@ -0,0 +1,188 @@ +-- Lesson 11: Hitting Things. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_WIDTH = 32 +local SHIP_HEIGHT = 24 +local SHIP_PAD_X = 6 +local SHIP_PAD_Y = 5 +local SHIP_SPEED = 3 +local ROCK_WIDTH = 24 +local ROCK_HEIGHT = 24 +local ROCK_PAD = 4 +local ROCK_SPEED = 1 +local ROCK_CHANCE = 40 +local SHOT_WIDTH = 4 +local SHOT_HEIGHT = 10 +local SHOT_SPEED = 6 +local SHOW_BOXES = false + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local shootSound = soundLoad(DIR .. "art/shoot.wav") +local boomSound = soundLoad(DIR .. "art/boom.wav") + +local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 +local shipY = overlayGetHeight() - SHIP_HEIGHT - 8 +local movingLeft = false +local movingRight = false +local rocks = {} +local shots = {} + + +local function addRock() + local rock = {} + rock.x = math.random(0, overlayGetWidth() - ROCK_WIDTH) + rock.y = -ROCK_HEIGHT + rocks[#rocks + 1] = rock +end + + +local function fireShot() + local shot = {} + shot.x = shipX + SHIP_WIDTH / 2 - SHOT_WIDTH / 2 + shot.y = shipY - SHOT_HEIGHT + shots[#shots + 1] = shot + soundPlay(shootSound) +end + + +local function rockBox(rock) + return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2 +end + + +local function shipBox() + return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, SHIP_WIDTH - SHIP_PAD_X * 2, SHIP_HEIGHT - SHIP_PAD_Y * 2 +end + + +local function shipHitsRock(rock) + local sx, sy, sw, sh = shipBox() + local rx, ry, rw, rh = rockBox(rock) + return collideRects(sx, sy, sw, sh, rx, ry, rw, rh) +end + + +local function shotHitsRock(shot, rock) + local rx, ry, rw, rh = rockBox(rock) + return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh) +end + + +local function moveShip() + if movingLeft then + shipX = shipX - SHIP_SPEED + end + if movingRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > overlayGetWidth() - SHIP_WIDTH then + shipX = overlayGetWidth() - SHIP_WIDTH + end +end + + +local function moveRocks() + if math.random(ROCK_CHANCE) == 1 then + addRock() + end + for i = #rocks, 1, -1 do + local rock = rocks[i] + rock.y = rock.y + ROCK_SPEED + if rock.y > overlayGetHeight() then + table.remove(rocks, i) + end + end +end + + +local function moveShots() + for i = #shots, 1, -1 do + local shot = shots[i] + shot.y = shot.y - SHOT_SPEED + if shot.y + SHOT_HEIGHT < 0 then + table.remove(shots, i) + end + end +end + + +local function checkHits() + for s = #shots, 1, -1 do + for r = #rocks, 1, -1 do + if shotHitsRock(shots[s], rocks[r]) then + table.remove(rocks, r) + table.remove(shots, s) + soundPlay(boomSound) + break + end + end + end + for r = #rocks, 1, -1 do + if shipHitsRock(rocks[r]) then + table.remove(rocks, r) + soundPlay(boomSound) + end + end +end + + +local function drawBox(x, y, width, height) + overlayBox(x, y, x + width - 1, y + height - 1) +end + + +local function drawBoxes() + colorForeground(0, 255, 0, 255) + drawBox(shipBox()) + for _, rock in ipairs(rocks) do + drawBox(rockBox(rock)) + end +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + fireShot() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + + moveShip() + moveRocks() + moveShots() + checkHits() + + spriteDraw(shipSprite, shipX, shipY) + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end + if SHOW_BOXES then + drawBoxes() + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/12-score.singe b/docs/learn/12-score.singe new file mode 100644 index 000000000..170439035 --- /dev/null +++ b/docs/learn/12-score.singe @@ -0,0 +1,249 @@ +-- Lesson 12: Score, Lives, And Game Over. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_WIDTH = 32 +local SHIP_HEIGHT = 24 +local SHIP_PAD_X = 6 +local SHIP_PAD_Y = 5 +local SHIP_SPEED = 3 +local ROCK_WIDTH = 24 +local ROCK_HEIGHT = 24 +local ROCK_PAD = 4 +local ROCK_SPEED = 1 +local ROCK_CHANCE = 40 +local ROCK_POINTS = 10 +local SHOT_WIDTH = 4 +local SHOT_HEIGHT = 10 +local SHOT_SPEED = 6 +local START_LIVES = 3 + +local TITLE = "title" +local PLAYING = "playing" +local OVER = "over" + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local shootSound = soundLoad(DIR .. "art/shoot.wav") +local boomSound = soundLoad(DIR .. "art/boom.wav") + +local state = TITLE +local score = 0 +local lives = START_LIVES +local best = saveGet("highScore", 0) +local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 +local shipY = overlayGetHeight() - SHIP_HEIGHT - 8 +local movingLeft = false +local movingRight = false +local rocks = {} +local shots = {} + + +local function addRock() + local rock = {} + rock.x = math.random(0, overlayGetWidth() - ROCK_WIDTH) + rock.y = -ROCK_HEIGHT + rocks[#rocks + 1] = rock +end + + +local function fireShot() + local shot = {} + shot.x = shipX + SHIP_WIDTH / 2 - SHOT_WIDTH / 2 + shot.y = shipY - SHOT_HEIGHT + shots[#shots + 1] = shot + soundPlay(shootSound) +end + + +local function rockBox(rock) + return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2 +end + + +local function shipBox() + return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, SHIP_WIDTH - SHIP_PAD_X * 2, SHIP_HEIGHT - SHIP_PAD_Y * 2 +end + + +local function shipHitsRock(rock) + local sx, sy, sw, sh = shipBox() + local rx, ry, rw, rh = rockBox(rock) + return collideRects(sx, sy, sw, sh, rx, ry, rw, rh) +end + + +local function shotHitsRock(shot, rock) + local rx, ry, rw, rh = rockBox(rock) + return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh) +end + + +local function startGame() + score = 0 + lives = START_LIVES + rocks = {} + shots = {} + shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 + movingLeft = false + movingRight = false + state = PLAYING +end + + +local function endGame() + if score > best then + best = score + saveSet("highScore", best) + saveFlush() + end + state = OVER +end + + +local function moveShip() + if movingLeft then + shipX = shipX - SHIP_SPEED + end + if movingRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > overlayGetWidth() - SHIP_WIDTH then + shipX = overlayGetWidth() - SHIP_WIDTH + end +end + + +local function moveRocks() + if math.random(ROCK_CHANCE) == 1 then + addRock() + end + for i = #rocks, 1, -1 do + local rock = rocks[i] + rock.y = rock.y + ROCK_SPEED + if rock.y > overlayGetHeight() then + table.remove(rocks, i) + end + end +end + + +local function moveShots() + for i = #shots, 1, -1 do + local shot = shots[i] + shot.y = shot.y - SHOT_SPEED + if shot.y + SHOT_HEIGHT < 0 then + table.remove(shots, i) + end + end +end + + +local function checkHits() + for s = #shots, 1, -1 do + for r = #rocks, 1, -1 do + if shotHitsRock(shots[s], rocks[r]) then + table.remove(rocks, r) + table.remove(shots, s) + soundPlay(boomSound) + score = score + ROCK_POINTS + break + end + end + end + for r = #rocks, 1, -1 do + if shipHitsRock(rocks[r]) then + table.remove(rocks, r) + soundPlay(boomSound) + lives = lives - 1 + if lives <= 0 then + endGame() + end + end + end +end + + +local function drawWorld() + spriteDraw(shipSprite, shipX, shipY) + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end + + +local function drawScore() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(25, 1, "BEST " .. best) + overlayPrint(48, 1, "LIVES " .. lives) +end + + +local function updateTitle() + overlayPrint(25, 5, "R O C K S") + overlayPrint(24, 8, "BEST " .. best) + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end + + +local function updatePlaying() + moveShip() + moveRocks() + moveShots() + checkHits() + drawWorld() + drawScore() +end + + +local function updateOver() + drawWorld() + drawScore() + overlayPrint(25, 8, "GAME OVER") + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end + + +function onInputPressed(what) + if state == PLAYING then + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + fireShot() + end + elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 then + startGame() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + + if state == TITLE then + updateTitle() + elseif state == PLAYING then + updatePlaying() + else + updateOver() + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/13-game-folder.singe b/docs/learn/13-game-folder.singe new file mode 100644 index 000000000..883909c1a --- /dev/null +++ b/docs/learn/13-game-folder.singe @@ -0,0 +1,249 @@ +-- Lesson 13: A Game Folder. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_WIDTH = 32 +local SHIP_HEIGHT = 24 +local SHIP_PAD_X = 6 +local SHIP_PAD_Y = 5 +local SHIP_SPEED = 3 +local ROCK_WIDTH = 24 +local ROCK_HEIGHT = 24 +local ROCK_PAD = 4 +local ROCK_SPEED = 1 +local ROCK_CHANCE = 40 +local ROCK_POINTS = 10 +local SHOT_WIDTH = 4 +local SHOT_HEIGHT = 10 +local SHOT_SPEED = 6 +local START_LIVES = 3 + +local TITLE = "title" +local PLAYING = "playing" +local OVER = "over" + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local shootSound = soundLoad(DIR .. "art/shoot.wav") +local boomSound = soundLoad(DIR .. "art/boom.wav") + +local state = TITLE +local score = 0 +local lives = START_LIVES +local best = saveGet("highScore", 0) +local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 +local shipY = overlayGetHeight() - SHIP_HEIGHT - 8 +local movingLeft = false +local movingRight = false +local rocks = {} +local shots = {} + + +local function addRock() + local rock = {} + rock.x = math.random(0, overlayGetWidth() - ROCK_WIDTH) + rock.y = -ROCK_HEIGHT + rocks[#rocks + 1] = rock +end + + +local function fireShot() + local shot = {} + shot.x = shipX + SHIP_WIDTH / 2 - SHOT_WIDTH / 2 + shot.y = shipY - SHOT_HEIGHT + shots[#shots + 1] = shot + soundPlay(shootSound) +end + + +local function rockBox(rock) + return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2 +end + + +local function shipBox() + return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, SHIP_WIDTH - SHIP_PAD_X * 2, SHIP_HEIGHT - SHIP_PAD_Y * 2 +end + + +local function shipHitsRock(rock) + local sx, sy, sw, sh = shipBox() + local rx, ry, rw, rh = rockBox(rock) + return collideRects(sx, sy, sw, sh, rx, ry, rw, rh) +end + + +local function shotHitsRock(shot, rock) + local rx, ry, rw, rh = rockBox(rock) + return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh) +end + + +local function startGame() + score = 0 + lives = START_LIVES + rocks = {} + shots = {} + shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 + movingLeft = false + movingRight = false + state = PLAYING +end + + +local function endGame() + if score > best then + best = score + saveSet("highScore", best) + saveFlush() + end + state = OVER +end + + +local function moveShip() + if movingLeft then + shipX = shipX - SHIP_SPEED + end + if movingRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > overlayGetWidth() - SHIP_WIDTH then + shipX = overlayGetWidth() - SHIP_WIDTH + end +end + + +local function moveRocks() + if math.random(ROCK_CHANCE) == 1 then + addRock() + end + for i = #rocks, 1, -1 do + local rock = rocks[i] + rock.y = rock.y + ROCK_SPEED + if rock.y > overlayGetHeight() then + table.remove(rocks, i) + end + end +end + + +local function moveShots() + for i = #shots, 1, -1 do + local shot = shots[i] + shot.y = shot.y - SHOT_SPEED + if shot.y + SHOT_HEIGHT < 0 then + table.remove(shots, i) + end + end +end + + +local function checkHits() + for s = #shots, 1, -1 do + for r = #rocks, 1, -1 do + if shotHitsRock(shots[s], rocks[r]) then + table.remove(rocks, r) + table.remove(shots, s) + soundPlay(boomSound) + score = score + ROCK_POINTS + break + end + end + end + for r = #rocks, 1, -1 do + if shipHitsRock(rocks[r]) then + table.remove(rocks, r) + soundPlay(boomSound) + lives = lives - 1 + if lives <= 0 then + endGame() + end + end + end +end + + +local function drawWorld() + spriteDraw(shipSprite, shipX, shipY) + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end + + +local function drawScore() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(25, 1, "BEST " .. best) + overlayPrint(48, 1, "LIVES " .. lives) +end + + +local function updateTitle() + overlayPrint(25, 5, "R O C K S") + overlayPrint(24, 8, "BEST " .. best) + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end + + +local function updatePlaying() + moveShip() + moveRocks() + moveShots() + checkHits() + drawWorld() + drawScore() +end + + +local function updateOver() + drawWorld() + drawScore() + overlayPrint(25, 8, "GAME OVER") + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end + + +function onInputPressed(what) + if state == PLAYING then + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + fireShot() + end + elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 then + startGame() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + + if state == TITLE then + updateTitle() + elseif state == PLAYING then + updatePlaying() + else + updateOver() + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/14-video.singe b/docs/learn/14-video.singe new file mode 100644 index 000000000..74bb3af68 --- /dev/null +++ b/docs/learn/14-video.singe @@ -0,0 +1,69 @@ +-- Lesson 14: Playing Video. The finished script, exactly as the lesson ends. +-- Run it with: Singe -R -v Singe/menuBackground.mkv 14-video.singe +dofile("Singe/Framework.singe") + +local stopAt = 400 +local held = false + + +local function stateName() + local state = discGetState() + + if state == DISC_PLAYING then + return "playing" + elseif state == DISC_PAUSED then + return "paused" + elseif state == DISC_STOPPED then + return "stopped" + end + return "not there" +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + if discGetState() == DISC_PLAYING then + discPause() + else + discPlay() + end + elseif what == SWITCH_LEFT then + discSearch(0) + held = false + elseif what == SWITCH_RIGHT then + discSkipForward(30) + elseif what == SWITCH_START1 then + discStop() + held = false + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + overlayClear() + if not SINGE_DISC then + overlayPrint(2, 2, "This game has no video.") + overlayPrint(2, 4, "Run it again with -v Singe/menuBackground.mkv") + return OVERLAY_UPDATED + end + + if not held and frame >= stopAt then + discPause() + held = true + end + + overlayPrint(2, 2, "Frame " .. frame .. " of " .. discGetFrameCount()) + overlayPrint(2, 4, "The disc is " .. stateName() .. ".") + overlayPrint(2, 6, "Space plays and pauses. Left rewinds.") + overlayPrint(2, 7, "Right jumps on a second. 1 stops the disc.") + if held then + overlayPrint(2, 9, "Holding at frame " .. stopAt .. ".") + end + + return OVERLAY_UPDATED +end + + +discPlay() diff --git a/docs/learn/15-threaded.singe b/docs/learn/15-threaded.singe new file mode 100644 index 000000000..dedc3b921 --- /dev/null +++ b/docs/learn/15-threaded.singe @@ -0,0 +1,67 @@ +-- Lesson 15: The Other Way To Write It. The finished script, exactly as the lesson ends. +-- Run it with: Singe -R -v Singe/menuBackground.mkv 15-threaded.singe +local message = "" +local pressed = false + + +local function drawFrame() + overlayClear() + overlayPrint(2, 2, "Frame " .. discGetFrame()) + overlayPrint(2, 4, message) +end + + +local function playTo(frame) + while discGetFrame() < frame do + drawFrame() + singeYield() + end +end + + +local function waitForButton() + pressed = false + while not pressed do + drawFrame() + singeYield() + end +end + + +local function waitSeconds(seconds) + local stopTime = singeGetTicks() + seconds * 1000 + + while singeGetTicks() < stopTime do + drawFrame() + singeYield() + end +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + pressed = true + end +end + + +function singeMain() + discSearch(0) + discPlay() + playTo(120) + discPause() + + message = "Press the space bar to go on." + waitForButton() + + message = "" + discPlay() + playTo(300) + discPause() + + message = "The end." + waitSeconds(4) +end + + +dofile("Singe/Framework.singe") diff --git a/docs/learn/16-branching.singe b/docs/learn/16-branching.singe new file mode 100644 index 000000000..224ab4939 --- /dev/null +++ b/docs/learn/16-branching.singe @@ -0,0 +1,116 @@ +-- Lesson 16: Branching. The finished script, exactly as the lesson ends. +-- Run it with: Singe -R -v Singe/menuBackground.mkv 16-branching.singe +local FPS = 30 + +local scenes = { + arrive = { + first = 0, + last = 119, + ask = 30, + prompt = "The dragon lands. Left or Right?", + left = "sunset", + right = "pit", + goesTo = "pit" + }, + sunset = { + first = 120, + last = 259, + ask = 170, + prompt = "It breathes fire. Duck Left or run Right?", + left = "escape", + right = "pit", + goesTo = "pit" + }, + pit = { + first = 260, + last = 339, + death = true, + goesTo = "arrive" + }, + escape = { + first = 340, + last = 410 + } +} + +local answer = nil +local ending = "You got out alive." +local lives = 3 + + +local function drawScene(scene, frame, asking) + overlayClear() + overlayPrint(2, 2, "Lives: " .. lives) + if scene.death then + overlayPrint(2, 4, "That did not go well.") + elseif asking then + overlayPrint(2, 4, scene.prompt) + overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") + end +end + + +local function waitForGo(text) + answer = nil + while answer ~= "go" do + overlayClear() + overlayPrint(2, 2, "Lives: " .. lives) + overlayPrint(2, 4, text) + singeYield() + end +end + + +local function playScene(name) + local scene = scenes[name] + + discSkipToFrame(scene.first) + while discGetFrame() < scene.last do + local frame = discGetFrame() + local asking = scene.ask ~= nil and frame >= scene.ask + + if not asking then + answer = nil + elseif answer == "left" or answer == "right" then + return scene[answer] + end + drawScene(scene, frame, asking) + singeYield() + end + discPause() + + if scene.death then + lives = lives - 1 + if lives == 0 then + ending = "The dragon wins. Game over." + return nil + end + waitForGo("Press the space bar to try again.") + end + + return scene.goesTo +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + answer = "left" + elseif what == SWITCH_RIGHT then + answer = "right" + elseif what == SWITCH_BUTTON1 then + answer = "go" + end +end + + +function singeMain() + local scene = "arrive" + + while scene ~= nil do + scene = playScene(scene) + end + waitForGo(ending .. " Space to quit.") +end + + +dofile("Singe/Framework.singe") diff --git a/docs/learn/17-light-guns.singe b/docs/learn/17-light-guns.singe new file mode 100644 index 000000000..ab0780e09 --- /dev/null +++ b/docs/learn/17-light-guns.singe @@ -0,0 +1,123 @@ +-- Learn to Program with Singe -- Lesson 17: Light Guns + +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local FIRST_FRAME = 20 +local LAST_FRAME = 400 +local TARGET_SIZE = 96 +local HIT_SCORE = 100 +local FLASH_TIME = 12 + +local targets = { + { first = 40, last = 110, x = 80, y = 110 }, + { first = 120, last = 190, x = 470, y = 140 }, + { first = 200, last = 270, x = 290, y = 250 }, + { first = 280, last = 360, x = 150, y = 300 }, +} + +local aimX = overlayGetWidth() / 2 +local aimY = overlayGetHeight() / 2 +local score = 0 +local shots = 0 +local hits = 0 +local flash = 0 +local flashX = 0 +local flashY = 0 +local drawAim = singeWantsCrosshairs() + + +local function drawCrosshair(x, y) + colorForeground(255, 255, 255, 255) + overlayCircle(x, y, 10) + overlayLine(x - 18, y, x - 4, y) + overlayLine(x + 4, y, x + 18, y) + overlayLine(x, y - 18, x, y - 4) + overlayLine(x, y + 4, x, y + 18) +end + + +local function targetIsUp(target, frame) + return not target.hit and frame >= target.first and frame <= target.last +end + + +local function startRound() + score = 0 + shots = 0 + hits = 0 + flash = 0 + for _, target in ipairs(targets) do + target.hit = false + end + discSkipToFrame(FIRST_FRAME) +end + + +function onInputPressed(what, device) + if what == SWITCH_START1 then + startRound() + return + end + + if what ~= SWITCH_BUTTON3 then + return + end + + local x, y = mouseGetPosition(device or 0) + local frame = discGetFrame() + + shots = shots + 1 + + for _, target in ipairs(targets) do + if targetIsUp(target, frame) and collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then + target.hit = true + hits = hits + 1 + score = score + HIT_SCORE + flash = FLASH_TIME + flashX = x + flashY = y + return + end + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + if frame >= LAST_FRAME then + startRound() + frame = FIRST_FRAME + end + + aimX, aimY = mouseGetPosition(0) + + overlayClear() + + for _, target in ipairs(targets) do + if targetIsUp(target, frame) then + colorForeground(255, 200, 0, 255) + overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) + overlayCircle(target.x + TARGET_SIZE / 2, target.y + TARGET_SIZE / 2, TARGET_SIZE / 3) + end + end + + if flash > 0 then + colorForeground(255, 255, 255, 255) + overlayCircle(flashX, flashY, 30 - flash * 2) + flash = flash - 1 + end + + overlayPrint(2, 1, "SCORE " .. score .. " HITS " .. hits .. "/" .. shots .. " FRAME " .. frame) + + if drawAim then + drawCrosshair(aimX, aimY) + end + + return OVERLAY_UPDATED +end + + +startRound() diff --git a/docs/learn/18-qte.singe b/docs/learn/18-qte.singe new file mode 100644 index 000000000..23740150b --- /dev/null +++ b/docs/learn/18-qte.singe @@ -0,0 +1,159 @@ +-- Learn to Program with Singe -- Lesson 18: Quick-Time Events + +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local FIRST_FRAME = 20 +local WRONG_FRAME = 362 +local LATE_FRAME = 388 +local LAST_FRAME = 415 + +local PROMPT_COL = 54 +local PROMPT_ROW = 22 +local GAUGE_X = 210 +local GAUGE_Y = 330 +local GAUGE_W = 300 +local GAUGE_H = 18 + +local RIGHT_SCORE = 100 + +local moves = { + { first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" }, + { first = 140, last = 185, switch = SWITCH_UP, name = "UP" }, + { first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" }, + { first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" }, +} + +local score = 0 +local runOver = false +local verdict = "" + + +local function fillBar(x, y, width, height) + if width < 1 then + return + end + for row = 0, height - 1 do + overlayLine(x, y + row, x + width - 1, y + row) + end +end + + +local function isDirection(what) + return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT +end + + +local function moveAt(frame) + for _, move in ipairs(moves) do + if move.answer == nil and frame >= move.first and frame <= move.last then + return move + end + end + return nil +end + + +local function startRun() + score = 0 + runOver = false + verdict = "" + for _, move in ipairs(moves) do + move.answer = nil + end + discSkipToFrame(FIRST_FRAME) +end + + +local function endRun(reason, frame) + verdict = reason + runOver = true + discSkipToFrame(frame) +end + + +function onInputPressed(what) + if runOver then + if what == SWITCH_START1 then + startRun() + end + return + end + + if not isDirection(what) then + return + end + + local move = moveAt(discGetFrame()) + if move == nil then + return + end + + if what == move.switch then + move.answer = "right" + score = score + RIGHT_SCORE + else + move.answer = "wrong" + endRun("WRONG WAY", WRONG_FRAME) + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + if not runOver then + for _, move in ipairs(moves) do + if move.answer == nil and frame > move.last then + move.answer = "late" + endRun("TOO SLOW", LATE_FRAME) + break + end + end + end + + if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then + if not runOver then + verdict = "CLEAR" + runOver = true + end + discSearch(LAST_FRAME) + end + + overlayClear() + + local move = nil + if not runOver then + move = moveAt(frame) + end + + if move ~= nil then + local left = move.last - frame + local span = move.last - move.first + + overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name) + + colorForeground(255, 255, 255, 255) + overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1) + + if left * 3 < span then + colorForeground(255, 60, 60, 255) + else + colorForeground(255, 200, 0, 255) + end + fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H) + end + + overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame) + + if runOver then + overlayPrint(2, 3, verdict) + overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN") + end + + return OVERLAY_UPDATED +end + + +startRun() diff --git a/docs/learn/19-text.singe b/docs/learn/19-text.singe new file mode 100644 index 000000000..9b1a4db83 --- /dev/null +++ b/docs/learn/19-text.singe @@ -0,0 +1,95 @@ +-- Lesson 19: Text That Looks Good. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local MARGIN = 24 +local TITLE_Y = 60 +local SCORE_Y = 170 +local LABEL_Y = 186 +local HINT_Y = 300 +local CLOCK_Y = 430 + +local score = 0 +local titleFont = nil +local scoreFont = nil +local smallFont = nil +local titleImage = nil +local hintImage = nil +local scoreImage = nil +local scoreShown = nil + + +local function centred(image) + return (overlayGetWidth() - spriteGetWidth(image)) / 2 +end + + +local function renderScore() + if scoreImage ~= nil then + spriteUnload(scoreImage) + end + fontSelect(scoreFont) + colorForeground(255, 255, 255) + scoreImage = fontToSprite(string.format("%06d", score)) + scoreShown = score +end + + +fontQuality(FONT_QUALITY_BLENDED) + +titleFont = fontLoad("Singe/FreeSansBold.ttf", 48) +scoreFont = fontLoad("Singe/FreeSansBold.ttf", 36) +smallFont = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(titleFont) +colorForeground(255, 211, 90) +titleImage = fontToSprite("ASTEROID PATROL") + +fontSelect(smallFont) +colorForeground(140, 200, 255) +hintImage = fontToSprite("Press the fire button to score") + +renderScore() + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + score = score + 125 + end +end + + +function onOverlayUpdate() + overlayClear() + + spriteDraw(titleImage, centred(titleImage), TITLE_Y) + + if score ~= scoreShown then + renderScore() + end + spriteDraw(scoreImage, overlayGetWidth() - MARGIN - spriteGetWidth(scoreImage), SCORE_Y) + + fontSelect(smallFont) + colorForeground(160, 160, 160) + fontPrint(MARGIN, LABEL_Y, "SCORE") + fontPrint(MARGIN, CLOCK_Y, "Running for " .. (singeGetTicks() // 1000) .. " seconds") + + if (singeGetTicks() // 500) % 2 == 0 then + spriteDraw(hintImage, centred(hintImage), HINT_Y) + end + + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(titleImage) + spriteUnload(hintImage) + if scoreImage ~= nil then + spriteUnload(scoreImage) + end + fontUnload(titleFont) + fontUnload(scoreFont) + fontUnload(smallFont) +end diff --git a/docs/learn/20-gui.singe b/docs/learn/20-gui.singe new file mode 100644 index 000000000..e49306f34 --- /dev/null +++ b/docs/learn/20-gui.singe @@ -0,0 +1,121 @@ +-- Lesson 20: Menus And Screens. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) +singeSetPauseKeyEnabled(false) + +local SHIP_Y = 300 + +local ship = spriteLoad(DIR .. "art/ship.png") +local hudFont = fontLoad("Singe/FreeSansBold.ttf", 20) +local shipX = 0 +local speed = 2 +local score = 0 +local paused = false +local gui = nil +local page = nil + + +local function say(text) + if gui ~= nil then + guiSetValue(gui, page, "status", text) + end +end + + +local function closeMenu() + paused = false + singeSetPauseFlag(false) + if gui ~= nil then + guiHide(gui, page) + guiSetInput(gui, false) + end +end + + +local function openMenu() + paused = true + singeSetPauseFlag(true) + if gui ~= nil then + guiSetValue(gui, page, "score", string.format("%06d", score)) + guiShow(gui, page) + guiSetInput(gui, true) + end +end + + +if singeHasGpu() then + gui = guiNew(overlayGetWidth(), overlayGetHeight()) + page = guiLoad(gui, DIR .. "pause.rml") + guiHide(gui, page) + guiSetInput(gui, false) + + guiSetHandler(gui, page, "resume", "click", function() + closeMenu() + end) + + guiSetHandler(gui, page, "restart", "click", function() + score = 0 + shipX = 0 + closeMenu() + end) + + guiSetHandler(gui, page, "quit", "click", function() + singeQuit() + end) + + guiSetHandler(gui, page, "speed", "change", function(g, d, id, event, value) + speed = math.floor(tonumber(value)) + say("Speed " .. speed) + end) +end + + +function onInputPressed(what) + if what == SWITCH_PAUSE then + if paused then + closeMenu() + else + openMenu() + end + end +end + + +function onOverlayUpdate() + overlayClear() + + if not paused then + shipX = shipX + speed + if shipX > overlayGetWidth() then + shipX = -spriteGetWidth(ship) + score = score + 10 + end + end + spriteDraw(ship, shipX, SHIP_Y) + + fontSelect(hudFont) + colorForeground(255, 255, 255) + fontPrint(16, 16, string.format("SCORE %06d", score)) + fontPrint(16, 40, "P pauses") + + if paused then + if gui ~= nil then + guiDraw(gui) + else + colorForeground(255, 211, 90) + fontPrint(16, 70, "PAUSED -- no GPU, so no menu") + end + end + + return OVERLAY_UPDATED +end + + +function onShutdown() + if gui ~= nil then + guiDelete(gui) + end + spriteUnload(ship) + fontUnload(hudFont) +end diff --git a/docs/learn/21-subtitles.singe b/docs/learn/21-subtitles.singe new file mode 100644 index 000000000..5f477c1e9 --- /dev/null +++ b/docs/learn/21-subtitles.singe @@ -0,0 +1,80 @@ +-- Lesson 21: Subtitles. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local HUD_X = 16 +local STEP = 5 +local TOP_LIMIT = 10 +local LOW_LIMIT = 95 + +local hudFont = fontLoad("Singe/FreeSansBold.ttf", 18) +local loaded = false +local showing = false +local height = 80 + + +if discGetSubtitleTracks() > 0 then + loaded = srtLoadTrack(0) +end +if not loaded then + loaded = srtLoad(DIR .. "subtitles.srt") +end + +if loaded then + srtPosition(height) + showing = true + srtEnable(true) +end + +discPlay() + + +function onInputPressed(what) + if not loaded then + return + end + if what == SWITCH_BUTTON1 then + showing = not showing + srtEnable(showing) + if showing then + overlayBanner("Subtitles on", 20) + else + overlayBanner("Subtitles off", 20) + end + elseif what == SWITCH_UP then + height = math.max(TOP_LIMIT, height - STEP) + srtPosition(height) + elseif what == SWITCH_DOWN then + height = math.min(LOW_LIMIT, height + STEP) + srtPosition(height) + elseif what == SWITCH_LEFT then + srtClear() + discSearch(0) + discPlay() + end +end + + +function onOverlayUpdate() + overlayClear() + fontSelect(hudFont) + colorForeground(255, 255, 255) + if loaded then + if showing then + fontPrint(HUD_X, 16, "Frame " .. discGetFrame() .. " subtitles ON at " .. height .. "%") + else + fontPrint(HUD_X, 16, "Frame " .. discGetFrame() .. " subtitles OFF") + end + fontPrint(HUD_X, 40, "Fire toggles them, up and down move them, left rewinds") + else + fontPrint(HUD_X, 16, "No subtitles loaded.") + fontPrint(HUD_X, 40, "Run with --framefile and check subtitles.srt is beside the script.") + end + return OVERLAY_UPDATED +end + + +function onShutdown() + fontUnload(hudFont) +end diff --git a/docs/learn/22-3d.singe b/docs/learn/22-3d.singe new file mode 100644 index 000000000..9427cf485 --- /dev/null +++ b/docs/learn/22-3d.singe @@ -0,0 +1,76 @@ +-- Lesson 22: Into 3D. The finished script, exactly as the lesson ends. + +local turnLeft = false +local turnRight = false +local tiltUp = false +local tiltDown = false + +sceneEnable(true) +sceneSetBackground(18, 20, 34) +sceneSetAmbient(35, 35, 45) + +local paint = materialNew() +materialSetColor(paint, 215, 95, 60) +materialSetRoughness(paint, 0.6) + +local box = nodeNew() +nodeSetMesh(box, meshBox(2, 2, 2), paint) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, 4, 6, 5) +nodeLookAt(sun, 0, 0, 0) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 2, 7) +nodeLookAt(camera, 0, 0, 0) +cameraSet(camera) +cameraSetPerspective(60, 0.1, 100) + + +function onInputPressed(what) + if what == SWITCH_LEFT then + turnLeft = true + elseif what == SWITCH_RIGHT then + turnRight = true + elseif what == SWITCH_UP then + tiltUp = true + elseif what == SWITCH_DOWN then + tiltDown = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + turnLeft = false + elseif what == SWITCH_RIGHT then + turnRight = false + elseif what == SWITCH_UP then + tiltUp = false + elseif what == SWITCH_DOWN then + tiltDown = false + end +end + + +function onOverlayUpdate() + if turnLeft then + nodeRotate(box, 0, -1.5, 0) + end + if turnRight then + nodeRotate(box, 0, 1.5, 0) + end + if tiltUp then + nodeRotate(box, -1.5, 0, 0) + end + if tiltDown then + nodeRotate(box, 1.5, 0, 0) + end + + local pitch, yaw = nodeGetRotation(box) + + overlayClear() + overlayPrint(2, 2, "Arrow keys turn the box.") + overlayPrint(2, 4, "yaw " .. math.floor(yaw) .. " pitch " .. math.floor(pitch)) + return OVERLAY_UPDATED +end diff --git a/docs/learn/23-models.singe b/docs/learn/23-models.singe new file mode 100644 index 000000000..e27c109d0 --- /dev/null +++ b/docs/learn/23-models.singe @@ -0,0 +1,62 @@ +-- Lesson 23: Models And Light. The finished script, exactly as the lesson ends. + +sceneEnable(true) +sceneSetBackground(10, 12, 20) +sceneSetAmbient(28, 30, 38) + +local stone = materialNew() +materialSetColor(stone, 105, 105, 115) +materialSetRoughness(stone, 0.9) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(24, 0.4, 24), stone) +nodeSetPosition(floor, 0, -0.2, 0) + +local dragonModel = modelLoad("Singe/DragonModel.glb") +local dragon = modelInstance(dragonModel) +nodeSetScale(dragon, 0.5) +nodeSetRotation(dragon, 0, -60, 0) + +local clips = modelGetAnimations(dragonModel) + +if #clips > 0 then + animationPlay(dragon, 1, true) +else + debugPrint("DragonModel.glb carries no animation clips. The wings are turned by hand.") +end + +local wingLeft = nodeFind("wingL", dragon) +local wingRight = nodeFind("wingR", dragon) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -6, 9, 7) +nodeLookAt(sun, 0, 1.5, 0) +lightSetColor(sun, 255, 244, 224) +lightSetIntensity(sun, 1.8) +lightSetShadow(sun, true) + +local torch = lightNew(LIGHT_POINT) +nodeSetPosition(torch, 3.5, 1.2, 3.0) +lightSetColor(torch, 255, 150, 70) +lightSetIntensity(torch, 14) +lightSetRange(torch, 12) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 2.6, 9) +nodeLookAt(camera, 0, 1.7, 0) +cameraSet(camera) +cameraSetPerspective(55, 0.1, 200) + + +function onOverlayUpdate() + local flap = math.sin(singeGetTicks() / 260) * 22 + + nodeSetRotation(wingLeft, flap, 0, 0) + nodeSetRotation(wingRight, -flap, 0, 0) + nodeRotate(dragon, 0, 0.25, 0) + + overlayClear() + overlayPrint(2, 2, "Singe/DragonModel.glb") + overlayPrint(2, 4, "animation clips in the file: " .. #clips) + return OVERLAY_UPDATED +end diff --git a/docs/learn/24-physics.singe b/docs/learn/24-physics.singe new file mode 100644 index 000000000..f72c103f4 --- /dev/null +++ b/docs/learn/24-physics.singe @@ -0,0 +1,162 @@ +-- Lesson 24: Physics. The finished script, exactly as the lesson ends. + +local crates = {} +local balls = {} +local aimX = 0 +local aimLeft = false +local aimRight = false +local knocks = 0 +local showShapes = false + +sceneEnable(true) +sceneSetBackground(16, 18, 26) +sceneSetAmbient(40, 42, 50) + +local stone = materialNew() +materialSetColor(stone, 110, 110, 120) +materialSetRoughness(stone, 0.9) + +local wood = materialNew() +materialSetColor(wood, 190, 140, 80) +materialSetRoughness(wood, 0.8) + +local steel = materialNew() +materialSetColor(steel, 200, 205, 215) +materialSetMetallic(steel, 1) +materialSetRoughness(steel, 0.25) + +local floor = nodeNew() +nodeSetMesh(floor, meshBox(16, 0.4, 16), stone) +nodeSetPosition(floor, 0, -0.2, 0) +bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.4, 16) + +local wallMesh = meshBox(16, 1, 0.4) + +for _, side in ipairs({ { 0, -8, 0 }, { 0, 8, 0 }, { -8, 0, 90 }, { 8, 0, 90 } }) do + local wall = nodeNew() + nodeSetMesh(wall, wallMesh, stone) + nodeSetPosition(wall, side[1], 0.5, side[2]) + nodeSetRotation(wall, 0, side[3], 0) + bodyNew(wall, BODY_STATIC, SHAPE_BOX, 16, 1, 0.4) +end + +local crateMesh = meshBox(0.8, 0.8, 0.8) + + +local function crateAt(x, y) + local crate = nodeNew() + + nodeSetMesh(crate, crateMesh, wood) + nodeSetPosition(crate, x, y, -2) + bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8) + bodySetMass(crate, 4) + crates[#crates + 1] = crate +end + + +local function fire() + local ball = nodeNew() + + nodeSetMesh(ball, meshSphere(0.35, 24), steel) + nodeSetPosition(ball, aimX, 1.2, 6) + bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.35) + bodySetMass(ball, 3) + bodySetBounce(ball, 0.3) + bodySetVelocity(ball, 0, 2, -14) + balls[#balls + 1] = ball + if #balls > 5 then + nodeDelete(balls[1]) + table.remove(balls, 1) + end +end + + +crateAt(-0.9, 0.4) +crateAt(0, 0.4) +crateAt(0.9, 0.4) +crateAt(-0.45, 1.25) +crateAt(0.45, 1.25) +crateAt(0, 2.1) + +local marker = nodeNew() +nodeSetMesh(marker, meshBox(0.7, 0.1, 0.7), steel) +nodeSetPosition(marker, 0, 0.05, 6) + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, -5, 9, 6) +nodeLookAt(sun, 0, 0, -2) +lightSetIntensity(sun, 1.7) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 4, 11) +nodeLookAt(camera, 0, 1, -2) +cameraSet(camera) +cameraSetPerspective(55, 0.1, 200) + + +function onCollision(nodeA, nodeB, x, y, z, speed) + if speed > 4 then + knocks = knocks + 1 + end +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + fire() + elseif what == SWITCH_BUTTON3 then + showShapes = not showShapes + if showShapes then + physicsSetDebug(DEBUG_SHAPES + DEBUG_CONTACTS) + else + physicsSetDebug(DEBUG_NONE) + end + elseif what == SWITCH_LEFT then + aimLeft = true + elseif what == SWITCH_RIGHT then + aimRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + aimLeft = false + elseif what == SWITCH_RIGHT then + aimRight = false + end +end + + +function onOverlayUpdate() + local over = 0 + local settled = true + + if aimLeft then + aimX = math.max(aimX - 0.08, -5) + end + if aimRight then + aimX = math.min(aimX + 0.08, 5) + end + nodeSetPosition(marker, aimX, 0.05, 6) + + for _, crate in ipairs(crates) do + local pitch, _, roll = nodeGetRotation(crate) + + if math.abs(pitch) > 30 or math.abs(roll) > 30 then + over = over + 1 + end + if not bodyIsResting(crate) then + settled = false + end + end + + overlayClear() + overlayPrint(2, 2, "Arrows aim. Space fires. Shift shows the shapes.") + overlayPrint(2, 4, "knocked over " .. over .. " of " .. #crates .. " hard hits " .. knocks) + if settled then + overlayPrint(2, 6, "everything has settled") + end + return OVERLAY_UPDATED +end diff --git a/docs/learn/25-particles.singe b/docs/learn/25-particles.singe new file mode 100644 index 000000000..cbbe11ce5 --- /dev/null +++ b/docs/learn/25-particles.singe @@ -0,0 +1,116 @@ +-- Lesson 25: Particles. The finished script, exactly as the lesson ends. +dofile("Singe/Framework.singe") + +local SHIP_WIDTH = 32 +local SHIP_HEIGHT = 24 +local SHIP_SPEED = 120 +local RESPAWN_MS = 1400 + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local sparkSprite = spriteLoad(DIR .. "art/star.png") +local boomSound = soundLoad(DIR .. "art/boom.wav") + +local ground = overlayGetHeight() - 20 +local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 +local shipY = overlayGetHeight() - 56 + +local flying = true +local movingLeft = false +local movingRight = false +local lastTicks = singeGetTicks() + +local exhaust = emitterNew() +emitterSetBlend(exhaust, PARTICLE_ADD) +emitterSetRate(exhaust, 90) +emitterSetLife(exhaust, 0.15, 0.40) +emitterSetDirection(exhaust, 0, 1) +emitterSetSpread(exhaust, 12) +emitterSetSpeed(exhaust, 40, 90) +emitterSetSize(exhaust, 7, 1) +emitterSetColor(exhaust, 255, 240, 180, 255, 255, 60, 0, 0) +emitterSetMax(exhaust, 120) +emitterStart(exhaust) + +local boom = emitterNew() +emitterSetTexture(boom, sparkSprite) +emitterSetBlend(boom, PARTICLE_ADD) +emitterSetLife(boom, 0.4, 1.1) +emitterSetSpeed(boom, 40, 220) +emitterSetSpread(boom, 180) +emitterSetGravity(boom, 0, 160) +emitterSetDrag(boom, 0.9) +emitterSetSize(boom, 10, 2, 0.4) +emitterSetSpin(boom, -240, 240) +emitterSetColor(boom, 255, 255, 220, 255, 255, 70, 20, 0) +emitterSetCollide(boom, COLLIDE_FLOOR, 0.35, 0.3, ground) +emitterSetTrail(boom, 4, 2) +emitterSetMax(boom, 400) + + +function explode() + if not flying then + return + end + flying = false + emitterStop(exhaust) + emitterSetPosition(boom, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT / 2) + emitterBurst(boom, 140) + soundPlay(boomSound) + timerAfter(RESPAWN_MS, function() + flying = true + emitterStart(exhaust) + end) +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + explode() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + local now = singeGetTicks() + local seconds = (now - lastTicks) / 1000 + lastTicks = now + + if flying then + if movingLeft then + shipX = shipX - SHIP_SPEED * seconds + end + if movingRight then + shipX = shipX + SHIP_SPEED * seconds + end + if shipX < 0 then + shipX = 0 + end + if shipX > overlayGetWidth() - SHIP_WIDTH then + shipX = overlayGetWidth() - SHIP_WIDTH + end + emitterSetPosition(exhaust, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT - 2) + end + + overlayClear() + if flying then + spriteDraw(shipSprite, shipX, shipY) + end + emitterDraw(exhaust) + emitterDraw(boom) + overlayPrint(2, 2, "Left and right to fly. Button 1 to blow up.") + overlayPrint(2, 4, "Particles alive: " .. (emitterGetCount(exhaust) + emitterGetCount(boom))) + return OVERLAY_UPDATED +end diff --git a/docs/learn/26-navigation.singe b/docs/learn/26-navigation.singe new file mode 100644 index 000000000..c13367149 --- /dev/null +++ b/docs/learn/26-navigation.singe @@ -0,0 +1,146 @@ +-- Lesson 26: Characters That Move Themselves. The finished script, exactly as the lesson ends. +local ROOM = 20 +local WALL_HEIGHT = 2 +local AGENT_RADIUS = 0.4 +local AGENT_HEIGHT = 1.8 +local WALK_SPEED = 2.2 + +sceneEnable(true) +sceneSetBackground(28, 32, 42, 255) +sceneSetAmbient(80, 86, 100) + +local floorLook = materialNew() +materialSetColor(floorLook, 96, 102, 110) +materialSetRoughness(floorLook, 0.9) + +local wallLook = materialNew() +materialSetColor(wallLook, 150, 128, 104) +materialSetRoughness(wallLook, 0.8) + +local walkerLook = materialNew() +materialSetColor(walkerLook, 225, 85, 60) +materialSetEmissive(walkerLook, 70, 18, 10) + +local wandererLook = materialNew() +materialSetColor(wandererLook, 80, 150, 225) +materialSetEmissive(wandererLook, 10, 30, 70) + +local ground = nodeNew() +nodeSetMesh(ground, meshPlane(ROOM, ROOM), floorLook) + +local wallMesh = meshBox(1, WALL_HEIGHT, 1) +local walls = {} + + +function addWall(x, z, width, depth) + local wall = nodeNew() + nodeSetMesh(wall, wallMesh, wallLook) + nodeSetScale(wall, width, 1, depth) + nodeSetPosition(wall, x, WALL_HEIGHT / 2, z) + walls[#walls + 1] = wall +end + + +function makeWalker(x, z, look) + local walker = nodeNew() + nodeSetPosition(walker, x, 0, z) + local body = nodeNew() + nodeSetParent(body, walker) + nodeSetMesh(body, meshBox(0.7, AGENT_HEIGHT, 0.7), look) + nodeSetPosition(body, 0, AGENT_HEIGHT / 2, 0) + local nose = nodeNew() + nodeSetParent(nose, body) + nodeSetMesh(nose, meshSphere(0.22, 16), look) + nodeSetPosition(nose, 0, 0.4, -0.45) + return walker +end + + +addWall(-4, 0, 12, 0.6) +addWall(7.5, 0, 5, 0.6) +addWall(-3, -6, 0.6, 8) + +local nav = navNew(AGENT_RADIUS, AGENT_HEIGHT, 45, 0.3) +navAddNode(nav, ground) +for _, wall in ipairs(walls) do + navAddNode(nav, wall) +end +navBuild(nav) + +local walker = makeWalker(-7, 7, walkerLook) +local walkerAgent = navAgentNew(nav, walker, AGENT_RADIUS, AGENT_HEIGHT, WALK_SPEED) + +local wanderer = makeWalker(6, -6, wandererLook) +local wandererAgent = navAgentNew(nav, wanderer, AGENT_RADIUS, AGENT_HEIGHT, 1.4) + +local posts = { + { 7, 0, 7 }, + { 7, 0, -7 }, + { -7, 0, -7 }, + { -7, 0, 7 }, + { 30, 0, 30 } +} +local post = 0 +local message = "Button 1 sends the red one somewhere." + +local sun = lightNew(LIGHT_DIRECTIONAL) +nodeSetPosition(sun, 8, 14, 6) +nodeLookAt(sun, 0, 0, 0) +lightSetIntensity(sun, 3.5) +lightSetShadow(sun, true) + +local camera = nodeNew() +nodeSetPosition(camera, 0, 17, 19) +nodeLookAt(camera, 0, 0, 0) +cameraSet(camera) +cameraSetPerspective(55, 0.1, 120) + +navAgentMoveTo(wandererAgent, navRandomPoint(nav)) + + +function onInputPressed(what) + if what ~= SWITCH_BUTTON1 then + return + end + post = post % #posts + 1 + local target = posts[post] + if navAgentMoveTo(walkerAgent, target[1], target[2], target[3]) then + message = string.format("Walking to post %d.", post) + else + message = string.format("No path to post %d. Staying put.", post) + end +end + + +function onNavArrived(agent) + if agent == wandererAgent then + navAgentMoveTo(agent, navRandomPoint(nav)) + else + message = string.format("Arrived at post %d.", post) + end +end + + +function onOverlayUpdate() + overlayClear() + navDraw(nav, 0, 170, 190) + + local target = posts[post] + if target ~= nil then + local x, y, z = nodeGetWorldPosition(walker) + local route = navPath(nav, x, y, z, target[1], target[2], target[3]) + if route ~= nil then + for i = 2, #route do + local a = route[i - 1] + local b = route[i] + lineDraw(a[1], a[2] + 0.1, a[3], b[1], b[2] + 0.1, b[3], 255, 215, 60) + end + end + end + + local vx, vy, vz = navAgentGetVelocity(walkerAgent) + local speed = math.sqrt(vx * vx + vz * vz) + overlayPrint(2, 2, message) + overlayPrint(2, 4, string.format("Speed %.2f", speed)) + return OVERLAY_UPDATED +end diff --git a/docs/learn/27-midi.singe b/docs/learn/27-midi.singe new file mode 100644 index 000000000..9b919f6f4 --- /dev/null +++ b/docs/learn/27-midi.singe @@ -0,0 +1,115 @@ +-- Lesson 27: Music And MIDI. The finished script, exactly as the lesson ends. +local NOTE_NAMES = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" } +local MIDI_NOTE_ON = 0x90 +local LOWEST = 60 +local HIGHEST = 71 +local HOLD_MS = 400 +local VOLUME_STEP = 8 + +local theme = musicLoad("Singe/menuIntro.flac") +local volume = 64 + +local target = LOWEST +local heard = "nothing yet" +local score = 0 +local inPort = "none" +local outPort = "none" + + +function noteName(key) + return NOTE_NAMES[(key % 12) + 1] .. (math.floor(key / 12) - 1) +end + + +function pickTarget() + target = math.random(LOWEST, HIGHEST) +end + + +function playTarget() + if not midiIsOutputOpen() then + return + end + local key = target + midiNoteOn(1, key, 100) + timerAfter(HOLD_MS, function() + midiNoteOff(1, key) + end) +end + + +function setVolume(level) + volume = math.min(128, math.max(0, level)) + musicSetVolume(volume) +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + playTarget() + elseif what == SWITCH_BUTTON2 then + if musicIsPlaying(theme) then + musicPause(theme) + else + musicResume(theme) + end + elseif what == SWITCH_BUTTON3 then + if musicIsPlaying(theme) then + musicStop(theme, 2000) + else + musicPlay(theme, -1) + end + elseif what == SWITCH_UP then + setVolume(volume + VOLUME_STEP) + elseif what == SWITCH_DOWN then + setVolume(volume - VOLUME_STEP) + end +end + + +function onMidiMessage(status, data1, data2, bytes) + if (status & 0xF0) ~= MIDI_NOTE_ON or data2 == 0 then + return + end + heard = noteName(data1) + if data1 == target then + score = score + 1 + pickTarget() + playTarget() + end +end + + +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Music: " .. (musicIsPlaying(theme) and "playing" or "stopped")) + overlayPrint(2, 3, "Volume: " .. volume .. " of 128") + overlayPrint(2, 5, "MIDI in: " .. inPort) + overlayPrint(2, 6, "MIDI out: " .. outPort) + overlayPrint(2, 8, "Play this note: " .. noteName(target)) + overlayPrint(2, 9, "Last note heard: " .. heard) + overlayPrint(2, 10, "Score: " .. score) + overlayPrint(2, 12, "Up and down change the volume.") + overlayPrint(2, 13, "Space sends the note out, Alt pauses, Shift fades out.") + return OVERLAY_UPDATED +end + + +function onShutdown() + midiCloseInput() + midiCloseOutput() + musicUnload(theme) +end + + +setVolume(volume) +musicPlay(theme, -1) + +if midiInputCount() > 0 and midiOpenInput(0) then + inPort = midiInputName(0) +end +if midiOutputCount() > 0 and midiOpenOutput(0) then + outPort = midiOutputName(0) +end + +pickTarget() diff --git a/docs/learn/28-online.singe b/docs/learn/28-online.singe new file mode 100644 index 000000000..0228b057d --- /dev/null +++ b/docs/learn/28-online.singe @@ -0,0 +1,129 @@ +-- Lesson 28: Online. The finished script, exactly as the lesson ends. + +dofile("Singe/Framework.singe") + +local WAIT_MIN = 1000 +local WAIT_MAX = 3000 +local PERFECT = 1000 + +local state = "attract" +local goAt = 0 +local score = 0 +local best = saveGet("best", 0) +local saying = "Button 1 to play." +local top = nil +local standing = nil + + +local function whyNotPosted() + if not singeGetGameId() then + return "this game has no GAME_ID." + end + return "this machine is not signed in." +end + + +local function fetchBoard() + top = nil + standing = nil + saying = "Asking the service..." + scoreBoard(function(ok, result) + if not ok then + saying = "No board: " .. tostring(result) + return + end + top = result.top + standing = result.standing + if #top == 0 then + saying = "The board is empty. You could be first." + else + saying = "" + end + end) +end + + +local function finish(reaction) + score = PERFECT - reaction + if score < 0 then + score = 0 + end + if score > best then + best = score + saveSet("best", best) + end + state = "result" + if scoreSubmit(score) then + fetchBoard() + else + saying = "Kept here only: " .. whyNotPosted() + end +end + + +local function play() + state = "waiting" + goAt = singeGetTicks() + math.random(WAIT_MIN, WAIT_MAX) + score = 0 + top = nil + standing = nil + saying = "" + scoreBegin() +end + + +function onInputPressed(what) + if what ~= SWITCH_BUTTON1 then + return + end + if state == "waiting" then + state = "result" + score = 0 + saying = "Too soon. Nothing posted." + elseif state == "go" then + finish(singeGetTicks() - goAt) + else + play() + end +end + + +function onOverlayUpdate() + scoreUpdate() + + if state == "waiting" and singeGetTicks() >= goAt then + state = "go" + end + + overlayClear() + overlayPrint(2, 0, "REACTION TEST") + + if state == "attract" then + overlayPrint(2, 2, "Button 1 to play.") + elseif state == "waiting" then + overlayPrint(2, 2, "Wait for it...") + elseif state == "go" then + overlayPrint(2, 2, "NOW!") + else + overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.") + end + + overlayPrint(2, 4, "Best on this machine: " .. best) + overlayPrint(2, 5, "Posting as: " .. (scorePlayerName() or "nobody yet")) + overlayPrint(2, 6, "Waiting to send: " .. scoreWaiting()) + overlayPrint(2, 8, saying) + + if top then + for place, row in ipairs(top) do + if place > 5 then + break + end + overlayPrint(2, 9 + place, place .. ". " .. row.name .. " " .. row.value) + end + end + if standing then + overlayPrint(2, 16, "You are " .. standing.rank .. " of " .. standing.players .. ".") + end + + return OVERLAY_UPDATED +end diff --git a/docs/learn/29-shipping.singe b/docs/learn/29-shipping.singe new file mode 100644 index 000000000..bda04159d --- /dev/null +++ b/docs/learn/29-shipping.singe @@ -0,0 +1,81 @@ +-- Lesson 29: Shipping It. The finished script, exactly as the lesson ends. + +dofile("Singe/Framework.singe") + +local GAME_NAME = "Rock Dodger" +local GAME_VERSION = "1.0.2" + +local CREDITS = { + "Written and drawn by ", + "", + "Music \"Night Drive\" by A. Composer", + " Creative Commons BY 4.0", + " example.org/night-drive", + "", + "Explosion freesound.org user \"thud\"", + " Creative Commons CC0", + "", + "Font FreeSansBold", + " GPL with the font exception", + "", + "Engine Singe " .. SINGE_VERSION_STRING, + " GPL version 3", + " See Singe/LICENSES for the", + " libraries Singe is built on.", + "", + "Thank you for playing.", +} + +local FIRST_ROW = 4 +local ROWS = 10 + +local scroll = 0 + + +local function scrollBy(lines) + local most = #CREDITS - ROWS + + if most < 0 then + most = 0 + end + scroll = scroll + lines + if scroll < 0 then + scroll = 0 + end + if scroll > most then + scroll = most + end +end + + +function onInputPressed(what) + if what == SWITCH_UP then + scrollBy(-1) + elseif what == SWITCH_DOWN then + scrollBy(1) + elseif what == SWITCH_BUTTON1 then + scroll = 0 + end +end + + +function onOverlayUpdate() + local line = nil + + overlayClear() + overlayPrint(2, 0, GAME_NAME .. " version " .. GAME_VERSION) + overlayPrint(2, 1, "Singe " .. SINGE_VERSION_STRING .. " id " .. (singeGetGameId() or "none")) + + for row = 0, ROWS - 1 do + line = CREDITS[scroll + row + 1] + if line then + overlayPrint(2, FIRST_ROW + row, line) + end + end + + overlayPrint(2, FIRST_ROW + ROWS + 1, "Saves go to:") + overlayPrint(2, FIRST_ROW + ROWS + 2, singeGetDataPath()) + overlayPrint(2, 17, "Up and down scroll. Button 1 goes back to the top.") + + return OVERLAY_UPDATED +end diff --git a/docs/learn/30-cabinet.singe b/docs/learn/30-cabinet.singe new file mode 100644 index 000000000..58919ba9a --- /dev/null +++ b/docs/learn/30-cabinet.singe @@ -0,0 +1,246 @@ +-- Lesson 30: In A Cabinet. The finished script, exactly as the lesson ends. + +dofile("Singe/Framework.singe") + +local ROUND_MS = 15000 +local MENU = { "Coins per credit", "Difficulty", "Free play", "Clear high score", "Exit service" } +local HARDNESS = { "Easy", "Normal", "Hard" } + +local coinsPerCredit = saveGet("coinsPerCredit", 1) +local difficulty = saveGet("difficulty", 2) +local freePlay = saveGet("freePlay", false) +local best = saveGet("best", 0) + +local state = "attract" +local goBackTo = "attract" +local coins = 0 +local credits = 0 +local score = 0 +local endsAt = 0 +local item = 1 +local saying = "" +local hasBezel = false + + +local function wrap(value, low, high) + if value < low then + return high + end + if value > high then + return low + end + return value +end + + +local function canStart() + return freePlay or credits > 0 +end + + +local function insertCoin() + coins = coins + 1 + while coins >= coinsPerCredit do + coins = coins - coinsPerCredit + credits = credits + 1 + end +end + + +local function startGame() + if not freePlay then + credits = credits - 1 + end + score = 0 + endsAt = singeGetTicks() + ROUND_MS + state = "play" +end + + +local function endGame() + if score > best then + best = score + saveSet("best", best) + end + saveFlush() + endsAt = singeGetTicks() + 5000 + state = "over" +end + + +local function settingOf(which) + if which == 1 then + return tostring(coinsPerCredit) + elseif which == 2 then + return HARDNESS[difficulty] + elseif which == 3 then + if freePlay then + return "on" + end + return "off" + elseif which == 4 then + return tostring(best) + end + return "" +end + + +local function adjust(step) + if item == 1 then + coinsPerCredit = wrap(coinsPerCredit + step, 1, 4) + saveSet("coinsPerCredit", coinsPerCredit) + elseif item == 2 then + difficulty = wrap(difficulty + step, 1, #HARDNESS) + saveSet("difficulty", difficulty) + elseif item == 3 then + freePlay = not freePlay + saveSet("freePlay", freePlay) + end + saveFlush() +end + + +local function serviceInput(what) + if what == SWITCH_UP then + item = wrap(item - 1, 1, #MENU) + elseif what == SWITCH_DOWN then + item = wrap(item + 1, 1, #MENU) + elseif what == SWITCH_LEFT then + adjust(-1) + elseif what == SWITCH_RIGHT then + adjust(1) + elseif what == SWITCH_BUTTON1 then + if item == 4 then + best = 0 + saveSet("best", 0) + saveFlush() + saying = "High score cleared." + elseif item == 5 then + state = goBackTo + end + elseif what == SWITCH_BUTTON2 or what == SWITCH_SERVICE then + state = goBackTo + end +end + + +function onInputPressed(what) + if what == SWITCH_QUIT then + if state == "confirm" then + singeQuit() + else + goBackTo = state + state = "confirm" + end + return + end + + if state == "confirm" then + if what == SWITCH_BUTTON2 then + state = goBackTo + end + return + end + + if state == "service" then + serviceInput(what) + return + end + + if what == SWITCH_SERVICE then + goBackTo = state + saying = "" + state = "service" + return + end + + if what == SWITCH_COIN1 then + insertCoin() + return + end + + if state == "play" then + if what == SWITCH_BUTTON1 then + score = score + 10 * difficulty + end + return + end + + if what == SWITCH_START1 and canStart() then + startGame() + end +end + + +local function drawAttract() + overlayPrint(2, 2, "ROCK DODGER") + if canStart() then + overlayPrint(2, 4, "PRESS START") + elseif (singeGetTicks() // 400) % 2 == 0 then + overlayPrint(2, 4, "INSERT COIN") + end + overlayPrint(2, 6, "High score " .. best) +end + + +local function drawService() + overlayPrint(2, 0, "SERVICE MENU") + for row, name in ipairs(MENU) do + if row == item then + overlayPrint(1, row + 1, ">") + end + overlayPrint(3, row + 1, name) + overlayPrint(24, row + 1, settingOf(row)) + end + overlayPrint(2, 8, saying) + overlayPrint(2, 10, "Singe " .. SINGE_VERSION_STRING .. " rotation " .. vldpGetRotate()) + if hasBezel then + overlayPrint(2, 11, "Bezel artwork loaded.") + else + overlayPrint(2, 11, "No bezel artwork.") + end + overlayPrint(2, 12, singeGetDataPath()) + overlayPrint(2, 16, "Up/Down choose. Left/Right change. Button 1 does it.") + overlayPrint(2, 17, "Button 2 or the service switch leaves.") +end + + +function onOverlayUpdate() + overlayClear() + + if state == "play" and singeGetTicks() >= endsAt then + endGame() + elseif state == "over" and singeGetTicks() >= endsAt then + state = "attract" + end + + if state == "service" then + drawService() + elseif state == "confirm" then + overlayPrint(2, 2, "QUIT THIS GAME?") + overlayPrint(2, 4, "Quit again to quit. Button 2 stays.") + elseif state == "play" then + overlayPrint(2, 2, "SCORE " .. score) + overlayPrint(2, 3, "TIME " .. ((endsAt - singeGetTicks()) // 1000)) + overlayPrint(2, 5, "Button 1 scores. " .. HARDNESS[difficulty] .. ".") + elseif state == "over" then + overlayPrint(2, 2, "GAME OVER") + overlayPrint(2, 4, "You scored " .. score .. ".") + overlayPrint(2, 6, "High score " .. best) + else + drawAttract() + end + + if state ~= "service" then + overlayPrint(2, 17, "CREDITS " .. credits .. " COINS " .. coins .. "/" .. coinsPerCredit) + end + + return OVERLAY_UPDATED +end + + +singeSetQuitKeyEnabled(false) +hasBezel = mainBezelLoaded() +if hasBezel then + setOverlayOnTop(true) +end diff --git a/docs/learn/art/README.txt b/docs/learn/art/README.txt new file mode 100644 index 000000000..348b056c4 --- /dev/null +++ b/docs/learn/art/README.txt @@ -0,0 +1,22 @@ +The art kit for "Learn to Program with Singe" +============================================= + +Copy this folder next to your script, keeping the name "art", and the lessons +will find these files where they say they are. + + ship.png 32 x 24 A ship pointing up. + rock.png 24 x 24 An asteroid. + shot.png 4 x 10 A bullet. + star.png 8 x 8 A small star, also used as a spark. + walk.png 96 x 32 Four frames of a walk cycle, each 24 x 32, side by side. + shoot.wav A shot. + boom.wav An explosion. + +All of it is drawn and synthesised by util/learnKit.py rather than painted, so +it is part of Singe and carries Singe's licence. Use it in your own games, +change it, or replace it with something better as soon as you can. + +The lessons also use files the engine unpacks into the Singe folder beside your +script the first time you run anything: a video (menuBackground.mkv), a font +(FreeSansBold.ttf), two models (DragonModel.glb and SingeText.glb), and a click +(click.wav). You do not have to find those either. diff --git a/docs/learn/art/boom.wav b/docs/learn/art/boom.wav new file mode 100644 index 000000000..401f6560e --- /dev/null +++ b/docs/learn/art/boom.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc1b404dc680712b0ccece8e4e5e57161163b876a9606bf4ca413dfdabf1cfc0 +size 19888 diff --git a/docs/learn/art/rock.png b/docs/learn/art/rock.png new file mode 100644 index 000000000..40a8c84f3 --- /dev/null +++ b/docs/learn/art/rock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5217f30acd442b210faed93179a1162e0c2bb932cfd0dbb094aa4ef0322309bc +size 281 diff --git a/docs/learn/art/ship.png b/docs/learn/art/ship.png new file mode 100644 index 000000000..93ad2248b --- /dev/null +++ b/docs/learn/art/ship.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef22207502f63834066bbe25a806a91b3b660a202a22d4181b592d596d502cf1 +size 297 diff --git a/docs/learn/art/shoot.wav b/docs/learn/art/shoot.wav new file mode 100644 index 000000000..1b2d38e9c --- /dev/null +++ b/docs/learn/art/shoot.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2679d3cdc6a5154fe47882384aad4be851851407ff225674a04713e4df8faa04 +size 7982 diff --git a/docs/learn/art/shot.png b/docs/learn/art/shot.png new file mode 100644 index 000000000..1e3b625ee --- /dev/null +++ b/docs/learn/art/shot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd8746ff2dacdc5f8fafde4f0b82964f9fbfe673533437e6432f9f7f92d88462 +size 98 diff --git a/docs/learn/art/star.png b/docs/learn/art/star.png new file mode 100644 index 000000000..4196af0c2 --- /dev/null +++ b/docs/learn/art/star.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c20d8f35648f190508761484b391d21900a42e288abfee49c8cedf275ddc932a +size 113 diff --git a/docs/learn/art/walk.png b/docs/learn/art/walk.png new file mode 100644 index 000000000..8294afce7 --- /dev/null +++ b/docs/learn/art/walk.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9f30c9679806fb73fac62a9b96d29f5c4405e0a589d279ae6a7357d670a04c4 +size 562 diff --git a/docs/learn/pause.rml b/docs/learn/pause.rml new file mode 100644 index 000000000..56033a0ca --- /dev/null +++ b/docs/learn/pause.rml @@ -0,0 +1,28 @@ + + + Pause + + + + +
+

Paused

+

Score 000000

+
+
+ + + +
+

Arrow keys move, Return chooses.

+
+ +
diff --git a/docs/learn/subtitles.srt b/docs/learn/subtitles.srt new file mode 100644 index 000000000..ae515dbee --- /dev/null +++ b/docs/learn/subtitles.srt @@ -0,0 +1,12 @@ +1 +00:00:01,000 --> 00:00:04,000 +They told us the cave was empty. + +2 +00:00:04,200 --> 00:00:08,000 +They were wrong about +a great many things. + +3 +00:00:08,500 --> 00:00:13,000 +Keep your light on the floor. diff --git a/docs/lessons/01-hello.adoc b/docs/lessons/01-hello.adoc new file mode 100644 index 000000000..ddf95478b --- /dev/null +++ b/docs/lessons/01-hello.adoc @@ -0,0 +1,273 @@ +== Lesson 1: Hello + +image::learn/01-hello.png[The finished lesson, 480] + +That is what you will have at the end of this lesson. It is not much to look +at. It is also a program you wrote, running on a real game engine, and +everything else in this book is built on the five lines that made it. + +=== Set Up Your Work Folder + +Make one folder to keep everything in, called `singe` or whatever you like, +and put your copy of Singe in it. This is your *work folder*, and every +lesson in this book puts a new folder inside it. You will make it once and +never think about it again. + +Inside the work folder, make a folder called `hello`, and inside that a file +called `hello.singe`. + +[literal] +---- +singe/ <- your work folder, with Singe in it + hello/ + hello.singe +---- + +That nesting is not fussiness. Singe treats the folder you run it from as the +place where games live, one folder per game, and it keeps its own files +there too. Your game goes in its own folder, one level down. From lesson two +onward each lesson is another folder beside `hello`. + +Two things about the names. The part after the dot, `.singe`, is how Singe +knows this is a script. And on most computers that are not Windows, capital +letters count: `Hello.singe` and `hello.singe` are two different files. Get +into the habit now of typing names exactly. + +Giving the folder and the script the same name matters as well, and you will +see why in a moment. + +Open `hello.singe` in your text editor. It is empty. That is correct. + +=== Type This In + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Hello!") + return OVERLAY_UPDATED +end +---- + +Save it. + +Do not worry yet about what any of it means. Two things are worth noticing +before you run it, because they will be true of every program you ever write. + +The first is that the spaces at the start of the middle three lines are there +on purpose. They do nothing at all to the program -- Singe would run it +exactly the same without them -- but they show you at a glance that those +three lines belong *inside* the thing that starts with `function` and ends +with `end`. Every programmer does this. Do it from the first day and you will +never have to learn it later. + +The second is the order of the lines. They happen top to bottom, one after +another, like the steps of a recipe. That is the single most important thing +about a program, and almost everything that confuses a beginner comes from +forgetting it. + +=== Run It + +Open a terminal, go to your *work folder* -- not the `hello` folder inside it +-- and name the folder you want to run: + +---- +Singe hello +---- + +On Windows that is `Singe.exe hello`. If your copy of Singe lives somewhere +else, type the path to it, such as `../Singe hello`. + +That is why the folder and the script share a name. Given a folder, Singe +looks inside it for a script called the same thing. Name them differently and +you get `Error: Unable to locate the game.`, which is the engine saying it +looked and found nothing it recognised. You can always be explicit instead and +write `Singe hello/hello.singe`, which does the same job. + +A black window opens with the word `Hello!` near the top left corner. Press +*Escape* or *Q* to quit. + +Two things will have happened that you did not ask for, and both are normal. +Folders called `Singe` and `data` appeared in your work folder, beside +`hello`: the first is the engine unpacking the files it needs, the second is +where anything your games save will go. Leave both alone. And the window is +mostly black, because you have not drawn anything except six letters. + +=== What Just Happened + +Now the five lines, one at a time. + +[source,lua] +---- +function onOverlayUpdate() +---- + +`function` starts a group of instructions and gives it a name. Everything from +here down to the matching `end` belongs to it. This one is called +`onOverlayUpdate`, and the empty parentheses after the name will make sense in +lesson five. + +That name is not yours to choose. Singe looks for a function with exactly that +name and calls it about sixty times a second, forever, so that you have a +chance to draw. If you spell it `onoverlayupdate` or `onOverlayUpdated`, Singe +will not find it, your window will stay black, and nothing will tell you you +made a mistake. That is the first real lesson: the computer does what you +wrote, not what you meant. + +A function that you write and the engine calls is a *callback*. You do not +call it yourself, and you cannot say when it runs; you write it, and Singe +calls you back when there is something to do. Every name in this book that +begins with `on` is a callback, and there is one for nearly everything that +can happen: a button pressed, a key typed, a sound finishing, the disc +reaching a frame. `onOverlayUpdate` is the one for drawing, and it is the only +place drawing is allowed. + +[source,lua] +---- + overlayClear() +---- + +This is a function too, but not one you have to write: Singe provides it, +along with several hundred others, and you are *calling* it. Calling a +function means saying its name followed by parentheses. This one wipes the +screen clean. + +It comes first because drawing does not replace what was there before, it +draws on top of it. Clear, then draw, every time. + +[source,lua] +---- + overlayPrint(2, 2, "Hello!") +---- + +Another call, and this one needs to be told three things, which go inside the +parentheses separated by commas. Things you hand to a function like this are +called *arguments*. + +The first two are numbers: how far across and how far down. The third is the +text to print, and it is in double quotes because that is how you tell the +computer that `Hello!` is a piece of text rather than the name of something. +Text in quotes is called a *string*, and you will use strings constantly. + +Sooner or later you will want a double quote inside a string, and the obvious +way does not work: the second quote ends the string early, and you get the +same `unfinished string` error you will meet at the end of this lesson. There +are two ways round it, and both are worth knowing. + +[source,lua] +---- + overlayPrint(2, 2, "She said \"hello\" to me.") + overlayPrint(2, 4, 'She said "hello" to me.') +---- + +The first puts a backslash in front of each quote that belongs to the text. +The backslash says "the next character is part of the string, not the end of +it", and the pair together is called an *escape*. The second uses single +quotes to mark the ends of the string, which leaves double quotes free to be +ordinary text. Lua accepts either kind of quote, as long as the string ends +with the same kind it started with. + +Both of those lines print exactly the same thing. Use whichever is easier to +read, which is usually the single quotes when the text is full of double +ones. + +The two numbers are not pixels. `overlayPrint` is the engine's own plain +text, and it counts in character cells: `(0, 0)` is the very top left corner, +`(1, 0)` is one letter to the right, and `(0, 1)` is one line down. So `(2, 2)` +is two letters in and two lines down. Other ways of drawing text do count in +pixels, and you will meet them in lesson nineteen. + +[source,lua] +---- + return OVERLAY_UPDATED +---- + +`return` hands an answer back to whoever called the function. Singe asks your +`onOverlayUpdate` one question every time it calls it: did you change +anything? `OVERLAY_UPDATED` means yes, so put it on screen. + +`OVERLAY_UPDATED` has no quotes around it because it is not text. It is a name +that Singe has already given to a value, so that you can write something you +can read instead of a number you would have to remember. + +[source,lua] +---- +end +---- + +The matching end of the `function`. Every `function` has exactly one, and +forgetting it is the single most common mistake there is. + +=== Try It + +Change one thing at a time, save, and run it again. That loop -- change, run, +look -- is the whole job. + +. *Move the text.* Change `overlayPrint(2, 2, "Hello!")` to + `overlayPrint(10, 5, "Hello!")`. Then try `(0, 0)`. Then try `(200, 2)`, and + work out why it vanished. +. *Say something else.* Put your own name in the quotes. +. *Say two things.* Add a second `overlayPrint` line under the first one, with + a different row number and different text. Nothing stops you having as many + as you like. +. *Take the clear out.* Delete the `overlayClear()` line and run it. Nothing + changes, because nothing on your screen moves yet. Put it back anyway. In + lesson two it will matter enormously, and this is a good time to see that + some mistakes wait before they bite. + +=== Break It on Purpose + +Beginners spend more time reading error messages than reading books about +programming, so let us get the first one over with now. + +Delete the closing quote after `Hello!`, so the line reads +`overlayPrint(2, 2, "Hello!)`, and run it. Singe refuses to start and says: + +---- +Error running script: hello.singe:3: unfinished string near '"Hello!)' +---- + +Read it from the left. `hello.singe` is which file. `3` is which line. The +rest is what went wrong, in this case a piece of text that was opened and +never closed. Go to line three, put the quote back, and it runs. + +Almost every error you will ever see has that shape: a file, a line, and a +complaint. The complaint is sometimes written for someone who already knows +Lua, but the file and the line are always right, and nine times in ten the +line is enough. + +=== Stop Restarting It + +You have now started Singe by hand five or six times. Stop doing that. Run it +like this instead: + +---- +Singe -R hello +---- + +`-R` tells Singe to watch your script. Save a change in your editor, and the +game restarts itself a moment later with your change in it, without closing +the window. If you save a mistake, the error is printed and the window sits +empty until you save a fix. + +Use `-R` for the rest of this book. + +=== What You Learned + +* A program is a file of instructions that happen in order, top to bottom. +* A `function` groups instructions under a name and stops at its `end`. +* `onOverlayUpdate` is a name Singe looks for and calls many times a second, + and it is where all your drawing goes. +* Calling a function means writing its name and parentheses. Anything inside + the parentheses is an argument. +* A function the engine calls for you, rather than one you call yourself, is a + callback. `onOverlayUpdate` is one. +* Text in double quotes is a string. +* Clear the screen before you draw on it. +* An error tells you the file and the line. Start there. + +=== Next Time + +Your `Hello!` sits where you put it and never moves. To make it move, you need +somewhere to keep a number that changes -- a *variable* -- and that is the +whole of lesson two. diff --git a/docs/lessons/02-numbers.adoc b/docs/lessons/02-numbers.adoc new file mode 100644 index 000000000..43404158a --- /dev/null +++ b/docs/lessons/02-numbers.adoc @@ -0,0 +1,404 @@ +== Lesson 2: Numbers That Change + +image::learn/02-numbers.png[The finished lesson, 480] + +In lesson one you put a word on the screen and it sat there. By the end of this +lesson the same word will be sliding around the window, turning round when it +reaches an edge, and it will be doing it because of four numbers that the +program changes sixty times a second. + +Along the way you will meet the single idea that trips up more beginners than +any other: where you put something decides how long it lives. + +=== Start a New Folder + +Make a folder called `numbers` next to your `hello` folder, and inside it a +file called `numbers.singe`. Type the lesson one program into it, or copy +`hello.singe` over and rename it -- either is fine, and typing it again is not +wasted. + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Hello!") + return OVERLAY_UPDATED +end +---- + +Run it with `Singe -R numbers` and leave it running. The `-R` from the +end of lesson one means you will not have to start it again for the rest of +this lesson; save the file and the window catches up on its own. + +=== Count the Frames + +Lesson one said that Singe calls `onOverlayUpdate` about sixty times a second. +That is easy to say and hard to believe, so count them. + +[source,lua] +---- +function onOverlayUpdate() + frames = 0 + frames = frames + 1 + overlayClear() + overlayPrint(2, 2, "Hello!") + overlayPrint(0, 0, "frame " .. frames) + return OVERLAY_UPDATED +end +---- + +Save it. In the top left corner you get `frame 1`, and it stays at `frame 1` +forever. + +That is disappointing, and it is also the most useful thing in this lesson, so +do not fix it yet. Look at what you wrote and think about the order of the +lines, which lesson one said was the most important thing about a program. + +Singe calls `onOverlayUpdate`. The first line sets `frames` to zero. The +second adds one to it, making one. You print it, you return, and the function +is over. Then Singe calls it again, and the first thing that happens is that +`frames` is set back to zero. + +You are not counting frames. You are counting to one, sixty times a second. + +=== Move the Line Out + +Take the `frames = 0` line out of the function and put it above, on its own, +before the word `function`. + +[source,lua] +---- +frames = 0 + + +function onOverlayUpdate() + frames = frames + 1 + overlayClear() + overlayPrint(2, 2, "Hello!") + overlayPrint(0, 0, "frame " .. frames) + return OVERLAY_UPDATED +end +---- + +Save it. The number climbs, fast, and does not stop. + +Nothing else changed. The only difference is *where* the line is, and that is +the whole of the idea. Lines inside a function happen every single time the +function is called. Lines outside every function happen once, when Singe reads +your file, before the game starts. + +`frames = 0` needed to happen once. It was happening sixty times a second, and +it was wiping out the work of the line under it. + +Every moving thing you ever write has this shape. Set the number up outside, +change it inside. + +=== What Just Happened + +Three new pieces of code are in there. Here they are on their own. + +[source,lua] +---- +frames = 0 +---- + +This makes up a name, `frames`, and puts the number zero behind it. A name +with a value behind it is a *variable*, and it is called that because the value +is allowed to vary -- to change while the program runs. + +The `=` is not the equals sign from school. It does not say that `frames` is +equal to zero; it says *put zero into `frames`*, and it always works in that +direction, right into left. Programmers call it *assignment*, and it is worth +saying the line out loud that way as you write it: "frames gets zero". + +You choose the name. It can be almost anything that starts with a letter and +has no spaces in it, and it should say what the thing is. `frames` is a good +name. `f` is a bad one, and you will not thank yourself for it in a week. + +[source,lua] +---- + frames = frames + 1 +---- + +This is the line that makes people stop and stare, because as a statement about +arithmetic it is nonsense: nothing is equal to itself plus one. + +It is not a statement about arithmetic. Remember that `=` puts the right-hand +side into the left-hand name, and read it in that order, right first. Work out +`frames + 1` using whatever is in `frames` right now. Then put the answer back +into `frames`. If `frames` held six, the right-hand side works out to seven, +and seven goes into `frames`. + +Adding one to a variable like this is something you will write thousands of +times. + +The `+` is what you expect, and so are the other three: `-` subtracts, `*` +multiplies, and `/` divides. There is no `x` key for multiply, which is why the +star does that job. + +[source,lua] +---- + overlayPrint(0, 0, "frame " .. frames) +---- + +`overlayPrint` wants text, and `frames` is a number, not text. The two dots +join the two together and hand the result over as one string: the four +characters `frame`, a space, and then however many digits the number needs. + +Those two dots are the joining operator, and it is worth knowing its real name +because the error messages use it: joining strings is called *concatenation*. + +Note the space inside the quotes, before the closing one. Without it you get +`frame6`. Lua joins exactly what you give it and adds nothing of its own. + +Lua will turn a number into text for you when you join it to a string this +way, which is why you did not have to do anything special. It will not do that +for everything. If you ever try to join something that is neither text nor a +number, Lua stops with an error, and the way round it is `tostring`, which +turns anything at all into text: + +[source,lua] +---- + overlayPrint(0, 0, "frame " .. tostring(frames)) +---- + +That line does exactly the same as the one before it. You do not need +`tostring` for numbers. You will need it in lesson three. + +=== Make It Move + +You now have everything you need to move the word. The word is drawn wherever +you say, you have a way to keep a number between frames, and you have a way to +change it. + +Give the column its own variable outside the function, and add to it inside. + +[source,lua] +---- +frames = 0 +wordX = 0 + + +function onOverlayUpdate() + frames = frames + 1 + wordX = wordX + 1 + overlayClear() + overlayPrint(wordX, 2, "Hello!") + overlayPrint(0, 0, "frame " .. frames) + return OVERLAY_UPDATED +end +---- + +Save it. The word shoots off the right-hand side and never comes back. + +It moved one column every frame, sixty times a second, so it crossed the whole +window in about a second. Slow it down by adding less than a whole column: + +[source,lua] +---- + wordX = wordX + 0.25 +---- + +Now it takes four frames to move one column, and the word creeps. `wordX` is +mostly a number like `12.75`, which is not a column at all, and Singe deals +with that by throwing away everything after the dot when it draws: `12.75` +draws in column twelve. Keeping the fraction in the variable is exactly what +lets you move slower than one column a frame. The number is finer than the +screen is. + +=== Where Is the Edge? + +The word still leaves. To turn it round you have to know where the right-hand +edge is, and you could find that out by counting: a game with no video gets a +world 720 by 480 by default, the overlay you draw on is half that, 360 by 240, +and the built-in console font's letters are six pixels wide, so there are sixty +columns. + +Do not do that. Ask the engine. + +[source,lua] +---- +lastColumn = overlayGetWidth() // CELL_WIDTH - string.len(word) +---- + +`overlayGetWidth` and `overlayGetHeight` are two more functions Singe provides, +like `overlayPrint`. They take nothing and hand back a number: how wide and how +tall the drawing surface is. You saw `return` hand a value back in lesson one, +and this is the other end of it -- a function you call can give you something, +and here you are doing arithmetic with what it gave you. + +The numbers they give you are in pixels, and `overlayPrint` counts in character +cells, so you have to divide. `overlayGetFontWidth` tells you how many pixels +wide one cell is, and `overlayGetFontHeight` how tall, so you never have to +know the answer yourself. The two slashes mean *divide and throw the fraction +away*, because a third of a character cell is not a place you can print. The +ordinary single slash would give you a fraction, and a fraction of a cell is +not a place either. + +`string.len` counts the characters in a string, and it is there so that the +word turns round when its *last* letter reaches the edge rather than its first. +Try it without and you will see six letters slide off into nothing. + +Those two capitalized names are a habit rather than a rule. Lua does not care, +but a name in capitals is how programmers say "this is a number I set once at +the top and never touch again", and it beats having a bare `6` halfway down a +file that nobody can explain a month later. + +=== Turn It Round + +Moving the other way is no work at all: instead of adding `0.25` every frame, +add `-0.25`. So keep the amount in a variable of its own and flip its sign when +the word arrives at an edge. + +[source,lua] +---- + if wordX > lastColumn then + wordX = lastColumn + speedX = -speedX + end +---- + +That is a decision, and decisions are the whole of lesson three, so take it on +trust for one lesson. Read it out: *if* `wordX` has got past `lastColumn`, +*then* do the two indented lines; otherwise skip them. The `end` closes it, the +same way `end` closes a function. + +The first line pins the word exactly on the edge, so that a fast word cannot +sail past it. The second is the flip. `-speedX` is minus whatever `speedX` +holds, so if it held `0.25` it now holds `-0.25`, and adding `-0.25` every +frame walks the word back the way it came. When it reaches the left edge, the +matching `if` flips it again, and it flips back to `0.25`. + +Two more of those for the top and bottom edges and the word bounces around the +whole window. + +=== The Whole Thing + +[source,lua] +---- +CELL_WIDTH = overlayGetFontWidth() +CELL_HEIGHT = overlayGetFontHeight() +TOP_ROW = 2 + +word = "Hello!" +wordX = 0 +wordY = TOP_ROW +speedX = 0.25 +speedY = 0.125 +frames = 0 + +lastColumn = overlayGetWidth() // CELL_WIDTH - string.len(word) +lastRow = overlayGetHeight() // CELL_HEIGHT - 1 + + +function onOverlayUpdate() + frames = frames + 1 + wordX = wordX + speedX + wordY = wordY + speedY + + if wordX < 0 then + wordX = 0 + speedX = -speedX + end + + if wordX > lastColumn then + wordX = lastColumn + speedX = -speedX + end + + if wordY < TOP_ROW then + wordY = TOP_ROW + speedY = -speedY + end + + if wordY > lastRow then + wordY = lastRow + speedY = -speedY + end + + overlayClear() + overlayPrint(0, 0, "frame " .. frames) + overlayPrint(wordX, wordY, word) + return OVERLAY_UPDATED +end +---- + +`TOP_ROW` keeps the word out of the top two lines so that it never scribbles +over the frame counter. `lastRow` has a `- 1` in it rather than a +`string.len`, because a word is many characters wide but only ever one line +tall. + +Notice that the whole top half of the file runs once and the whole bottom half +runs sixty times a second, and that you can tell which is which by looking at +the indentation. That is what the spaces were for. + +=== Try It + +. *Change the speeds.* Try `speedX = 1` and `speedY = 1`. Then try `0.05` for + both. Then make one much bigger than the other. +. *Change the word.* Put a much longer word in the quotes -- long enough to + fill a third of the window -- and watch the right-hand bounce still happen in + the right place. That is `string.len` doing its job. +. *Take the clear out.* Delete the `overlayClear()` line. Lesson one promised + this would matter one day, and this is the day: the word smears a trail + across the window and never rubs any of it out, because drawing has always + drawn on top of what was there. Put it back. +. *Stop it going sideways.* Set `speedX = 0` and run it. Work out why the word + now never leaves column zero, and why the `if` for the left edge never fires. +. *Break the frame counter on purpose.* Move the `frames = 0` line back inside + the function, above `frames = frames + 1`. Watch the counter stick at one + while the word keeps moving perfectly. Then work out why the word is + unaffected. + +=== Break It on Purpose + +Change the two dots on the frame counter line to a plus sign, so that it reads: + +[source,lua] +---- + overlayPrint(0, 0, "frame " + frames) +---- + +Save it. The window closes, Singe stops, and the terminal says: + +---- +Error executing function 'onOverlayUpdate': numbers.singe:42: attempt to add a 'string' with a 'number' +---- + +followed by several lines starting with `stack traceback:`, which you can +ignore for now. + +Read it the way lesson one taught you: the file, the line, then the complaint. +Line 42 is the line you changed. The complaint says Lua was asked to *add* a +string to a number, and adding is for numbers. `+` and `..` look similar on the +page and do entirely different jobs: `+` is arithmetic, `..` is glue. + +This one is worth meeting because of where it appeared. Lesson one's error +stopped Singe before the window ever opened, because Lua could not read the +file at all. This one is a perfectly readable line that only goes wrong when it +runs, so the window opened, the game ran, and it died on the first frame. Both +kinds tell you the file and the line. Start there either way. + +=== What You Learned + +* A variable is a name with a value behind it, and the value can change. +* `=` means "put the right-hand side into the name on the left", not "is equal + to". +* `frames = frames + 1` works out the right-hand side first, then stores it. +* `+`, `-`, `*`, and `/` do the arithmetic you expect; `//` divides and throws + the fraction away. +* `..` joins text together, and turns a number into text on the way. +* A line inside `onOverlayUpdate` runs every frame. A line outside every + function runs once, before the game starts. +* Anything that has to remember something between frames has to live outside. +* `overlayGetWidth` and `overlayGetHeight` tell you the size of the drawing + surface, so you never have to guess it. +* `overlayClear` matters the moment anything moves. + +=== Next Time + +The word moves, but it moves on its own and nothing you do makes any +difference to it. In lesson three you take the four `if` blocks you used on +trust, learn what they really say, and point them at the arrow keys instead of +the edges of the screen. The box you steer around the window at the end of it +is the player of the game you build in lesson seven. diff --git a/docs/lessons/03-decisions.adoc b/docs/lessons/03-decisions.adoc new file mode 100644 index 000000000..171cd1a28 --- /dev/null +++ b/docs/lessons/03-decisions.adoc @@ -0,0 +1,505 @@ +== Lesson 3: Making Decisions + +image::learn/03-decisions.png[The finished lesson, 480] + +Everything you have written so far does the same thing every frame. The word in +lesson two moved whether you wanted it to or not, and the only reason it ever +turned round was four blocks of code you were told to take on trust. + +This lesson is those four blocks, properly. By the end of it your program will +ask questions, act on the answers, and -- because the most interesting question +a game can ask is "what is the player doing?" -- you will be steering a box +around the window with the arrow keys. Keep that box. It is the player of the +game you build in lesson seven. + +=== Start a New Folder + +A folder called `decisions`, a file in it called `decisions.singe`, and +`Singe -R decisions` running in a terminal. Same as last time. + +=== What an `if` Really Says + +Here is the block from lesson two on its own. + +[source,lua] +---- +if wordX > lastColumn then + wordX = lastColumn +end +---- + +Four parts. The word `if`. A question. The word `then`. And a body of lines +that run only when the answer to the question is yes, closed off by `end`. + +The question is `wordX > lastColumn`, and the `>` is doing the real work. It +compares the two numbers either side of it and produces an answer that is +either *true* or *false*. Those two words are not text and are not numbers; +they are values in their own right, called *booleans*, and a boolean is the +only thing an `if` is interested in. + +The indentation is the same idea as in lesson one: the lines inside are indented +so you can see at a glance what belongs to the `if`, and the `end` lines up with +the `if` that opened it. Lua would run it without the spaces. You would not be +able to read it. + +There are six of these comparisons and you will use all of them. + +[cols="1,4"] +|=== +| `a < b` | a is less than b +| `a > b` | a is greater than b +| `a <= b` | a is less than or equal to b +| `a >= b` | a is greater than or equal to b +| `a == b` | a is the same as b +| `a ~= b` | a is not the same as b +|=== + +The last two are the ones to be careful with, for different reasons. + +`~=` means "is not equal to". Most languages write that `!=`, and if you have +seen a program before, your fingers will type `!=` and Lua will refuse it. The +squiggle is on your keyboard next to the `1` or under the `#`, depending on +where you live. + +`==` is two equals signs, and it is not a typo for one. This is the single most +common mistake in programming, and it is worth stopping on for a moment. + +In lesson two you learned that one equals sign means *put the right-hand side +into the name on the left*. That is a thing you do. It changes something. + +Two equals signs mean *are these the same?* That is a question you ask. It +changes nothing, and it hands back true or false. + +[source,lua] +---- +lives = 3 +if lives == 3 then +---- + +The first line sets `lives` to three. The second asks whether it is three. +Different jobs, different symbols, one character apart. Lua will catch you if +you get it the wrong way round, and you will meet the error it uses at the end +of this lesson. + +=== When the Answer Is No + +An `if` on its own does nothing at all when the answer is false. Very often you +want the other thing to happen instead, and that is `else`. + +[source,lua] +---- +if lives > 0 then + message = "Ready" +else + message = "Game Over" +end +---- + +Exactly one of those two bodies runs, never both, never neither, so `message` +always ends up holding one of the two strings and never nothing. + +When there are more than two possibilities, `elseif` chains them. + +[source,lua] +---- +if score >= 10000 then + rank = "ace" +elseif score >= 5000 then + rank = "pilot" +elseif score >= 1000 then + rank = "rookie" +else + rank = "passenger" +end +---- + +Lua tries the questions from the top and stops at the first one that is true. A +score of 6000 is not greater than 10000, so it tries the next, which is true, +so `rank` becomes `pilot` and the other two questions are never asked. That +last point matters: the `elseif` for 1000 is also true for a score of 6000, and +it is never reached, which is why the order is from biggest to smallest. + +The whole chain needs exactly one `end`, no matter how many `elseif` parts are +in it. It is one decision, not four. + +=== True, False, and Nothing at All + +You do not have to compare two things to get a boolean. You can keep one. + +[source,lua] +---- +paused = false +---- + +That is a variable like any other, and the value in it is the boolean `false` +itself, written without quotes because it is not the word "false", it is the +idea. The same goes for `true`. A variable holding one of them can go straight +into an `if` with no comparison at all: + +[source,lua] +---- +if paused then +---- + +Read it as "if paused". That is better English than `if paused == true`, which +does the same thing the long way round, and experienced programmers will look +at you oddly for writing it. + +Three words join and flip these answers. + +[source,lua] +---- +if alive and not shielded then +if coins > 0 or freePlay then +---- + +`and` is true when both sides are. `or` is true when either side is, or both. +`not` turns true into false and false into true. They read like English and, +unusually for programming, they mean what English means. + +There is one more value to know about, and it is the one that produces the most +confusing errors of your first year. + +[source,lua] +---- +overlayPrint(0, 0, "lives " .. tostring(lives)) +---- + +If you have never put anything into `lives`, that prints `lives nil`. Lua does +not complain about a name it has never seen; it hands you `nil`, which is its +word for *nothing here*. It is not zero and it is not an empty string. Zero is +a number you meant to store. `nil` is the absence of a value. + +You have met `nil` twice already without being told. `frames = frames + 1` +without the `frames = 0` line above it fails because you asked Lua to add one +to nothing. And a callback you spell wrong is `nil` too, which is why a +misspelled `onOverlayUpdate` gives you a black window and no error at all: +Singe looks for the name, finds nothing there, and quietly gets on with its +day. + +`tostring` is in that line because `..` will not join a boolean or a `nil` to +anything. Numbers it converts for you; these it will not. That was the promise +at the end of lesson two, and now you have a use for it. + +Finally, the rule that ties the whole section together, because `if` will +accept anything you give it, not only true and false: + +[quote] +Everything in Lua is true except `false` and `nil`. + +Zero is true. An empty string is true. Only those two are not, and once you know +it, `if movingLeft then` reads perfectly whether `movingLeft` holds `true`, +`false`, or has never been set at all. + +=== Reading the Controls + +Time to ask the player something. That takes a new callback. + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_RIGHT then + boxX = boxX + 10 + end +end +---- + +`onInputPressed` is the second callback you have written, and it works exactly +the way lesson one described: you write it, Singe calls it. `onOverlayUpdate` +is called on a timer. This one is called when something happens -- when the +player presses anything the engine recognizes. + +The difference from `onOverlayUpdate` is the word inside the parentheses. +Singe has to tell you *which* control was pressed, so it hands the function a +value, and `what` is the name you have chosen to receive it under. Lesson one +called the things you pass into a function arguments; from the inside, the name +that catches one is a *parameter*. The name is yours: call it `what`, call it +`button`, call it `k`. Singe fills it in either way. + +`SWITCH_RIGHT` is a name the engine has already given to a value, like +`OVERLAY_UPDATED` in lesson one. You do not have to load anything to use it; +Singe defines all of them before your script runs. There is one for everything +a cabinet has: +`SWITCH_LEFT`, `SWITCH_RIGHT`, `SWITCH_UP`, `SWITCH_DOWN`, `SWITCH_BUTTON1` +through `SWITCH_BUTTON4`, `SWITCH_START1`, `SWITCH_COIN1`, and more. The manual +lists them all. They are deliberately not called "the right arrow key", because +the same `SWITCH_RIGHT` arrives whether the player pushed an arrow key, a +joystick, or a control pad's thumbstick. You write the game once and it works +on all three. + +Add that callback, give `boxX` a starting value outside the functions, and draw +something at `boxX`. Press the right arrow. The box jumps ten pixels each time +you press it. + +=== It Happened, and It Is Happening + +Hold the arrow key down. Nothing more happens. + +`onInputPressed` is called once, on the way down, and not again until you let +go and press it again. There is no key repeat. That is exactly right for some +things -- firing a shot, inserting a coin, choosing a menu item -- and exactly +wrong for walking, which needs to keep happening for as long as the key is +held. + +The two are genuinely different questions. *It happened* is an event, and you +hear about it once. *It is happening* is a state, and it lasts. Singe tells you +about events, and the way you turn events into state is to remember them +yourself. + +Two callbacks, a variable between them. + +[source,lua] +---- +movingRight = false + + +function onInputPressed(what) + if what == SWITCH_RIGHT then + movingRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_RIGHT then + movingRight = false + end +end +---- + +`onInputReleased` is the third callback, and it is the mirror of the second: +Singe calls it on the way back up, with the same `SWITCH_` value. Between them +they keep `movingRight` telling the truth about what the player's finger is +doing right now. + +Nothing has moved yet, and that is the point. The pressing and the moving are +now two separate jobs. The moving goes where all the per-frame work goes. + +[source,lua] +---- +function onOverlayUpdate() + if movingRight then + boxX = boxX + BOX_SPEED + end + ... +---- + +The box now slides smoothly for as long as the key is down, at a speed you +control, and the arithmetic is back in the one function that runs sixty times a +second where it belongs. This shape -- events set flags, the frame acts on +flags -- is how nearly every game handles its controls, and you will use it +again in every lesson that has a player in it. + +=== Drawing a Box + +`overlayBox` takes two corners: the left and top of the rectangle, then the +right and bottom. + +[source,lua] +---- +colorForeground(255, 220, 0) +overlayBox(boxX, boxY, boxX + BOX_SIZE, boxY + BOX_SIZE) +---- + +It draws an *outline*, four lines one pixel thick, and not a filled rectangle. +There is no filled rectangle in the overlay drawing calls at all. When you want +a solid block later there are ways to get one, and lesson nine has the usual +one. + +Its numbers are pixels, not character cells. This catches people out, because +`overlayPrint` in lesson two counted in cells of six pixels by thirteen and +everything else in Singe counts in pixels. `overlayPrint` is the odd one; the +rest of the drawing calls -- `overlayBox`, `overlayLine`, `overlayCircle`, +`overlayEllipse`, and `overlayPlot` -- all work in the same pixels that +`overlayGetWidth` reports. + +`colorForeground` sets the colour, as three numbers from 0 to 255: how much +red, how much green, how much blue. All three at 255 is white, all at 0 is +black, and `(255, 220, 0)` is a warm yellow. Notice that it is not an argument +to `overlayBox`. It is a setting: you change it, and everything you draw after +that comes out in the new colour until you change it again. `overlayPrint` is +the exception once more -- the console font arrives with its own colours baked +in and ignores you. + +=== The Whole Thing + +[source,lua] +---- +BOX_SIZE = 20 +BOX_SPEED = 2 + +width = overlayGetWidth() +height = overlayGetHeight() +maxX = width - BOX_SIZE - 1 +maxY = height - BOX_SIZE - 1 + +boxX = width // 2 +boxY = height // 2 + +movingLeft = false +movingRight = false +movingUp = false +movingDown = false + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_UP then + movingUp = true + elseif what == SWITCH_DOWN then + movingDown = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + elseif what == SWITCH_UP then + movingUp = false + elseif what == SWITCH_DOWN then + movingDown = false + end +end + + +function onOverlayUpdate() + if movingLeft and not movingRight then + boxX = boxX - BOX_SPEED + elseif movingRight and not movingLeft then + boxX = boxX + BOX_SPEED + end + + if movingUp and not movingDown then + boxY = boxY - BOX_SPEED + elseif movingDown and not movingUp then + boxY = boxY + BOX_SPEED + end + + if boxX < 0 then + boxX = 0 + elseif boxX > maxX then + boxX = maxX + end + + if boxY < 0 then + boxY = 0 + elseif boxY > maxY then + boxY = maxY + end + + overlayClear() + + if movingLeft or movingRight or movingUp or movingDown then + colorForeground(255, 220, 0) + else + colorForeground(80, 160, 255) + end + + overlayBox(boxX, boxY, boxX + BOX_SIZE, boxY + BOX_SIZE) + overlayPrint(0, 0, "x " .. boxX .. " y " .. boxY) + return OVERLAY_UPDATED +end +---- + +Four things in there are worth a second look. + +`movingLeft and not movingRight` is there because a player can hold both arrows +at once. Without the `not`, the two would fight and the box would jitter or +drift; with it, holding both means neither wins and the box stands still, which +is what a player expects. + +`maxX` is worked out once at the top, using `overlayGetWidth` the way lesson two +did, and the `- 1` is because the surface's last pixel is one less than its +width. Take the `- 1` out and the box's right-hand edge falls off the surface +and vanishes, because Singe throws away anything you draw outside the overlay +without complaining. + +The clamping blocks use `elseif` rather than two separate `if` blocks. The box +cannot be off the left edge and off the right edge at the same time, so asking +the second question when the first was true is wasted work. + +And the colour decision is an `if` whose only job is to pick a value. Yellow +while the player is doing something, blue while they are not, which is a free +way of seeing that your input handling actually works. + +=== Try It + +. *Change the speed.* `BOX_SPEED = 1` and `BOX_SPEED = 8`. At eight, watch what + the clamping does when you shove the box into a corner. +. *Show your flags.* Add a line to `onOverlayUpdate`: + `overlayPrint(0, 1, "left " .. tostring(movingLeft))`. Hold and release the + left arrow and watch it flip. Then take the `tostring` off and read the error + Lua gives you. +. *Make it faster when a button is down.* Add a `running` flag driven by + `SWITCH_BUTTON1` in the two input callbacks, and use an `if` in + `onOverlayUpdate` to move by twice `BOX_SPEED` while it is true. +. *Mark the middle.* Draw a second, small box in the centre of the window that + changes colour when the player's box overlaps it. You will need `and` to join + four comparisons, and it is harder than it sounds. Getting it slightly wrong + is normal; lesson eleven does it properly. +. *Take the releases out.* Delete the whole `onInputReleased` function and run + it. The box takes one step and then never stops. Work out which line is now + never reached. + +=== Break It on Purpose + +Find the clamping block and take one of the equals signs out of a comparison, +so it reads: + +[source,lua] +---- + if boxX = 0 then +---- + +Save it. The window sits there empty and the terminal says: + +---- +Error running script: decisions.singe:57: 'then' expected near '=' +---- + +This is the mistake the whole `==` section was warning about, and the message +is a good example of an error that is completely accurate and completely +unhelpful if you take it literally. Lua is not asking you to add the word +`then`. Lua got as far as `if boxX`, expected a comparison or a `then` next, +and found `=`, which cannot be either. + +Whenever you see `'then' expected` after an `if`, look for a single `=` that +should be a double one. It is nearly always that. + +Note where this one appeared: Singe never opened the window, the same as lesson +one's unfinished string, because Lua could not read the file. The frame counter +error in lesson two happened while running. Errors in the shape of your code +stop you before the game starts. Errors in what your code does wait until it +runs. + +=== What You Learned + +* `if question then ... end` runs a body only when the answer is true. +* `else` gives the other case, and `elseif` chains more; the first true + question wins and the rest are never asked. +* The comparisons are `<`, `>`, `<=`, `>=`, `==`, and `~=`. +* `=` puts a value into a name. `==` asks whether two things are the same. +* `true` and `false` are values, and you can keep them in variables. +* `and`, `or`, and `not` join and flip those answers. +* `nil` is what you get from a name you never set, and it is not zero. +* Everything is true except `false` and `nil`. +* `onInputPressed` and `onInputReleased` are callbacks Singe calls with a + `SWITCH_` value when the player presses or lets go of a control. +* A press is an event and happens once; hold the state in a variable and act on + it every frame. +* `overlayBox` draws an outline in pixels, and `colorForeground` is a setting, + not an argument. + +=== Next Time + +One box is easy. A hundred stars are not, at least not the way you have written +things so far, because typing a hundred `overlayPlot` lines is nobody's idea of +a good evening. Lesson four is the loop: telling the computer to do the same +thing many times, with something different each time round, which is what makes +a screen full of anything possible. diff --git a/docs/lessons/04-repeating.adoc b/docs/lessons/04-repeating.adoc new file mode 100644 index 000000000..d394fba43 --- /dev/null +++ b/docs/lessons/04-repeating.adoc @@ -0,0 +1,395 @@ +== Lesson 4: Doing It Again + +image::learn/04-repeating.png[The finished lesson, 480] + +You have drawn one word and one box. A game needs a hundred of things: stars, +bricks, bullets, the rows of a menu. Typing a hundred lines is not the answer, +and if it were, you would have to type another hundred the moment you wanted a +hundred and one. + +This lesson is the answer. By the end of it a field of stars will be drifting +down your window, every one of them drawn by the same three lines of code. + +=== Start a New Folder + +A folder called `repeating`, a file in it called `repeating.singe`, and +`Singe -R repeating` running in a terminal. + +=== Forty Dots + +Type this in and run it. + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + colorForeground(255, 255, 255) + + for star = 1, 40 do + overlayPlot(star * 8, 100) + end + + return OVERLAY_UPDATED +end +---- + +A row of forty white dots across the middle of the window. + +`overlayPlot` is the simplest drawing call there is: it sets one pixel to the +current foreground colour, and it is exactly what a star wants. Everything +interesting here is the three lines around it. + +[source,lua] +---- + for star = 1, 40 do +---- + +That says: do what follows forty times, and each time round, `star` holds a +different number -- one the first time, two the second, forty the last. Both +ends are included, so `1, 40` really is forty times round and not thirty-nine. +This shape is a *loop*, and this particular one is a *numeric for loop*. + +`star` is a name you invent, the same way you invented `frames` in lesson two, +and the loop fills it in for you. You do not set it, and you should not change +it inside the loop. It exists only between the `for` and its `end`; ask for it +afterwards and you get `nil`. + +The `do` is required and is easy to forget. Leave it out and Lua says +`'do' expected near`, followed by whatever it found instead, which will be the +first thing on the next line. + +[source,lua] +---- + overlayPlot(star * 8, 100) +---- + +The body. This is what runs forty times, and it is the same line every time -- +what changes is `star`, so the first dot lands at x of 8, the second at 16, and +the last at 320. A loop is only useful when the body does something with the +loop's number, and the first thing you will reach for every time is arithmetic +on it. + +The body is indented one step further than the `for`, for the same reason +everything else has been indented so far. + +[source,lua] +---- + end +---- + +Closes the loop. You have now met three things that need an `end`: `function`, +`if`, and `for`. They all work the same way, they nest inside each other, and +the indentation is how you keep track of which `end` belongs to what. + +=== Counting by More Than One + +The loop counts up by one unless you tell it otherwise. Add a third number and +it counts by that instead. + +[source,lua] +---- + for x = 0, overlayGetWidth() - 1, 10 do + overlayPlot(x, 100) + end +---- + +Read it as *from, to, by*. This one goes 0, 10, 20, and so on, stopping at or +before the last pixel of the surface, which draws a dotted line all the way +across whatever size the window happens to be. It is the same row of dots as +before, described in terms of where they go rather than how many there are. + +The step can be negative, which counts down. + +[source,lua] +---- + for countdown = 10, 1, -1 do +---- + +Ten, nine, eight, down to one. With a negative step the second number is the +floor rather than the ceiling, which is what you would expect, and getting the +sign wrong is a loop that never runs at all: `for i = 10, 1 do` with no step +counts up from ten towards one, is already past the end before it starts, and +does nothing. No error, no output. If a loop of yours never seems to run, check +the direction first. + +The step can be a fraction too, though you will want that less often than you +think. + +=== One Loop Inside Another + +The body of a loop is ordinary code, and ordinary code can contain a loop. + +[source,lua] +---- + for row = 0, overlayGetHeight() - 1, 20 do + for column = 0, overlayGetWidth() - 1, 20 do + overlayPlot(column, row) + end + end +---- + +That draws a grid of dots over the whole window. Put it in your +`onOverlayUpdate` in place of the row and look at it, because the way it runs +is worth getting straight in your head now rather than in lesson eleven when +something depends on it. + +The outer loop runs once for `row` of 0. Inside that, the whole inner loop runs +from beginning to end -- eighteen columns, eighteen dots, one row of the grid. +Only when the inner loop has finished does the outer loop move on to `row` of +20 and run the entire inner loop again. + +The inner loop's body therefore runs eighteen times eighteen, which is three +hundred and twenty-four times, for eighteen trips round the outer one. That +multiplication is why nested loops are how you fill a rectangle with anything, +and also why you should glance at the numbers before you nest three of them. + +Notice that each dot's position needs both loop variables: `column` for the +across and `row` for the down. That is the pattern. If the inner body only uses +the inner variable, the inner loop is drawing the same thing over and over in +the same place, and you have written a slow way of doing it once. + +=== When You Do Not Know How Many + +A `for` loop needs to know how many times before it starts. Sometimes you do +not, and then you want the other kind. + +[source,lua] +---- + x = 0 + while x < overlayGetWidth() do + overlayPlot(x, 100) + x = x + 10 + end +---- + +A `while` loop checks a question before every trip round, exactly the question +an `if` would ask, and keeps going for as long as the answer is true. When the +answer is false it stops and carries on with the line after the `end`. If the +answer is false the very first time, the body never runs at all. + +That example draws the same dotted line as the `for` with a step of ten, and it +takes three lines to do what `for` did in one, so use `for` for that. Here is +the difference that decides it: + +* Use `for` when the number of trips is known before you start. Forty stars. + Every tenth pixel across. Every row of a grid. +* Use `while` when you are waiting for something to become true and cannot say + in advance how long it takes. Deal cards until the deck runs out. Keep asking + until the player types something valid. Step through a list until you find + what you were looking for. + +A `while` has three parts and you have to write all three yourself: set the +variable up before the loop, test it in the `while`, and change it inside the +body. The `for` loop does all three for you, which is why it is the one to +reach for when it fits. Forget the third part in a `while` and the question +never stops being true, which you will do on purpose at the end of this lesson. + +=== A Star Made Out of Its Own Number + +Now the star field, and a problem that shapes the rest of part one. + +Each star needs an x and a y of its own, and you have no way to keep a hundred +separate variables -- writing `star1X`, `star2X` and so on up to a hundred is +worse than typing the hundred lines you were avoiding. So do not store them. +Work each one out from the only thing that makes a star different from its +neighbours: its number. + +[source,lua] +---- + starX = (star * 37) % width +---- + +The `%` is the last of the arithmetic operators and it is the *remainder*: what +is left over after dividing. Fifteen divided by four is three with three left +over, so `15 % 4` is 3. Its useful property is that the answer can never be as +large as the number on the right, so a remainder by `width` is always a +position somewhere on the screen no matter how big the left-hand side gets. + +Star 1 goes to 37, star 5 to 185, star 10 to 10 -- 370 wrapped round past 360 +and came back at the left. Thirty-seven is chosen because it shares no factor +with 360, which is what stops the stars landing in neat stripes; try 36 instead +and you will see the problem immediately. + +The downward drift is the same trick with a number that grows over time. + +[source,lua] +---- +drift = 0 + + +function onOverlayUpdate() + drift = drift + 1 + ... + starY = (star * 61 + math.floor(drift * layer * 0.5)) % height +---- + +`drift` lives outside the function and climbs every frame, exactly like +`frames` in lesson two. Adding it to every star's y moves the whole field down +together, and the `%` brings a star that falls off the bottom back on at the +top, forever, for free. + +`math.floor` throws away the fraction of a number and hands back the whole part +below it: `math.floor(7.9)` is 7. Lesson two pointed out that Singe does that +for you when it draws, and it does, but doing it yourself means the number you +are working with is the number that appears on screen. That will matter in +lesson seven, when two things have to agree about whether they are touching. + +Note that `layer` in there is a second loop variable. The stars are drawn by a +loop inside a loop, and the outer one is not a row of a grid but a *distance*: +three layers of stars, each drifting at its own speed and its own brightness, +which is what makes a flat field of dots look like it has depth. + +=== Chance + +Stars twinkle. Twinkling means a brightness that is a bit different every +frame, and "a bit different" is `math.random`. + +[source,lua] +---- + shade = math.random(60, 90) + layer * 55 + colorForeground(shade, shade, shade) +---- + +`math.random(60, 90)` hands back a whole number from 60 to 90, both included, +chosen afresh every time you call it. Call it with no arguments at all and you +get a fraction between 0 and 1 instead, which is the form to use when you want +something to happen one time in five. You do not have to set the generator up; +Lua does that when Singe starts. + +Adding `layer * 55` lifts the near layers out of the dim range, so the far +stars stay faint and the near ones are almost white. Equal red, green, and +blue, as lesson three explained, is a shade of grey. + +Because `shade` is picked again on every frame for every star, each star +flickers on its own, which at sixty frames a second reads as a twinkle rather +than as a fault. + +=== The Whole Thing + +[source,lua] +---- +LAYERS = 3 +STARS_PER_LAYER = 40 +ACROSS_STEP = 37 +DOWN_STEP = 61 +LAYER_SHIFT = 13 +LAYER_SPEED = 0.5 +LAYER_BRIGHT = 55 + +width = overlayGetWidth() +height = overlayGetHeight() + +drift = 0 + + +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + + for layer = 1, LAYERS do + for star = 1, STARS_PER_LAYER do + starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + shade = math.random(60, 90) + layer * LAYER_BRIGHT + colorForeground(shade, shade, shade) + overlayPlot(starX, starY) + end + end + + return OVERLAY_UPDATED +end +---- + +A hundred and twenty stars, drawn by five lines, and the file is shorter than +the one in lesson three. + +It is also as far as this approach goes, and it is worth being honest about why +while you are looking at it. Every star in there is a formula. Nothing about a +star is *remembered*: you cannot make one brighter than its neighbour and have +it stay that way, you cannot knock one out of the sky, and you cannot have the +player collect one, because there is nowhere to write down that anything +happened to it. Star seventeen is not a thing. It is a number that briefly +passes through a variable called `star`. + +The moment you want a hundred things that each remember something of their own, +you need somewhere to put a hundred sets of facts. That is lesson six, and the +star field is why it exists. + +=== Try It + +. *More and fewer.* Try `STARS_PER_LAYER = 200`, then `4`. Then `LAYERS = 8` + and watch what `layer * LAYER_BRIGHT` does to `shade` when the layer number + gets big. The numbers `colorForeground` takes stop at 255. +. *Change the spacing.* Set `ACROSS_STEP` to 36 and run it. The stars fall into + stripes, because 36 divides into 360 exactly ten times. Put it back to 37 and + try 71 and 90. +. *Stop the twinkle.* Replace the `math.random` line with a plain + `shade = layer * 70` and compare. Decide which you prefer; there is no right + answer, and noticing that you have a preference is part of the job. +. *Make them fall upward.* One character. +. *Draw the grid as well.* Put the nested grid loop from earlier back in, after + the stars, in a dim colour. Two nested loops in one function, drawing two + different things, and the second one does not disturb the first. + +=== Break It on Purpose + +Every error so far has been a message. This one is not, and that is exactly why +you should meet it while you are expecting it. + +Put this in your `onOverlayUpdate`, run it, and read the next paragraph before +you do anything else. + +[source,lua] +---- + x = 0 + while x < width do + overlayPlot(x, 100) + end +---- + +The line that moves `x` along is missing, so `x` stays at zero, so `x < width` +never stops being true. The loop goes round for ever, plotting the same pixel, +and it is inside `onOverlayUpdate`, so `onOverlayUpdate` never returns. Singe +is waiting for your function. It cannot draw, it cannot read the keyboard, and +it cannot quit. + +What you see is a window that stops updating and stops responding. Escape does +nothing. Q does nothing. Your desktop may grey the window out or offer to force +it closed, and it may say the program is not responding, which is true. + +Go to the terminal you started Singe in and press *Ctrl* and *C* together. That +kills it. Then take the loop out and save. + +There is no message because nothing went wrong, as far as the computer is +concerned. You asked for a loop that runs while `x` is less than the width, and +that is precisely what you got. This is the one kind of mistake the error +messages cannot help you with, and the cure is a habit: every time you write a +`while`, write the line that changes the variable before you write anything +else in the body. + +=== What You Learned + +* `for name = first, last do ... end` runs its body once for each number from + first to last, both included. +* A third number is the step, and it may be negative to count down. +* The loop variable exists only inside the loop, and the body should use it. +* `for` needs a `do`, and every loop needs an `end`. +* A loop inside a loop runs the whole inner loop for each trip round the outer + one, which is how you fill a rectangle. +* `while question do ... end` repeats for as long as the question is true, and + you have to change something in the body yourself. +* Use `for` when you know how many times, and `while` when you are waiting for + something to become true. +* `%` is the remainder, and it keeps a growing number inside a range. +* `math.random(a, b)` gives a whole number from a to b; `math.floor` throws away + the fraction. +* A loop that never ends freezes the whole game with no error message, and + *Ctrl* and *C* in the terminal is how you get out. + +=== Next Time + +Look at that `onOverlayUpdate` again. It draws stars, it works out positions, it +picks colours, and it is starting to be a function that does four jobs instead +of one. In lesson five you write functions of your own, give them arguments the +way Singe's functions take them, and get answers back out of them -- and the +star field becomes one line that says `drawStars()`. diff --git a/docs/lessons/05-functions.adoc b/docs/lessons/05-functions.adoc new file mode 100644 index 000000000..573361772 --- /dev/null +++ b/docs/lessons/05-functions.adoc @@ -0,0 +1,690 @@ +== Lesson 5: Your Own Functions + +image::learn/05-functions.png[The finished lesson, 480] + +Lesson four ended with a warning about your own program: `onOverlayUpdate` +works out where a hundred and twenty stars go, decides how bright each one is, +draws them, and counts the drift, and it is one function doing four jobs. It +is about to be asked to do a fifth, because a star field with nothing in front +of it is not a game. + +In this lesson you take it apart. You will write functions of your own -- +functions Singe has never heard of and will never call -- and hand the work to +them. By the end, the whole star field is one line that says `drawStars()`, +there is a ship back on the bottom of the screen, and the program is easier to +read than it was when it did less. + +=== Start a New Folder + +A folder called `functions`, a file in it called `functions.singe`, and +`Singe -R functions` running in a terminal. + +Copy lesson four's finished program into it. This is it, unchanged, and it is +worth running once before you touch anything: + +[source,lua] +---- +LAYERS = 3 +STARS_PER_LAYER = 40 +ACROSS_STEP = 37 +DOWN_STEP = 61 +LAYER_SHIFT = 13 +LAYER_SPEED = 0.5 +LAYER_BRIGHT = 55 + +width = overlayGetWidth() +height = overlayGetHeight() + +drift = 0 + + +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + + for layer = 1, LAYERS do + for star = 1, STARS_PER_LAYER do + starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + shade = math.random(60, 90) + layer * LAYER_BRIGHT + colorForeground(shade, shade, shade) + overlayPlot(starX, starY) + end + end + + return OVERLAY_UPDATED +end +---- + +Three layers of stars drifting down the window at three speeds. Nothing in the +first half of this lesson changes what you see. Everything in it changes how +the program reads. + +=== One Job, One Name + +Start with the two lines that put a star on the screen. Above +`onOverlayUpdate`, add this: + +[source,lua] +---- +function drawStar(x, y, shade) + colorForeground(shade, shade, shade) + overlayPlot(x, y) +end +---- + +Then, in the inner loop, delete those two lines and call it instead: + +[source,lua] +---- + for star = 1, STARS_PER_LAYER do + starX = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + starY = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + shade = math.random(60, 90) + layer * LAYER_BRIGHT + drawStar(starX, starY, shade) + end +---- + +Save. The picture is the same. + +You have written functions before -- `onOverlayUpdate` is one, and so are +`onInputPressed` and `onInputReleased` from lesson three -- but those are +callbacks. You write them and Singe calls them. `drawStar` is different. Singe +has never heard of it. It runs because *you* wrote its name and parentheses, +the same way you call `overlayPlot`, and nothing in the world will run it +unless you do. + +That is the whole trick. From here on, when you find yourself thinking "and +now draw a star", you can write "draw a star" and mean it. + +=== A Parameter Is a Name, Not a Thing + +Look at the first line of `drawStar` again. + +[source,lua] +---- +function drawStar(x, y, shade) +---- + +The three names in the parentheses are *parameters*. A parameter is a name the +function uses for whatever the caller handed it. When you write +`drawStar(starX, starY, shade)`, Lua sets `x` to whatever is in `starX`, `y` +to whatever is in `starY`, `shade` to whatever is in `shade`, and then runs +the function. + +The names have nothing to do with each other. The caller's number is called +`starX` out there and `x` in here, and it would work exactly the same if you +called it `across` or `banana`. Inside the function, only the names in the +parentheses exist, and they hold copies of what came in. + +This is where the empty parentheses from lesson one finally make sense. + +[source,lua] +---- +function onOverlayUpdate() +---- + +Nothing in the parentheses means no parameters: the engine has nothing to tell +this function, so there is nothing to name. Compare it with the callback you +wrote in lesson three. + +[source,lua] +---- +function onInputPressed(what) +---- + +One parameter, called `what`. The engine has something to tell this one -- +which control was pressed -- so there is a name to catch it in. You have been +using a parameter since lesson three without knowing what it was called. + +A parameter really is the function's own copy, and it is worth proving that to +yourself once. Type this in, call it from `onOverlayUpdate`, and take it out +again afterwards: + +[source,lua] +---- +function half(number) + number = number // 2 + return number +end +---- + +Call it as `half(drift)`. The function halves its `number` and hands the +answer back, and `drift` out in `onOverlayUpdate` carries on counting as +though nothing had happened. A function cannot reach out and change the +caller's variables through a parameter. It gets a copy, and what it does to +the copy is its own business. + +=== Handing an Answer Back + +`half` did something the functions you have written so far did not: it gave an +answer. That is the last line. + +[source,lua] +---- + return number +---- + +`return` hands a value back to whoever called the function, and the call turns +into that value. Write `smaller = half(drift)` and Lua runs `half`, takes what +`half` returned, and puts it in `smaller`. You met `return` in lesson one, +where `onOverlayUpdate` returns `OVERLAY_UPDATED` to answer Singe's question +about whether anything changed. This is the same word doing the same job, +except that now you are the one asking the question. + +The brightness line is begging to be a function with an answer. Add this one: + +[source,lua] +---- +function shadeFor(layer) + return math.random(60, 90) + layer * LAYER_BRIGHT +end +---- + +The whole body is a `return`, which is normal for a small function. Now the +inner loop can hand the answer straight to `drawStar` without a name in the +middle: + +[source,lua] +---- + drawStar(starX, starY, shadeFor(layer)) +---- + +A call inside a call. Lua works the inside one out first, gets a number back +from `shadeFor`, and passes that number to `drawStar` as its third argument. +You have written something like it before without noticing: the +`math.floor(drift * layer * LAYER_SPEED)` in lesson four is a call whose +answer is used on the spot, in the middle of a bigger sum. + +=== Two Answers at Once + +The two position lines are harder, because they are two numbers worked out +together and you would have to pick one to hand back. In most languages a +function can return one value and getting two out of it is a nuisance. Lua +lets you return as many as you like. + +[source,lua] +---- +function starAt(star, layer) + local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + return x, y +end +---- + +You catch the answers by putting two names on the left, separated by a comma: + +[source,lua] +---- + local x, y = starAt(star, layer) +---- + +First value into the first name, second into the second. If you ask for fewer +names than the function returns, the extra values are thrown away: +`local x = starAt(star, layer)` gives you the across number and loses the down +one, with no complaint. If you ask for more names than there are values, the +extras are `nil`. + +Ignore the word `local` in those two blocks for one more minute. It has a +section of its own coming, and it is the important one. + +=== The Star Field in One Line + +The inner loop is now three short lines, all of them about one layer of stars. +That is a job with a name. + +[source,lua] +---- +function drawLayer(layer) + for star = 1, STARS_PER_LAYER do + local x, y = starAt(star, layer) + drawStar(x, y, shadeFor(layer)) + end +end +---- + +And the outer loop is a job with a name too: + +[source,lua] +---- +function drawStars() + for layer = 1, LAYERS do + drawLayer(layer) + end +end +---- + +Functions calling functions calling functions, three deep, and each one is +three or four lines you can hold in your head at once. `onOverlayUpdate` can +now say what a frame *is*: + +[source,lua] +---- +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + drawStars() + return OVERLAY_UPDATED +end +---- + +That is the promise lesson four made at the end, kept. + +=== Put the Ship Back + +Now the fifth job, the one that would have made a mess of the old +`onOverlayUpdate`. Bring back the box you steered in lesson three, this time +as a ship sitting near the bottom of the screen that only moves left and +right. + +It needs some values of its own, up at the top with the others, and lesson +three's two input callbacks with the up and down branches taken out: + +[source,lua] +---- +PLAYER_WIDTH = 20 +PLAYER_HEIGHT = 6 +PLAYER_SPEED = 3 +PLAYER_MARGIN = 6 + +playerX = width // 2 +playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN +movingLeft = false +movingRight = false +---- + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end +---- + +Drawing it is two lines, so it is a function: + +[source,lua] +---- +function drawPlayer() + colorForeground(80, 255, 120) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT) +end +---- + +Moving it is where the lesson pays off. In lesson three, keeping the box on +the screen took four lines of `if` and `elseif` for each direction. That idea +-- hold a number between a bottom and a top -- has a name, and the name is +*clamp*: + +[source,lua] +---- +function clamp(value, low, high) + if value < low then + return low + end + if value > high then + return high + end + return value +end +---- + +Three returns, and at most one of them ever happens, because `return` does not +only hand back an answer: it stops the function dead. Nothing after it runs. +If the value is under the bottom, hand back the bottom and stop. If it is over +the top, hand back the top and stop. Otherwise hand back what came in. + +[source,lua] +---- +function movePlayer() + if movingLeft then + playerX = playerX - PLAYER_SPEED + end + if movingRight then + playerX = playerX + PLAYER_SPEED + end + playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1) +end +---- + +A function that ends without a `return` hands back nothing, which is `nil`. +That is right for `drawPlayer` and `movePlayer`, which are called to do +something rather than to answer something. It is a bug in a function like +`clamp`: leave off that last `return value` and every position in the middle +of the range comes back as `nil`. + +=== Where Names Live + +Every name you have made since lesson two has been a *global*. A global +belongs to the whole program: `drift`, `playerX`, and `LAYERS` can be read and +written by any function in the file, at any moment, and they stay there for as +long as the game runs. + +That sounds convenient, and for six names it is. The trouble starts at sixty. +Every global is a name the whole program has agreed to reserve, so when some +other function wants somewhere to keep a position across the screen and calls +it `starX` as well, the two of them are the same box, and each writes over the +other's number. When a global holds something wrong, anything in the file +could have put it there, and finding out which means reading all of it. + +A *local* is the cure. Put the word `local` in front of a name the first time +you use it, and the name exists only inside the `function`, the loop, or the +`if` it was written in. + +[source,lua] +---- +function starAt(star, layer) + local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + return x, y +end +---- + +`x` and `y` here are nobody else's business. Another function can have its own +`x` and the two never meet. When `starAt`'s `x` is wrong, the three lines +above are the only three lines that could have done it. Without the word +`local`, those two lines would be quietly creating two more globals, which is +exactly what `starX` and `starY` have been all along. + +Now the part that surprises everyone. Type this function in, and put a line in +`onOverlayUpdate` that shows you what it hands back: + +[source,lua] +---- +function countUp() + local counted = 0 + counted = counted + 1 + return counted +end +---- + +[source,lua] +---- + overlayPrint(0, 0, "Count: " .. countUp()) +---- + +`Count: 1`, and it stays `Count: 1` while the game runs, though that function +is called sixty times a second and adds one every time. A local is made fresh +when the function starts and is gone when the function ends, so `counted` is +born as `0`, becomes `1`, and dies, sixty times a second, forever. A local +does not remember. Take the two of them out again when you have looked at it. + +That is not a flaw, it is the point: a local cannot be poisoned by the last +call, because there is nothing left of the last call. When you do need to +remember something between calls -- `drift` and `playerX` have to remember -- +the name must live outside every function. Put it at the top of the file with +`local` in front of it: + +[source,lua] +---- +local drift = 0 +local playerX = width // 2 +---- + +A `local` written at the top of the file, outside all the functions, lives as +long as the script does and can be seen by every function below it. That last +word matters: *below*. A local exists from its own line downwards, so a +function written above the line that creates it cannot see it. The same is +true of the functions themselves once you mark them `local`, which is why the +finished script is in the order it is in: the values first, then your own +functions, then the callbacks, last. + +Go through the whole file now and put `local` in front of every name you made +-- the constants in capitals, `width` and `height`, `drift`, the player's four +values, and every one of your own functions. Three names do not get it: +`onInputPressed`, `onInputReleased`, and `onOverlayUpdate` have to stay +global, because Singe finds them by name and a local name is invisible from +outside the file. + +Be honest about what locals do not fix. If you mistype a name you are +assigning to -- `movinLeft = true` instead of `movingLeft = true` -- Lua does +not complain. It makes a brand new global with your typo for a name, sets it, +and leaves the real one alone. Nothing moves, nothing errors, and you stare at +it for ten minutes. Locals will not save you from that one. Reading the name +twice will. + +=== The Whole Thing + +Here is the finished program, with every piece of the lesson in it. + +[source,lua] +---- +local LAYERS = 3 +local STARS_PER_LAYER = 40 +local ACROSS_STEP = 37 +local DOWN_STEP = 61 +local LAYER_SHIFT = 13 +local LAYER_SPEED = 0.5 +local LAYER_BRIGHT = 55 + +local PLAYER_WIDTH = 20 +local PLAYER_HEIGHT = 6 +local PLAYER_SPEED = 3 +local PLAYER_MARGIN = 6 + +local width = overlayGetWidth() +local height = overlayGetHeight() + +local drift = 0 +local playerX = width // 2 +local playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN +local movingLeft = false +local movingRight = false + + +local function clamp(value, low, high) + if value < low then + return low + end + if value > high then + return high + end + return value +end + + +local function shadeFor(layer) + return math.random(60, 90) + layer * LAYER_BRIGHT +end + + +local function starAt(star, layer) + local x = (star * ACROSS_STEP + layer * LAYER_SHIFT) % width + local y = (star * DOWN_STEP + math.floor(drift * layer * LAYER_SPEED)) % height + return x, y +end + + +local function drawStar(x, y, shade) + colorForeground(shade, shade, shade) + overlayPlot(x, y) +end + + +local function drawLayer(layer) + for star = 1, STARS_PER_LAYER do + local x, y = starAt(star, layer) + drawStar(x, y, shadeFor(layer)) + end +end + + +local function drawStars() + for layer = 1, LAYERS do + drawLayer(layer) + end +end + + +local function drawPlayer() + colorForeground(80, 255, 120) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT) +end + + +local function movePlayer() + if movingLeft then + playerX = playerX - PLAYER_SPEED + end + if movingRight then + playerX = playerX + PLAYER_SPEED + end + playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1) +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + drift = drift + 1 + overlayClear() + movePlayer() + drawStars() + drawPlayer() + return OVERLAY_UPDATED +end +---- + +Two things are worth pointing at before you move on. + +`local function drawStar(x, y, shade)` marks the function itself as local. +Your own functions are names like any other, and they belong to this file +alone. The three callbacks stay global so that Singe can find them. + +And `onOverlayUpdate` is now six lines that say what a frame is: count the +drift, clear the screen, move the player, draw the sky, draw the player, done. +It does five jobs by naming five things that do one job each, which is the +shape of every game loop you will ever write. + +=== A Function Is a Name for an Idea + +`clamp` is called once. Pulling it out saved no typing at all -- four lines +went in, one came out, and a nine line function appeared elsewhere. It was +still worth doing. + +A function is not only a way to avoid repeating yourself. It is a way to give +a name to an idea, so that the code using the idea can say the name and move +on. "Keep the player between the left edge and the right edge" is a thought +you had once. `clamp(playerX, 0, width - PLAYER_WIDTH - 1)` is that thought, +written down. Four `if` statements are the same thought taken apart into +pieces you have to put back together in your head every time you read them. + +When you are deciding whether something deserves to be a function, do not ask +how many times you will call it. Ask whether it has a name. + +=== Try It + +. *Rename a parameter.* In `drawStar`, change `x` and `y` to `across` and + `down`, everywhere inside the function. Do not touch anything outside it. + Run it. Work out why nothing broke. +. *Draw them the other way round.* Swap `drawStars()` and `drawPlayer()` in + `onOverlayUpdate` and look at the ship. Then put them back. Each of those + five lines is one job, and their order on the screen is their order in the + function. +. *Use `clamp` on a shade.* Change `shadeFor` to + `return clamp(math.random(60, 90) + layer * 90, 0, 200)` and look at the + three layers. Then take the `clamp` out and look again: `colorForeground` + was quietly holding the numbers down for you all along, and now you can see + where. +. *Call `drawStar` with two arguments.* Write `drawStar(100, 100)` somewhere + in `drawLayer` and run it. The error names the exact thing that went + missing. Read it before you put the third argument back. +. *Move a local.* Take the line `local drift = 0` and move it to the bottom of + the file, below everything. Run it, read the error, and think about the word + "below" in the section above. + +=== Break It on Purpose + +The order of a file with locals in it matters, and the error you get when you +break that order is not the one you expect. + +Take the whole `drawStar` function and move it to the *bottom* of the script, +below `onOverlayUpdate`. Everything is still there, spelled the same. Run it: + +---- +Error executing function 'onOverlayUpdate': functions.singe:50: attempt to call a nil value (global 'drawStar') +stack traceback: + functions.singe:50: in upvalue 'drawLayer' + functions.singe:57: in upvalue 'drawStars' + functions.singe:101: in function 'onOverlayUpdate' +---- + +This looks different from the error in lesson one because it is a different +kind of error. Lesson one's mistake was caught before the script ran. This one +was caught while it was running, which is why Singe names the function it was +in the middle of, and why there is a *traceback* underneath: the list of who +called whom, newest first. Line 50 is inside `drawLayer`, which was called +from line 57 inside `drawStars`, which was called from line 101 inside +`onOverlayUpdate`. Your line numbers will differ if your file does not match +the one above exactly, and the engine may add a line or two of its own below +these. The word `upvalue` is Lua's name for a local that a function borrowed +from the file around it; read past it for now. + +The complaint itself is worth learning by heart. "Attempt to call a nil value" +means you put parentheses after a name that held nothing. "Global `drawStar`" +is the part that gives the game away: you marked `drawStar` as local, so when +Lua read `drawLayer` and found no local of that name anywhere above it, the +only thing left to try was a global -- and there is no global called +`drawStar` either. Put the function back above the code that calls it and the +error goes with it. + +=== What You Learned + +* You can write functions of your own, and nothing runs them but your own + calls. +* The names inside a function's parentheses are parameters, and they are the + function's own copies of what the caller passed. +* `return` hands a value back and stops the function where it stands, and a + function that ends without one hands back `nil`. +* A call can be an argument to another call, and the inside one happens first. +* Lua can return more than one value, and you catch them with several names + separated by commas. +* `local` makes a name that exists only where it was written. +* A local inside a function is made fresh on every call and remembers nothing + from the last one. +* A local can only be seen below the line that creates it, so values and your + own functions go above the callbacks that use them. +* Callbacks stay global, because Singe finds them by name. +* A function is worth writing when the thing it does has a name, even if you + call it once. + +=== Next Time + +Your hundred and twenty stars are still a hundred and twenty copies of three +ideas. Nothing in the program is *a star*: there is a number, and some +arithmetic that turns the number into a position. You cannot say "this one is +nearer, so it falls faster and shines brighter", because there is no "this +one" to say it about, and you cannot let a star fall off the bottom and be +replaced by a new one, because there is nothing there to remove. + +Lesson six gives you the thing that holds a star, and then a way to hold a +hundred and twenty of them and add a hundred and twenty-first while the game +is running. It is the most useful lesson in this part of the book. diff --git a/docs/lessons/06-tables.adoc b/docs/lessons/06-tables.adoc new file mode 100644 index 000000000..be3e58f26 --- /dev/null +++ b/docs/lessons/06-tables.adoc @@ -0,0 +1,737 @@ +== Lesson 6: Lists of Things + +image::learn/06-tables.png[The finished lesson, 480] + +You have a hundred and twenty stars and not one of them exists. There is no +star anywhere in your program: there is a loop that runs a hundred and twenty +times, and each time round it works out two numbers from the star's number and +its layer, plots a pixel, and forgets everything. The three layers are there +to fake the one thing that arrangement cannot do, which is let a star be +different from its neighbour. + +This lesson gives you the missing thing. You will learn how to keep a list of +items, how to make one item that holds several named values at once, and then +how to put the two together into a list of items that each remember their own +position, speed, and brightness. It is the longest lesson in this part of the +book and the one everything afterwards leans on. Take your time with it. + +=== Start a New Folder + +A folder called `tables`, a file in it called `tables.singe`, and +`Singe -R tables` running in a terminal. Copy lesson five's finished +program into it: the star field, the ship, and all. + +=== A List + +Add this line at the top of `tables.singe`, under the other values, and the +three `overlayPrint` calls to `onOverlayUpdate` above the `return`. + +[source,lua] +---- +local names = { "Ada", "Grace", "Alan" } +---- + +[source,lua] +---- + overlayPrint(2, 2, names[1]) + overlayPrint(2, 3, names[3]) + overlayPrint(2, 4, "There are " .. #names .. " names.") +---- + +Run it. `Ada` on one line, `Alan` on the next, and `There are 3 names.` under +them. + +The braces on the first line make a *table*. A table is the one container Lua +has, and everything you will ever store more than one of goes in one. Here it +is holding three strings in a row, which is the shape people call a *list*. + +The brackets on the next lines take an item out. `names[1]` is the first item, +`names[3]` is the third, and the number in the brackets is called the *index*. + +That number is where Lua parts company with almost every other language you +will meet. *Lua counts from one.* C, Java, Python, JavaScript, and most of the +rest count from zero, so their first item is item zero and their third item is +index two. Lua's first item is item one, and this is the single thing that +trips up programmers arriving from somewhere else. You are arriving from +nowhere, so you have the easier job: the first one is number one. + +`names[0]` is not an error, by the way. It is `nil` -- the nothing value from +lesson three -- because there is nothing stored there. Reading an index that +was never filled always gives `nil`, which is a kindness right up until you +try to use it. + +The `#` in front of a table's name is the last new punctuation for a while. It +hands you how many items are in the list, so `#names` is `3`. Because the +count and the last index are the same number, `names[#names]` is always the +last item, however long the list gets. + +=== Adding and Taking Away + +A list you have to write out in full is not much use to a game. These two +calls are how a list changes while the program runs. + +[source,lua] +---- + table.insert(names, "Katherine") +---- + +That puts `Katherine` on the end, and `#names` is now `4`. `table.insert` is a +function like any other, and the dot in the middle of its name means it lives +inside a table called `table` that Lua provides -- a table of functions for +working on tables. You do not have to make it or load it. It is there. + +Give `table.insert` three arguments and the middle one is where to put the new +item. + +[source,lua] +---- + table.insert(names, 1, "Edsger") +---- + +`Edsger` goes in at the front, and everything that was already there shuffles +along to make room: `Ada` was item one and is now item two. Nothing is +overwritten and nothing is lost. + +Taking an item out is the mirror image. + +[source,lua] +---- + table.remove(names, 2) +---- + +Item two goes, and everything after it shuffles back down to close the gap, so +the list never has a hole in the middle. `table.remove` also hands back the +item it took out, so `local gone = table.remove(names, 2)` gets rid of it and +tells you what it was in one line. Leave the number off altogether -- +`table.remove(names)` -- and it takes the last item, which is the quickest way +to use a list as a pile. + +Put those three calls in your script, one at a time, run it after each, and +watch the count on the third line change. + +=== Walking a List + +You know one way to visit every item, because you have been using it since +lesson four: + +[source,lua] +---- + for i = 1, #names do + overlayPrint(2, i + 1, names[i]) + end +---- + +That works. Start at one, stop at the count, use the counter as the index. +There is a better way, and it is the way you will write from now on: + +[source,lua] +---- + for i, name in ipairs(names) do + overlayPrint(2, i + 1, name) + end +---- + +`ipairs` walks a list from item one until it runs out, and hands your loop two +things each time round: the index and the item itself. That second one is the +point. With the numeric `for` you get a number and have to go back to the +table with `names[i]` to find out what it is; with `ipairs` the item is +already in your hand, under whatever name you put second. + +It is also harder to get wrong. `for i = 1, #names` is a place to make an +off-by-one mistake -- start at nought and you print a `nil`, stop at +`#names - 1` and you lose the last one -- and `ipairs` has no numbers in it at +all to get wrong. + +Most of the time you do not want the index, only the item. Write the index as +a single underscore and be done with it: + +[source,lua] +---- + for _, name in ipairs(names) do +---- + +`_` is a perfectly ordinary name, and nothing in Lua treats it specially. It +is a habit programmers share for saying "something arrives here and I am not +going to use it". Anybody reading your loop knows at a glance that the index +does not matter in it. + +Delete the `names` experiments now, and the `overlayPrint` lines with them. +You know what a list is. Time to put something better than a string in one. + +=== A Table Can Also Be a Record + +A star needs to remember four things: where it is across, where it is down, +how fast it falls, and how bright it is. Four separate lists, all kept in step +by hand, would be miserable. Instead, make a table and put names inside it. + +[source,lua] +---- + local star = {} + star.x = 40 + star.y = 0 + star.speed = 2 + star.shade = 180 +---- + +The empty braces make an empty table. The four lines after it put values into +it under names instead of numbers, and the dot is how you say which name you +mean. `star.x` is read as "the `x` of `star`", and it behaves exactly like any +other variable: read it, assign to it, add to it. + +[source,lua] +---- + star.y = star.y + star.speed +---- + +A table used this way -- a fixed set of named values describing one thing -- +is a *record*, and the names inside it are its *fields*. It is the same kind +of table as the list above. The only difference is that a list's items are +found by number and a record's fields are found by name, and one table can do +both at once if you ever want it to. + +The dot is a shorthand. `star.x` and `star["x"]` mean exactly the same thing, +which is worth seeing once: the brackets take a name as happily as they take a +number, so a list and a record really are one idea wearing two hats. Use the +dot. It reads better. + +A field you never set is `nil`, the same as an index you never filled. There +is no list of allowed field names anywhere, and nothing checks your spelling, +so `star.sped` is not an error -- it is `nil`, and you will meet what happens +next at the end of this lesson. + +Writing four lines to fill in four fields gets old. You can put the fields +inside the braces when you make the table: + +[source,lua] +---- + local star = { + x = 40, + y = 0, + speed = 2, + shade = 180 + } +---- + +Same table, one statement. The commas separate the fields, the last one needs +no comma after it, and the lines are spread out only to be read easily. + +=== A List of Records + +Here is the whole idea of this lesson in two lines: + +[source,lua] +---- + local stars = {} + table.insert(stars, star) +---- + +A list can hold anything, and that includes a table. So a list of records is a +list of things, each of which remembers everything about itself. `stars[1]` is +a whole star, and `stars[1].y` is that star's distance down the screen. Nearly +every game you will ever write is a handful of lists like this one: the +enemies, the bullets, the pickups, and the falling blocks. + +There is one thing about tables you must know before you write the code, and +it catches everybody once. Handing a table to a function does not copy it. A +number gets copied -- that is why `half` could chop up its `number` in lesson +five without disturbing `drift` -- but a table is shared. Both names refer to +the same table, and a change made through one is visible through the other. + +[source,lua] +---- + local star = stars[3] + star.y = star.y + 1 +---- + +That moves the third star in the list. Not a copy of it: it. This is exactly +what you want here, and it is what makes the next section work, but remember +it when a function you wrote changes something you did not expect it to. + +=== Rebuild the Star Field + +Out goes the arithmetic. A star is about to become a thing. + +Delete `starAt`, `shadeFor`, `drawLayer`, and the old `drawStar`, and with +them the constants they used: `LAYERS`, `STARS_PER_LAYER`, `ACROSS_STEP`, +`DOWN_STEP`, `LAYER_SHIFT`, `LAYER_SPEED`, and `drift`. The `drift` line at +the top of `onOverlayUpdate` goes as well. `LAYER_BRIGHT` stays, under a name +that no longer mentions layers, and three values join it: + +[source,lua] +---- +local STAR_COUNT = 120 +local STAR_SHADE = 55 +local BURST_COUNT = 10 +---- + +[source,lua] +---- +local stars = {} +---- + +A hundred and twenty is what you had: three layers of forty. The list starts +empty, and this is what fills it: + +[source,lua] +---- +local function newStar(y) + local speed = math.random(1, 3) + local star = { + x = math.random(0, width - 1), + y = y, + speed = speed, + shade = math.random(60, 90) + speed * STAR_SHADE + } + return star +end +---- + +`newStar` makes one star and hands it back. Look at the last field and compare +it with `shadeFor` in lesson five: the same sum, with the star's own speed +where the layer number used to be. The layers have not gone away so much as +dissolved. Every star now picks its own speed, one, two, or three pixels a +frame, and a star that falls faster is brighter, which is the whole of the +illusion of depth and costs one line. + +The starting `y` is a parameter because the two callers want different things. +The stars made when the game starts should be scattered all over the screen; +the ones made later come in at the top. + +[source,lua] +---- +local function makeStars() + for number = 1, STAR_COUNT do + table.insert(stars, newStar(math.random(0, height - 1))) + end +end +---- + +Nothing calls `makeStars` yet. Add one line at the very bottom of the file, +after everything else and inside no function at all: + +[source,lua] +---- +makeStars() +---- + +There is more to say about that line, and it waits until the end of the +lesson. Now the drawing, which gets shorter every time you touch it: + +[source,lua] +---- +local function drawStar(star) + colorForeground(star.shade, star.shade, star.shade) + overlayPlot(star.x, star.y) +end + + +local function drawStars() + for _, star in ipairs(stars) do + drawStar(star) + end +end +---- + +`drawStar` took three numbers in lesson five and takes one star now. Everything +it needs to know is inside the star, which is the reason to have records at +all: one thing to pass around instead of four, and no chance of handing them +over in the wrong order. And `drawStars` has lost its loop over layers, along +with any interest in how many stars there are or where they came from. + +=== Stars That Come and Go + +Now the part the old star field could not do at all. Stars fall at their own +speeds, leave at the bottom, and are replaced at the top. + +[source,lua] +---- +local function moveStars() + for i = #stars, 1, -1 do + local star = stars[i] + star.y = star.y + star.speed + if star.y >= height then + table.remove(stars, i) + end + end + while #stars < STAR_COUNT do + table.insert(stars, newStar(0)) + end +end +---- + +Call it from `onOverlayUpdate`, on the line where `drift = drift + 1` used to +be. + +Each star moves down by its own `speed`, which is the line lesson five could +not write. The `while` at the bottom is the refill: while there are fewer than +a hundred and twenty stars, make another one at the top of the screen. It runs +as many times as it needs to and then stops, which is the right tool when you +do not know how many are missing. + +The `for` line at the top counts down, using the step you met in lesson four. +`for i = #stars, 1, -1` visits the last item first and works back to item one. +That is not a flourish. It is the only safe way to do what this loop does, and +the next section is why. + +=== Never Remove Going Forwards + +Write the same loop forwards and it is broken: + +[source,lua] +---- + for i = 1, #stars do + local star = stars[i] + star.y = star.y + star.speed + if star.y >= height then + table.remove(stars, i) + end + end +---- + +Follow it by hand with four stars, and suppose stars two and three have both +reached the bottom. + +* `i` is `1`. Star one is fine. +* `i` is `2`. Star two is off the bottom, so out it goes. Everything shuffles + down: the star that was three is now two, and the star that was four is now + three. +* `i` is `3`. That is the star that used to be four. *Star three was never + looked at.* Removing item two moved it down into slot two, and the loop had + already been to slot two and gone. +* `i` is `4`. There is no item four any more. `stars[4]` is `nil`. + +Two separate faults from one small mistake. Items get skipped, so a star that +had reached the bottom is not even looked at this time round. A star surviving +one extra frame does no harm, and the same loop over a list of enemies, where +being looked at is what kills them, is a bug you would chase for an hour. The +second fault is louder. The list is now shorter than the count the loop +started with, because `#stars` was worked out once, before any removal, so the +last turns of the loop reach past the end of the list: + +---- +Error executing function 'onOverlayUpdate': tables.singe:60: attempt to index a nil value (local 'star') +stack traceback: + tables.singe:60: in upvalue 'moveStars' + tables.singe:124: in function 'onOverlayUpdate' +---- + +"Attempt to index a nil value" means you put a dot after something that held +nothing. The `(local 'star')` on the end names it: `star` was `nil`, so +`star.y` had nowhere to look. + +Going backwards fixes both faults at once, and it is worth seeing why rather +than taking it on trust. When you remove item `i`, everything after `i` moves +down a slot -- and going backwards, everything after `i` is everything you +have already visited. What is still to come, items `i - 1` down to `1`, has +not moved at all. The list shrinking behind you cannot hurt you either, +because you are walking towards item one and item one is always there. + +The rule, which you will use for the rest of your life: *when a loop might +remove items from the list it is walking, walk it backwards.* + +`ipairs` is not an exception to this. Removing items in the middle of an +`ipairs` loop goes wrong in the same way. `ipairs` is for looking, and the +backwards `for` is for changing. + +=== Add Some Yourself + +One more branch in `onInputPressed` and you can watch the list grow while the +game runs. This goes with the other two: + +[source,lua] +---- + elseif what == SWITCH_BUTTON1 then + addBurst() +---- + +[source,lua] +---- +local function addBurst() + for number = 1, BURST_COUNT do + table.insert(stars, newStar(0)) + end +end +---- + +`addBurst` goes above the callbacks with your other functions. And so that you +can see the list's length change, put the count on screen in +`onOverlayUpdate`: + +[source,lua] +---- + overlayPrint(0, 0, "Stars: " .. #stars) +---- + +Press the button -- the space bar, unless you have changed what +`SWITCH_BUTTON1` comes from -- a dozen times. The count goes up by ten each +press and the sky thickens, and there is no repeat while you hold it down: +`onInputPressed` is told once, when the button goes down. Then watch the count +come back to a hundred and twenty on its own as the extra stars reach the +bottom and are removed, because the refill only ever tops the list up to +`STAR_COUNT` and never trims it. + +Nothing in the drawing, the moving, or the removing had to be told that the +number of stars had changed. They all work from the list. + +=== The Whole Thing + +[source,lua] +---- +local STAR_COUNT = 120 +local STAR_SHADE = 55 +local BURST_COUNT = 10 + +local PLAYER_WIDTH = 20 +local PLAYER_HEIGHT = 6 +local PLAYER_SPEED = 3 +local PLAYER_MARGIN = 6 + +local width = overlayGetWidth() +local height = overlayGetHeight() + +local stars = {} +local playerX = width // 2 +local playerY = height - PLAYER_HEIGHT - PLAYER_MARGIN +local movingLeft = false +local movingRight = false + + +local function clamp(value, low, high) + if value < low then + return low + end + if value > high then + return high + end + return value +end + + +local function newStar(y) + local speed = math.random(1, 3) + local star = { + x = math.random(0, width - 1), + y = y, + speed = speed, + shade = math.random(60, 90) + speed * STAR_SHADE + } + return star +end + + +local function makeStars() + for number = 1, STAR_COUNT do + table.insert(stars, newStar(math.random(0, height - 1))) + end +end + + +local function addBurst() + for number = 1, BURST_COUNT do + table.insert(stars, newStar(0)) + end +end + + +local function moveStars() + for i = #stars, 1, -1 do + local star = stars[i] + star.y = star.y + star.speed + if star.y >= height then + table.remove(stars, i) + end + end + while #stars < STAR_COUNT do + table.insert(stars, newStar(0)) + end +end + + +local function drawStar(star) + colorForeground(star.shade, star.shade, star.shade) + overlayPlot(star.x, star.y) +end + + +local function drawStars() + for _, star in ipairs(stars) do + drawStar(star) + end +end + + +local function drawPlayer() + colorForeground(80, 255, 120) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH, playerY + PLAYER_HEIGHT) +end + + +local function movePlayer() + if movingLeft then + playerX = playerX - PLAYER_SPEED + end + if movingRight then + playerX = playerX + PLAYER_SPEED + end + playerX = clamp(playerX, 0, width - PLAYER_WIDTH - 1) +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + addBurst() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + movingLeft = false + elseif what == SWITCH_RIGHT then + movingRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + movePlayer() + moveStars() + drawStars() + drawPlayer() + overlayPrint(0, 0, "Stars: " .. #stars) + return OVERLAY_UPDATED +end + + +makeStars() +---- + +=== What Just Happened + +The program is shorter than lesson five's and it does something lesson five +could not begin to do. Three pieces are worth a second look. + +[source,lua] +---- +makeStars() +---- + +That single line at the very bottom of the file is not inside any function, +and it is easy to miss. Lua reads your file from top to bottom when Singe +loads it, and a line at the outer level of the file runs there and then, once, +before the engine has called anything. Everything above it is definitions -- +here is a value, here is a function -- and this is the one instruction that +happens at load time. It has to be at the bottom, because `makeStars` does not +exist until the line that creates it has been read. + +[source,lua] +---- + local star = stars[i] + star.y = star.y + star.speed +---- + +`star` is a local made fresh on every turn of the loop, and it holds the same +table that is sitting in the list -- not a copy of it. Changing `star.y` moves +the star that is in the list. If tables were copied, this loop would move a +hundred and twenty copies a frame and nothing on screen would ever change. + +[source,lua] +---- + for _, star in ipairs(stars) do + drawStar(star) + end +---- + +Compare that with lesson four: two nested loops, two pieces of arithmetic per +star, a remainder to keep each one on the screen, and three layers to fake the +variety you now get for nothing. The drawing no longer knows or cares how the +stars got where they are, how many there are, or what happens to them next. +Each part of the program does one job, and the list is what they all agree +about. + +=== Try It + +. *Make it deeper.* Change `math.random(1, 3)` to `math.random(1, 6)` and + `STAR_SHADE` to `25`. More speeds, more shades, a deeper sky. +. *Fewer stars, bigger burst.* Set `STAR_COUNT` to `10` and `BURST_COUNT` to + `100`, then press the button three or four times. Watch the count on screen + climb, and time how long it takes to drain. +. *Give a star another field.* Add `age = 0` to `newStar`, add one to + `star.age` in `moveStars`, and put `stars[1].age` on screen next to the + count. Nothing else in the program needs changing to carry a new fact about + every star. +. *Stop removing them.* Delete the three lines of the `if star.y >= height` + block from `moveStars` and run it. Wait ten seconds, then read the count on + screen and look at the sky. Work out how both of those can be true at once. +. *Do it forwards.* Change `for i = #stars, 1, -1` to `for i = 1, #stars` and + run it until it falls over. Then read the section above again with the real + error in front of you. + +=== Break It on Purpose + +Misspell a field name. In `moveStars`, change `star.speed` to `star.sped`: + +[source,lua] +---- + star.y = star.y + star.sped +---- + +Run it: + +---- +Error executing function 'onOverlayUpdate': tables.singe:60: attempt to perform arithmetic on a nil value (field 'sped') +stack traceback: + tables.singe:60: in upvalue 'moveStars' + tables.singe:124: in function 'onOverlayUpdate' +---- + +Your line numbers will not be exactly these unless your file matches the one +above line for line. The name of the file, the number after it, and the +complaint are what you read. + +Read the end of the first line first. `field 'sped'` is Lua telling you which +name came up empty, and it is telling you it was a field -- something after a +dot -- rather than a variable of your own. "Attempt to perform arithmetic on a +nil value" is what it was doing when it found out: adding `nil` to a number, +which cannot be done. + +There was no complaint when the table was made without a `sped` field, and +none at the moment you asked for it. A table hands out `nil` for any name it +does not have, without comment. The error comes later, at the first line that +tries to *use* the nothing you were given, which may be a long way from the +typo. When you see `nil value (field 'something')`, look for the place that +name was spelled differently. + +=== What You Learned + +* Braces make a table, the one container Lua has. A table used as a list holds + items in a row, and brackets with an index take one out. +* Lua counts from one. Most other languages count from zero. +* `#list` is how many items are in it, and `list[#list]` is the last one. +* `table.insert` adds an item, on the end or at a position you name; + `table.remove` takes one out and closes the gap. +* An index or a field that was never filled is `nil`, and nothing warns you + until you use it. +* `ipairs` walks a list and hands you the index and the item; `_` is the habit + for an index you do not want. +* A table used as a record holds named fields, reached with a dot. +* A list of records is how a game holds its enemies, its bullets, and + everything else there is more than one of. +* Tables are shared, not copied, when you pass them around. +* When a loop may remove items from the list it is walking, walk it backwards. + +=== Next Time + +You have every piece of a game now: things that move, a ship that answers the +controls, decisions, loops, functions, and a list that things can join and +leave while the game is running. + +Lesson seven puts them together into a game you can lose. You steer along the +bottom of the screen, blocks fall from the top, and you stay out of their way +-- and the falling blocks are a list of records, made, moved, and removed +exactly as your stars are. diff --git a/docs/lessons/07-a-game.adoc b/docs/lessons/07-a-game.adoc new file mode 100644 index 000000000..6c76452f9 --- /dev/null +++ b/docs/lessons/07-a-game.adoc @@ -0,0 +1,840 @@ +== Lesson 7: A Game + +image::learn/07-a-game.png[The finished lesson, 480] + +Six lessons ago you put one word on a black screen. Since then you have +learned every idea a small game is made of: variables, decisions, loops, +functions of your own, and lists of things. This lesson does not teach a new +idea. It spends the ones you have. + +You are going to build a game called Dodge. Blocks fall out of the sky, you +slide a bar along the bottom to get out of their way, and every block that +misses you is a point. Hit three of them and it is over. You will build it in +six stages, and every one of them runs: you never have to type a hundred +lines and hope. + +=== Make the Folder + +Make a folder called `dodge`, and in it a file called `dodge.singe`. Run it +the way you have been running everything since lesson one: + +---- +Singe -R dodge +---- + +Leave that running in a terminal for the whole lesson. Every time you save, +the game restarts with your change in it. + +=== Stage One: Something You Can Steer + +Type this in, save, and steer it with the left and right arrow keys. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local PLAYER_WIDTH = 40 +local PLAYER_HEIGHT = 8 +local PLAYER_SPEED = 4 +local PLAYER_MARGIN = 4 + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN + +local playerX = (screenWidth - PLAYER_WIDTH) / 2 +local goingLeft = false +local goingRight = false + + +function onInputPressed(what) + if what == SWITCH_LEFT then + goingLeft = true + elseif what == SWITCH_RIGHT then + goingRight = true + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goingLeft = false + elseif what == SWITCH_RIGHT then + goingRight = false + end +end + + +function onOverlayUpdate() + if goingLeft then + playerX = playerX - PLAYER_SPEED + end + if goingRight then + playerX = playerX + PLAYER_SPEED + end + if playerX < 0 then + playerX = 0 + elseif playerX > screenWidth - PLAYER_WIDTH then + playerX = screenWidth - PLAYER_WIDTH + end + + overlayClear() + colorForeground(80, 255, 160) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) + return OVERLAY_UPDATED +end +---- + +Fifty lines, and almost none of it is new. Two things in it are, so take the +first line first. + +[source,lua] +---- +dofile("Singe/Framework.singe") +---- + +`dofile` belongs to Lua rather than to Singe, and it means "go and run that +file, then carry on here". The file it runs is `Singe/Framework.singe`, one of +the files the engine unpacked into your work folder the first time you ran +anything, back in lesson one. Open it in your text editor and look: it is +ordinary Lua, no different in kind from what you have been writing since +lesson two, and there is nothing magic in it. + +Running it hands your script a set of names it can then use. The ones this +book reaches for are these: + +* `DIR`, which holds the folder your own script is sitting in. Lesson nine + needs it the moment a game loads a picture, and it is the real reason this + line is here. +* `SCANCODE` and `MODIFIER`, names for every key on a keyboard, for a game + that reads typing rather than a joystick. +* `GAMEPAD_0` to `GAMEPAD_3` and `MOUSE_0` to `MOUSE_3`, for a cabinet with + more than one of something plugged into it. +* A few helpers whose names begin with `util`, and the six calls that post a + score to a leaderboard, which is lesson twenty-eight. +* The second way of writing a game altogether, which is lesson fifteen. + +One thing it does *not* give you, because it is the natural thing to assume. +`SWITCH_LEFT`, and every other name beginning with `SWITCH_`, comes from the +engine itself, before any script runs at all. That is why lessons three to six +could read the arrow keys without loading anything. The framework adds to what +is already there; it does not provide it. + +This particular game uses nothing from the framework. The line is here because +every game from lesson nine to the end of the book does need it, and because +it is how nearly every real Singe game starts. It is read once, at startup, +and then forgotten about. + +The second new thing is the way the arrow keys are handled, because it is the +first thing beginners get wrong. + +`onInputPressed` does not move the player. It sets `goingLeft` to `true`, and +`onInputReleased` sets it back to `false`. The moving happens in +`onOverlayUpdate`, which asks "is the player holding left?" sixty times a +second. If you moved the player inside `onInputPressed` instead, you would get +one step per press: Singe sends a press exactly once when the key goes down +and never repeats it, which the manual's entry for `onInputPressed` says +plainly. Holding the key would do nothing at all. + +So the press handler records what is true about the world, and the update +decides what to do about it. Keep that split and your input code will stay +five lines long forever. + +The three names in capitals are ordinary variables. Writing `PLAYER_SPEED` in +capitals is a message to yourself and to anyone reading: this one is set once +at the top and never changes. Nothing in Lua enforces it. It matters because +in a minute there will be ten of them, and when the game is too hard you want +one obvious place to go and turn a number down. + +`screenWidth` and `screenHeight` come from the engine rather than being typed +in, so the game lays itself out to whatever size the overlay happens to be. +`playerY` is worked out once from the height: the bar sits `PLAYER_MARGIN` +pixels off the bottom and never moves up or down, so there is no reason to +compute it again every frame. + +The `- 1` in the `overlayBox` line is not a typo. `overlayBox` draws through +both corners you give it, so a box from `playerX` to `playerX + 40` is +forty-one pixels wide. Subtracting one makes the bar you see exactly forty +wide, which will matter in stage four when something has to decide whether it +touched you. + +=== Stage Two: Something to Dodge + +One block, falling. Add two constants to the list at the top: + +[source,lua] +---- +local BLOCK_SIZE = 12 +local BLOCK_SPEED = 1.5 +---- + +Two more variables under `goingRight`: + +[source,lua] +---- +local blockX = math.random(0, screenWidth - BLOCK_SIZE) +local blockY = -BLOCK_SIZE +---- + +In `onOverlayUpdate`, just above the `overlayClear()` line, make it fall: + +[source,lua] +---- + blockY = blockY + BLOCK_SPEED + if blockY > screenHeight then + blockX = math.random(0, screenWidth - BLOCK_SIZE) + blockY = -BLOCK_SIZE + end +---- + +And under the line that draws the player, draw it: + +[source,lua] +---- + colorForeground(255, 90, 90) + overlayBox(blockX, blockY, blockX + BLOCK_SIZE - 1, blockY + BLOCK_SIZE - 1) +---- + +Save. A red square falls, reaches the bottom, and reappears at the top in a +new place. + +It starts at `-BLOCK_SIZE`, which is above the top edge of the screen. There +is nothing wrong with a negative position: the engine draws the part of the +box that is on the overlay and quietly throws away the part that is not. The +block slides into view instead of popping into existence. + +`BLOCK_SPEED` is `1.5`, not a whole number, so `blockY` spends most of its +life being something like `83.5`. That is fine too. Drawing chops the fraction +off, and keeping it in the variable is what lets the block move slower than +one pixel a frame. + +=== Stage Three: A Sky Full of Them + +One block is not a game. In lesson six you learned to keep many of something +in a list, and this is what it was for. + +Delete `blockX` and `blockY`, and put these in their place: + +[source,lua] +---- +local blocks = {} +local spawnTimer = 0 +---- + +Add one more constant at the top: + +[source,lua] +---- +local SPAWN_FRAMES = 40 +---- + +Now a function that makes one block and puts it in the list. Put it above +`onInputPressed`: + +[source,lua] +---- +function spawnBlock() + local block = {} + block.x = math.random(0, screenWidth - BLOCK_SIZE) + block.y = -BLOCK_SIZE + table.insert(blocks, block) +end +---- + +Replace the falling code from stage two with this: + +[source,lua] +---- + spawnTimer = spawnTimer - 1 + if spawnTimer <= 0 then + spawnBlock() + spawnTimer = SPAWN_FRAMES + end + + for i = #blocks, 1, -1 do + local block = blocks[i] + block.y = block.y + BLOCK_SPEED + if block.y > screenHeight then + table.remove(blocks, i) + end + end +---- + +And replace the one line that drew the block with a loop over all of them: + +[source,lua] +---- + for i, block in ipairs(blocks) do + overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) + end +---- + +Save. Blocks rain down. + +A block is a record: one little table with an `x` and a `y` in it, which is +how you keep two numbers that belong together from drifting apart. `blocks` is +a list of those records, which is the shape almost every game's world has. + +`spawnTimer` counts down one per frame, and when it reaches zero a block is +born and the timer is wound back up. Forty frames is about two thirds of a +second. There is no clock in this game and there does not need to be one: +`onOverlayUpdate` is the heartbeat, and counting heartbeats is a perfectly +good way to measure time. + +The falling loop runs *backwards* -- from `#blocks` down to `1`, a step at a +time -- because it removes things as it goes. That is the rule from lesson +six, and this is the first time you have had a real reason to obey it. Remove +item three while walking forwards and everything shuffles down one, so the +block that was item four is now item three and the loop never looks at it. +Walking backwards, everything that moves has already been dealt with. + +The drawing loop uses `ipairs` instead, because it removes nothing and only +wants each block in turn. + +=== Stage Four: Getting Hit + +The blocks fall straight through you. Time to notice. + +The engine has a function for exactly this question. Change the test inside +the falling loop from this: + +[source,lua] +---- + if block.y > screenHeight then + table.remove(blocks, i) + end +---- + +to this: + +[source,lua] +---- + if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) or block.y > screenHeight then + table.remove(blocks, i) + end +---- + +Save, and drive the bar into a falling block. It vanishes. + +`collideRects` takes two rectangles and answers `true` if they overlap. Each +rectangle is four numbers: a corner, then a width and a height -- *not* two +corners, which is the other reasonable way to describe a rectangle and the way +`overlayBox` does it. The manual's entry for `collideRects` gives the order, +and it is worth looking at now, because getting the eight arguments in the +wrong order is the kind of mistake that produces no error at all, just a game +where nothing ever hits anything. + +You could write this yourself. It is four comparisons, and it is genuinely not +hard. Use the engine's anyway, for three reasons. It is right, including the +awkward cases: a rectangle with no width, two rectangles that only touch along +an edge. It says what it means, so the line reads as "did these two overlap" +instead of four `and`s you have to decode every time you come back to it. And +when you reach lesson eleven, where things hit each other constantly, you will +already know the family: `collidePointRect` for a mouse click, `collideCircles` +for two round things, and `collideSegments` for a shot against a wall. The manual +lists them together. + +The one thing the engine cannot do for you is decide what a rectangle *means*. +The block's rectangle here is the whole block. In a real game you often want +the hit rectangle to be a little smaller than the picture, so a near miss +feels like a near miss. That is a design decision, and lesson eleven is where +you make it. + +=== Stage Five: Score and Lives + +Getting hit should cost something, and surviving should be worth something. + +Three more constants at the top: + +[source,lua] +---- +local BLOCK_FASTER = 0.05 +local START_LIVES = 3 +---- + +Three more variables, next to `spawnTimer`: + +[source,lua] +---- +local blockSpeed = BLOCK_SPEED +local score = 0 +local lives = START_LIVES +---- + +`BLOCK_SPEED` is now the speed the game *starts* at, and `blockSpeed` is the +speed it is going at right now. Change the falling line to use the variable: + +[source,lua] +---- + block.y = block.y + blockSpeed +---- + +Then split the two cases apart, because they now do different things: + +[source,lua] +---- + if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then + table.remove(blocks, i) + lives = lives - 1 + elseif block.y > screenHeight then + table.remove(blocks, i) + score = score + 1 + blockSpeed = blockSpeed + BLOCK_FASTER + end +---- + +And show the numbers. Put this just before the `return`: + +[source,lua] +---- + overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) +---- + +Save, and play it for a minute. It gets harder, which is the whole trick: +every block you dodge makes the next ones fall a twentieth of a pixel per +frame faster. Twenty points in and they are noticeably quicker. You did not +have to write a single word about difficulty levels. + +You will also watch `LIVES` go to `-4`, because nothing stops the game yet. +That is the next stage, and it is the one that matters most. + +=== Stage Six: Game Over, and Going Again + +Here is the trap. The obvious way to add a game over screen is a variable +called `gameOver`, set to `true` when the lives run out. Then you want a title +screen before the first game, so you add `started`. Then you want a pause key, +so you add `paused`. Now you have three true-or-false variables, eight +combinations between them, and five of those combinations are nonsense that +your code has to be careful never to produce. Every beginner writes this game +at least once, and it is where small games go to die. + +Do this instead. One variable holds *what the game is doing*, as a word: + +[source,lua] +---- +local state = "waiting" +---- + +It is `"waiting"` before the first game, `"playing"` during one, and `"over"` +when the lives are gone. Three states, one variable, and no such thing as an +impossible combination. There is nothing magic about the strings; they are +words you chose, and you must spell them the same way every time. + +Now the function that begins a game. Every variable a game owns goes back to +its starting value here, in one place: + +[source,lua] +---- +function startGame() + playerX = (screenWidth - PLAYER_WIDTH) / 2 + blocks = {} + blockSpeed = BLOCK_SPEED + spawnTimer = 0 + score = 0 + lives = START_LIVES + state = "playing" +end +---- + +Losing the last life ends it. Inside the collision branch, under +`lives = lives - 1`: + +[source,lua] +---- + if lives == 0 then + state = "over" + end +---- + +The space bar starts a game, but only when one is not already running. Add an +arm to `onInputPressed`: + +[source,lua] +---- + elseif what == SWITCH_BUTTON1 and state ~= "playing" then + startGame() +---- + +`~=` is "is not equal to". `state ~= "playing"` is true while waiting and true +while over, which are exactly the two moments when space should start a game. +`SWITCH_BUTTON1` is the space bar by default, and also the A button on a +controller. + +Two small functions put the words on screen. `printCentered` does the +arithmetic once so that the three screens do not each do it badly: + +[source,lua] +---- +function drawOverText() + printCentered(7, "GAME OVER") + printCentered(9, "PRESS SPACE TO PLAY AGAIN") +end + + +function drawWaitingText() + printCentered(6, "DODGE THE BLOCKS") + printCentered(8, "ARROW KEYS TO MOVE") + printCentered(10, "PRESS SPACE TO START") +end + + +function printCentered(row, text) + overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) +end +---- + +with one more constant at the top: + +[source,lua] +---- +local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth() +---- + +Finally, the important part. Move everything that draws out of +`onOverlayUpdate` into a function called `drawGame`, move everything that +moves into a function called `updatePlaying` -- both of them are in the full +listing below -- and leave `onOverlayUpdate` looking like this: + +[source,lua] +---- +function onOverlayUpdate() + if state == "playing" then + updatePlaying() + end + + overlayClear() + drawGame() + if state == "waiting" then + drawWaitingText() + elseif state == "over" then + drawOverText() + end + + return OVERLAY_UPDATED +end +---- + +Save. You have a game: a title screen, a game, a game over, and a way back +round. + +Read those thirteen lines again, because they are the shape of every game you +will ever write. The world only moves while the state is `"playing"`, so the +game over screen freezes with the last blocks hanging exactly where they were. +The world is drawn every frame whatever the state, because the picture is the +same in all three; the state decides only what words go on top of it. And +there is precisely one place that makes that decision. + +That is what you bought with the `state` variable. When you add a pause, you +add one arm to that `if` and one line to `onInputPressed`, and nothing else in +the game needs to know a thing about it. + +=== The Whole Thing + +Here it is, finished. If yours does not match, the difference is worth +finding. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local PLAYER_WIDTH = 40 +local PLAYER_HEIGHT = 8 +local PLAYER_SPEED = 4 +local PLAYER_MARGIN = 4 +local BLOCK_SIZE = 12 +local BLOCK_SPEED = 1.5 +local BLOCK_FASTER = 0.05 +local SPAWN_FRAMES = 40 +local START_LIVES = 3 +local TEXT_COLUMNS = overlayGetWidth() // overlayGetFontWidth() + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local playerY = screenHeight - PLAYER_HEIGHT - PLAYER_MARGIN + +local playerX = (screenWidth - PLAYER_WIDTH) / 2 +local goingLeft = false +local goingRight = false +local blocks = {} +local blockSpeed = BLOCK_SPEED +local spawnTimer = 0 +local score = 0 +local lives = START_LIVES +local state = "waiting" + + +function drawGame() + colorForeground(80, 255, 160) + overlayBox(playerX, playerY, playerX + PLAYER_WIDTH - 1, playerY + PLAYER_HEIGHT - 1) + colorForeground(255, 90, 90) + for i, block in ipairs(blocks) do + overlayBox(block.x, block.y, block.x + BLOCK_SIZE - 1, block.y + BLOCK_SIZE - 1) + end + overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) +end + + +function drawOverText() + printCentered(7, "GAME OVER") + printCentered(9, "PRESS SPACE TO PLAY AGAIN") +end + + +function drawWaitingText() + printCentered(6, "DODGE THE BLOCKS") + printCentered(8, "ARROW KEYS TO MOVE") + printCentered(10, "PRESS SPACE TO START") +end + + +function printCentered(row, text) + overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) +end + + +function spawnBlock() + local block = {} + block.x = math.random(0, screenWidth - BLOCK_SIZE) + block.y = -BLOCK_SIZE + table.insert(blocks, block) +end + + +function startGame() + playerX = (screenWidth - PLAYER_WIDTH) / 2 + blocks = {} + blockSpeed = BLOCK_SPEED + spawnTimer = 0 + score = 0 + lives = START_LIVES + state = "playing" +end + + +function updatePlaying() + if goingLeft then + playerX = playerX - PLAYER_SPEED + end + if goingRight then + playerX = playerX + PLAYER_SPEED + end + if playerX < 0 then + playerX = 0 + elseif playerX > screenWidth - PLAYER_WIDTH then + playerX = screenWidth - PLAYER_WIDTH + end + + spawnTimer = spawnTimer - 1 + if spawnTimer <= 0 then + spawnBlock() + spawnTimer = SPAWN_FRAMES + end + + for i = #blocks, 1, -1 do + local block = blocks[i] + block.y = block.y + blockSpeed + if collideRects(block.x, block.y, BLOCK_SIZE, BLOCK_SIZE, playerX, playerY, PLAYER_WIDTH, PLAYER_HEIGHT) then + table.remove(blocks, i) + lives = lives - 1 + if lives == 0 then + state = "over" + end + elseif block.y > screenHeight then + table.remove(blocks, i) + score = score + 1 + blockSpeed = blockSpeed + BLOCK_FASTER + end + end +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + goingLeft = true + elseif what == SWITCH_RIGHT then + goingRight = true + elseif what == SWITCH_BUTTON1 and state ~= "playing" then + startGame() + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goingLeft = false + elseif what == SWITCH_RIGHT then + goingRight = false + end +end + + +function onOverlayUpdate() + if state == "playing" then + updatePlaying() + end + + overlayClear() + drawGame() + if state == "waiting" then + drawWaitingText() + elseif state == "over" then + drawOverText() + end + + return OVERLAY_UPDATED +end +---- + +About a hundred and twenty lines of code, and you wrote all of them a handful +at a time. + +=== What Just Happened + +A few pieces deserve a second look. + +[source,lua] +---- +local blocks = {} +local score = 0 + + +function startGame() + score = 0 +end +---- + +The data is `local` and the functions are not. Those are two deliberate and +opposite choices. + +The data is `local` so that it belongs to this file and nothing outside can +reach in and change it. The functions are global so that they can call each +other in any order: `drawGame` is defined before `printCentered` and calls it +anyway, because a global name is looked up at the moment of the call, by which +time the whole file has been read. Make those functions `local` and the file +has to be sorted so that nothing is used before it appears, which is a rule +you would spend the rest of the book tripping over. Three of the names here +have no choice at all: `onOverlayUpdate` and the two input callbacks must be +global, because that is how Singe finds them. + +Inside the file, the order is alphabetical, with the three callbacks last. +That is not a rule of Lua. It is so that in six months you can find +`spawnBlock` without reading anything. + +[source,lua] +---- + overlayPrint(math.floor((TEXT_COLUMNS - #text) / 2), row, text) +---- + +`+#text+` is the length of a string, in characters. You met `+#+` in lesson six +counting the items in a list, and it does the same job here. `"GAME OVER"` is +nine characters, so on a sixty column screen it starts at column twenty-five, +which leaves twenty-five cells of space on its left and twenty-six on its +right. +`math.floor` throws away the fraction, because there is no such thing as +column twenty-five and a half. + +`TEXT_COLUMNS` works out how many character cells fit across the screen. +`overlayPrint` counts in cells while everything else counts in pixels, so the +width of the overlay is divided by the width of one cell, and +`overlayGetFontWidth` is what tells you that. Two slashes rather than one, +because a fraction of a column is not a place to print. Work it out this way +and the centring stays right at any overlay size, which is the fourth thing in +*Try it*. + +[source,lua] +---- + colorForeground(255, 90, 90) +---- + +`colorForeground` sets a colour that stays set. It is not an argument to +`overlayBox`; it is a switch you flip, and everything drawn afterwards comes +out in that colour until you flip it again. That is why `drawGame` sets green, +draws the player, sets red, and then draws all the blocks without mentioning +colour again. + +It does not affect `overlayPrint`. The console font is copied onto the overlay +exactly as it is, in its own colours, which the manual's entry for +`overlayPrint` says in as many words. Coloured text needs a loaded font, and +that is lesson nineteen. + +=== Try It + +. *Make it easier, then unfair.* Change `PLAYER_WIDTH` to `80` and play a + round. Then change it to `12`, the same size as a block. Notice that you did + not have to touch the drawing or the collision to do either one. +. *Speed up faster.* Change `BLOCK_FASTER` from `0.05` to `0.5` and see how + many points you can get. Then try `0`, and ask yourself whether the game is + still worth playing. +. *An extra life.* Give the player a life back every ten points. Everything + you need is already in `updatePlaying`, in the branch that adds to the + score. +. *Play it bigger.* Run it with `Singe -R --canvas=1280x720 dodge`. The + game lays itself out correctly, because it asked the engine how big the + screen was -- except for the centred text. Work out why, and fix it. +. *Pause it.* Add a fourth state, `"paused"`. `SWITCH_BUTTON2` should swap + between `"playing"` and `"paused"`, and nothing should move while paused. + Done right, this is three lines and you do not have to touch `updatePlaying` + at all. + +=== Break It on Purpose + +Go into `spawnBlock` and delete the line that sets `block.y`, so that it reads: + +[source,lua] +---- +function spawnBlock() + local block = {} + block.x = math.random(0, screenWidth - BLOCK_SIZE) + table.insert(blocks, block) +end +---- + +Save, press space, and Singe stops with this: + +---- +Error executing function 'onOverlayUpdate': dodge.singe:97: attempt to perform arithmetic on a nil value (field 'y') +stack traceback: + dodge.singe:97: in function 'updatePlaying' + dodge.singe:135: in function 'onOverlayUpdate' +---- + +Line 97 is `block.y = block.y + blockSpeed`. The complaint is that something in +that sum is not a number, and the part in parentheses tells you which one: +`field 'y'`, meaning the `y` inside a table. It has no value, because you +never gave it one, and a thing with no value in Lua is `nil`. + +Notice where the error is and where the mistake is. The error is in +`updatePlaying`, forty lines away from `spawnBlock`, where nothing is wrong at +all. `updatePlaying` is only the first piece of code unlucky enough to touch +the damage. This is the normal case, not the exception, and following that +trail backwards is a skill. + +Put the line back. Then turn the page, because the next lesson is about +nothing else. + +=== What You Learned + +* A game is a world that changes, a world that gets drawn, and a decision + about which of those to do -- and those are three separate pieces of code. +* Input handlers should record what is held down; the update should decide + what that means. +* A list of records is the shape of almost every game world. +* Remove things from a list by walking it backwards. +* `collideRects` answers whether two rectangles overlap, and the engine's + version is better than yours because it is right, it reads well, and it has + a family you will meet again. +* One `state` variable holding a word beats three true-or-false flags, and + keeps beating them as the game grows. +* Start a game from one function that resets everything, so that the second + game is exactly like the first. +* Numbers you might want to tune belong in named constants at the top of the + file. +* Build a game in stages that each run. A program that has never worked is + much harder to fix than one that worked five minutes ago. + +=== Next Time + +You have now written enough code to make interesting mistakes. The next lesson +is the one no beginner's book bothers with: what Singe's error messages +actually say, how to find out what a variable really holds instead of guessing, +and how to cut a problem in half until there is nowhere left for it to hide. diff --git a/docs/lessons/08-when-it-goes-wrong.adoc b/docs/lessons/08-when-it-goes-wrong.adoc new file mode 100644 index 000000000..eb56be3ce --- /dev/null +++ b/docs/lessons/08-when-it-goes-wrong.adoc @@ -0,0 +1,472 @@ +== Lesson 8: When It Goes Wrong + +image::learn/08-when-it-goes-wrong.png[The finished lesson, 480] + +You have written a game. From here on you will spend more of your time fixing +programs than writing them, and that is not a sign that anything is wrong with +you. It is the job. The difference between someone who enjoys programming and +someone who gives it up is almost never talent; it is whether they learned to +read what the computer is telling them. + +So this lesson has nothing new in it to build. It is the five messages you will +see most often for the rest of your life, what each one actually means, and +four ways of hunting down the mistake behind it. Keep `dodge.singe` from +lesson seven open, because you are going to break it on purpose several times. + +=== Two Kinds of Stop + +Singe stops for two different reasons, and it says so in two different ways. + +When the script will not even start, you get this shape: + +---- +Error running script: dodge.singe:92: 'then' expected near 'spawnBlock' +---- + +Lua reads your whole file before running a word of it, and this means it could +not make sense of what it read. Nothing ran at all. + +When the script started fine but something went wrong later, you get this +shape instead: + +---- +Error executing function 'onOverlayUpdate': dodge.singe:98: attempt to perform arithmetic on a nil value (global 'blockSped') +stack traceback: + dodge.singe:98: in function 'updatePlaying' + dodge.singe:136: in function 'onOverlayUpdate' +---- + +The game was running. Singe called your `onOverlayUpdate`, as it does sixty +times a second, and this time the call did not come back. + +Both shapes contain the same three things, and they are the three things you +want: a file, a line, and a complaint. Read them in that order, every time. + +The line numbers in this lesson are from my copy of the file. Yours will be a +line or two out. Nothing about reading an error depends on the number matching. + +=== The Ones That Stop It Before It Starts + +These are the easy ones, and they are also the ones that feel worst, because +the screen stays black and there is nothing to look at. + +Go into `updatePlaying` and delete the word `then` from the line +`if spawnTimer <= 0 then`. Save: + +---- +Error running script: dodge.singe:92: 'then' expected near 'spawnBlock' +---- + +Lua got to the end of the `if` line, wanted the word `then`, and found the +start of the next line instead -- which is `spawnBlock()`, and that is the +`near 'spawnBlock'` part. "Near" means "this is where I noticed", and it is +usually the first thing *after* what you left out. + +Now a nastier one. Put `then` back, and instead delete the `end` on the last +line of `spawnBlock`. Save: + +---- +Error running script: dodge.singe:148: 'end' expected (to close 'function' at line 58) near +---- + +Line 148 is the last line of the file. There is nothing wrong with line 148. +Lua read to the very end -- `` is what it calls the end of a file -- still +waiting for an `end` that never came, and by then it had swallowed every +function after `spawnBlock` as if it were part of it. + +The useful half of that message is `(to close 'function' at line 58)`, which is +where `spawnBlock` begins. When a message points at the end of the file, believe +what it says in the parentheses, not the line number. This is also why the indenting matters so +much: a missing `end` is almost invisible in a list of lines, and obvious the +moment everything below it is indented one step too far. + +Two more worth recognising, because you will meet both this week: + +---- +Error running script: dodge.singe:41: unfinished string near '"GAME OVER)' +---- + +A quote you opened and never closed, which you met in lesson one. + +---- +Error running script: dodge.singe:135: 'then' expected near '=' +---- + +That one is `if state = "playing" then`. One `=` sets a variable, two `==` ask +a question, and Lua will not let you set one inside an `if`. It looks like a +strange complaint, and it always means the same thing. + +=== Attempt to Index a Nil Value + +This is the one you will see most, by a wide margin. + +Go into `spawnBlock` and misspell `block` on the first line that uses it, so +it reads `blocl.x = math.random(0, screenWidth - BLOCK_SIZE)`. Save, press +space: + +---- +Error executing function 'onOverlayUpdate': dodge.singe:60: attempt to index a nil value (global 'blocl') +stack traceback: + dodge.singe:60: in function 'spawnBlock' + dodge.singe:92: in function 'updatePlaying' + dodge.singe:136: in function 'onOverlayUpdate' +---- + +*Indexing* is what the dot does. `blocl.x` means "go into the thing called +`blocl` and find the `x` in it". You can only do that to a table. + +*Nil* is what Lua calls a name with nothing behind it. You have not seen much +of it because you have been careful to give everything a value, but every name +you have never used is `nil` already, and typing a name wrong invents a brand +new one. + +So the whole message says: *you used a dot on something that does not exist*. + +The part in parentheses is the gift. `global 'blocl'` is the exact name of the +thing that was empty. Search your file for it. If it appears exactly once, it +is a spelling mistake, and you have found it in ten seconds. + +The word before the name tells you where Lua looked for it: + +* `global` -- a name with no `local` in front of it anywhere. +* `local` -- a name you declared with `local` in the function you are in. +* `field` -- a name after a dot, like the `y` in `block.y`. +* `upvalue` -- a `local` from further out that this function can see, which + is what all of `dodge.singe`'s variables are, since they are declared at the + top of the file and used inside functions. + +`field` is the other common cause, and it is not a spelling mistake at all. If +you wrote `block.hit.when` and never set `block.hit`, you get +`attempt to index a nil value (field 'hit')`. The dot that fails is the second +one. Lua tells you which by naming it. + +=== Attempt to Call a Nil Value + +Put `blocl` back. Now misspell an engine function: in `drawGame`, change +`overlayPrint` to `overlayPirnt`. Save: + +---- +Error executing function 'onOverlayUpdate': dodge.singe:36: attempt to call a nil value (global 'overlayPirnt') +stack traceback: + dodge.singe:36: in function 'drawGame' + dodge.singe:140: in function 'onOverlayUpdate' +---- + +Same `nil`, different crime. *Calling* is what the parentheses do, and you can +only do that to a function. There is no function called `overlayPirnt`, so +`overlayPirnt` is `nil`, so the parentheses have nothing to call. + +This happens for four reasons, in order of how often: + +. You misspelled one of your own functions. +. You misspelled one of Singe's. Nothing in the engine checks your spelling + when the file loads, so a misspelled engine name is just another name that + happens to be empty, and it costs you nothing until the line runs. +. The function does not exist in Singe at all -- it was a reasonable guess, and + reasonable guesses are wrong about half the time. The manual's function list + is the only way to be sure. +. You made it `local` and called it from somewhere above where it is declared. + This is exactly why the functions in `dodge.singe` are global. + +There is a much worse version of this mistake, and you met it in lesson one +without knowing: spell a *callback* wrong and you get no message at all. +`onOverlayUpdated` is not an error. It is a perfectly good function that Singe +has never heard of and will never call, so the screen stays black and nothing +complains. When a whole feature does nothing whatsoever, suspect the name. + +=== Attempt to Perform Arithmetic on a Nil Value + +Put `overlayPrint` back. In `updatePlaying`, misspell `blockSpeed` in the +falling line, so it reads `block.y = block.y + blockSped`: + +---- +Error executing function 'onOverlayUpdate': dodge.singe:98: attempt to perform arithmetic on a nil value (global 'blockSped') +stack traceback: + dodge.singe:98: in function 'updatePlaying' + dodge.singe:136: in function 'onOverlayUpdate' +---- + +Arithmetic is `+`, `-`, `*`, and `/`. You cannot add nothing to a number. +Again the parentheses name the empty thing, and again the commonest cause is a +typo -- but this one has a second cause that is worth knowing, because it is +harder to see. + +A variable you never gave a number to is `nil`, not zero. If you add a high +score to the game and write `local hiScore` at the top without `= 0`, it holds +`nil` until something sets it, and the first `hiScore + score` stops the game. +Lua does not start numbers at zero for you. You start them at zero. + +Two close relatives, with the same cause and the same cure: + +---- +dodge.singe:37: attempt to concatenate a nil value (upvalue 'hiScore') +---- + +`..` joining a string to something that is not there. You will see this one +whenever you print a score you forgot to set. + +---- +dodge.singe:118: attempt to compare number with nil +---- + +That is `if lives > 0 then` with nothing in `lives`. Note that this message +does not name anything in parentheses. When Lua cannot tell you which side was +empty, the next section is how you find out. + +=== An Argument of the Wrong Kind + +Every message so far came from Lua. This one comes from Singe, and it looks +completely different, so it is worth meeting on purpose. + +Suppose you want to see what is in `blocks`, and you try to print it. Add this +line to `drawGame`, under the score: + +[source,lua] +---- + overlayPrint(1, 3, blocks) +---- + +---- +37:overlayPrint: Argument 3 must be a string. +---- + +No file name, no traceback, no `attempt to` anything. An engine complaint +starts with the line number, then the name of the function that refused, then +what it wanted. Here `overlayPrint` was handed a table where it needs text, and +it stopped rather than draw something meaningless. + +The sibling message counts rather than checks: + +---- +34:overlayBox: Expected 4 argument(s), got 3. +---- + +That is an `overlayBox` with a corner missing. The odd-looking `argument(s)` is +the engine's, not a typo of yours. + +When you get one of these, the manual's entry for that function is the answer, +and it will take you a minute. Every entry lists the arguments in order with +what each one has to be. Singe checks them because the alternative is a game +that runs for ten minutes and then draws garbage. + +=== Finding Out What Is Really in There + +Half of debugging is not reading errors at all. It is the case where nothing +crashes and nothing works: blocks that never appear, a player who will not +move, a score stuck at zero. For that you need to see inside the running game, +and there are two ways. + +The first is `debugPrint`, which writes a line to the terminal you started +Singe from. Take the line you cannot make sense of and put one above it: + +[source,lua] +---- + debugPrint("blocks " .. #blocks .. ", timer " .. spawnTimer) +---- + +Now you are looking at what is actually there instead of what you are sure is +there, and that gap is where bugs live. Run it and you will see the truth +about `playerX`, too: it prints as `160.0`, not `160`, because dividing with +`/` in Lua always gives a number with a fractional part, even when the +fraction is zero. + +Two things will bite you. The first is that `debugPrint` insists on text or a +number; hand it anything else and it stops the game the way `overlayPrint` +did. `debugPrint(blocks)` will not show you a list, and +`debugPrint("left " .. goingLeft)` will not show you a `true` or a `false` +either: + +---- +dodge.singe:78: attempt to concatenate a boolean value (upvalue 'goingLeft') +---- + +`tostring` is the fix. It takes anything at all and gives you text for it, so +`tostring(goingLeft)` is `"true"` or `"false"` and the line works. + +The second is that `onOverlayUpdate` runs sixty times a second, so a +`debugPrint` inside it prints sixty lines a second and you cannot read any of +them. The manual says as much in its entry: the call is cheap, but nothing +throttles it. + +Before anything else, put one switch in front of every debug line you add, so +that you can turn the noise off without hunting them down again: + +[source,lua] +---- +local DEBUG = true + + +function debugLog(text) + if DEBUG then + debugPrint(text) + end +end +---- + +Every `debugLog` in the game obeys that one `true`. Change it to `false` and +the game goes quiet; change it back and everything returns. + +Then print when something *happens* rather than every frame: + +[source,lua] +---- + debugLog("hit at y " .. math.floor(block.y) .. ", lives now " .. lives) +---- + +Or print on a timer, counted down exactly the way blocks are spawned: + +[source,lua] +---- + reportTimer = reportTimer - 1 + if reportTimer <= 0 then + debugReport() + reportTimer = REPORT_FRAMES + end +---- + +The `learn` folder has the whole thing as `08-when-it-goes-wrong.singe`: it is +lesson seven's game with a report about every two seconds, a line when you get +hit, and a line when the game ends. + +The second way to see inside is to put it on screen, which is better for +anything that changes every frame, because sixty lines a second in a terminal +is useless but a number that ticks in the corner is easy to watch: + +[source,lua] +---- + if DEBUG then + overlayPrint(1, 3, "BLOCKS " .. #blocks .. " PLAYER " .. playerX) + end +---- + +=== Cutting the Problem in Half + +When you cannot see which of twenty lines is wrong, stop looking and start +removing. + +Put two dashes at the start of a line and Lua ignores the rest of it. That is +a *comment*, and the usual use is to leave notes for yourself, but the better +use is this one: + +[source,lua] +---- + -- overlayPrint(1, 1, "SCORE " .. score .. " LIVES " .. lives) +---- + +Comment out half of `drawGame` and run it. If the problem is still there, it +is in the half you kept. If it went away, it is in the half you removed. Put +that half back and cut *it* in half. Twenty lines takes five rounds of this, +and each round is one save and one look. + +It works on more than lines. Comment out the body of `updatePlaying` and the +blocks stop moving, which tells you whether the thing you are chasing is in +the moving or in the drawing. Comment out the `collideRects` branch and see +whether the game stops crashing. You are not trying to fix anything while you +do this. You are trying to find out where it is not. + +=== Going Round Faster + +All of this depends on being able to try something in a couple of seconds, and +that is what `-R` is for. You have been using it since lesson one; here is what +it is actually doing, and it is worth knowing now that you are going to lean on +it. + +Singe watches every script file your game loaded. When you save one, the game +starts again from the beginning: everything the script made is thrown away and +your file is run afresh, in the time it takes to read the file rather than the +time it takes to start the engine. `F5` does the same on demand, without +saving anything. + +A key you are holding through a reload is ignored until you let go, so the +player will not run off on his own after a reload. + +The two kinds of stop behave differently here, and it is useful to know which +you are looking at without reading the message. A script that will not compile +is *printed and survived*: the window stays open and empty, the file stays +watched, and saving the fix brings the game back. A crash inside a callback -- +all the `attempt to` messages, and the engine's argument complaints -- takes +Singe down, and you start it again by hand. + +=== Reading a Traceback + +The lines under `stack traceback:` are the list of functions that were in +progress when everything stopped, innermost first. Here is the one from +`blocl` again: + +---- + dodge.singe:60: in function 'spawnBlock' + dodge.singe:92: in function 'updatePlaying' + dodge.singe:136: in function 'onOverlayUpdate' +---- + +Read it from the bottom up and it is a sentence. Singe called +`onOverlayUpdate`. At line 136, `onOverlayUpdate` called `updatePlaying`. At +line 92, `updatePlaying` called `spawnBlock`. At line 60, `spawnBlock` gave up. + +The top line is where it broke. The lines under it are how it got there, and +they are what you need when the top line looks innocent. A function that adds +a number to `block.y` is not wrong; a function that put a block in the list +without a `y` is. Only the trail joins the two, and the trail is printed for +you every single time. + +=== The Habit + +Everything above is technique. This is the habit, and it matters more: + +*Change one thing. Run it. Look at what happened.* + +When something will not work, the temptation is to change four things at once, +because one of them is bound to be it. Do that and it stops working in a new +way, and now you do not know which of the four did it, or whether two of them +are cancelling out. You have made the problem bigger and hidden it better. + +One change. Run. Look. It feels slower. It is the fastest thing there is, and +every experienced programmer you will ever meet does it, for exactly the +reason you are about to find out. + +=== Try It + +. *Meet them all.* Make each of the five mistakes in this lesson in + `dodge.singe` on purpose, one at a time, and read the message before you fix + it. You will never be afraid of them again. +. *Watch the spawner.* Put a `debugPrint` inside `spawnBlock` that prints the + new block's `x`. Play for thirty seconds, then look at the numbers. Are they + spread across the screen, or do they favour one end? +. *Turn it off.* Change `DEBUG` to `false` in the lesson script, and satisfy + yourself that nothing prints and the game is unchanged. +. *A bug with no error.* Change the falling loop to run forwards, so it reads + `for i = 1, #blocks do`. Play a round. Nothing crashes and nothing is + printed, but the game is wrong. Put a `debugPrint` inside that loop and work + out what it is really doing. +. *Break a callback's name.* Rename `onInputPressed` to `onInputPress` and run + it. Nothing is printed, nothing crashes, the player will not move, and space + will not start a game. Then explain to yourself why that is the most + dangerous mistake in this lesson. + +=== What You Learned + +* Errors have a file, a line, and a complaint. Read them in that order. +* `Error running script` means nothing ran; the shape with a traceback means it + was running and stopped. +* `nil` is a name with nothing behind it, and it is behind most error messages + you will ever see. +* The word in parentheses names the empty thing, and says whether it was a + global, a local, a field, or an upvalue. +* A missing `end` is reported at the end of the file, and the parentheses tell + you where the mistake really is. +* Engine complaints look different: a line number, a function name, and what it + wanted instead. +* `debugPrint` shows you what a variable really holds. `tostring` makes + anything printable, and a switch like `DEBUG` turns the noise off. +* Comment a half out with `--` to find out which half the problem is in. +* A misspelled callback produces no error at all, which makes it worse than one + that does. +* Change one thing, run it, look. + +=== Next Time + +That is the end of part one. You can write a game, and now you can fix one, +which means everything from here is addition rather than foundation. Part two +starts by replacing those coloured boxes with actual pictures, and from there +the game you have been building begins to look like a game somebody else +would want to play. diff --git a/docs/lessons/09-pictures.adoc b/docs/lessons/09-pictures.adoc new file mode 100644 index 000000000..7db18d78f --- /dev/null +++ b/docs/lessons/09-pictures.adoc @@ -0,0 +1,753 @@ +== Lesson 9: Pictures + +image::learn/09-pictures.png[The finished lesson, 480] + +Everything you have drawn so far, you drew with boxes and lines and the +engine's own plain text. That was on purpose: eight lessons with no artwork +meant eight lessons where nothing could go wrong except your program. Now the +boxes become a ship and some rocks. + +A picture the engine draws onto the overlay is called a *sprite*. This lesson +loads them, draws them, measures them, puts them in the right order, and steps +through a picture that holds several frames of an animation. By the end, the +game from lesson seven looks like a game. + +=== The Art Kit + +You do not have to draw anything. This book ships a small folder of artwork, +and everything in this lesson and the next uses it. + +Find the `art` folder that came with the book and copy the whole folder into +this lesson's folder, beside your script. You should end up with this: + +---- +singe/ <- your work folder + Singe/ + data/ + rocks/ + rocks.singe + art/ + ship.png + rock.png + shot.png + star.png + walk.png + shoot.wav + boom.wav +---- + +Your artwork goes inside your game's own folder, and the engine's `Singe` +folder stays where it is, out in the work folder. That matters in a moment, +because it decides how you name a file when you load it. + +The `Singe` folder is the one the engine unpacked for you on your very first +run, back in lesson one. It is worth knowing what is in it, because those +files are yours to use too and every reader of this book has them: + +* `Singe/click.wav` -- a short click, which the engine's own menu uses for its + sound test. +* `Singe/FreeSansBold.ttf` -- a TrueType font, for lesson nineteen. +* `Singe/menuIntro.flac` -- the music the engine's menu plays. You will use it + in lesson ten. +* `Singe/menuBackground.mkv` -- a video, for lesson fourteen. +* `Singe/DragonModel.glb` and `Singe/SingeText.glb` -- two 3D models, for + lesson twenty-three. +* `Singe/missing.png` -- the picture the engine draws when a game asks for one + it cannot find. + +The kit's pictures are small on purpose. The ship is 32 pixels across and 24 +down, a rock is 24 by 24, a shot is 4 by 10, and a star is 8 by 8. That sounds +tiny until you remember how big the screen actually is, which is the next +thing to sort out. + +=== How Big the Screen Is + +Without a video to play, Singe gives your game a canvas 720 pixels across and +480 down, and the surface you draw on -- the *overlay* -- is half that in each +direction: *360 by 240*. Singe stretches it to fill the window for you, so a +32 pixel ship is about a tenth of the width of the screen, which is roughly +what a ship in an arcade game looks like. + +Never type 360 and 240 into your program. Ask: + +[source,lua] +---- +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +---- + +Those two report the size of the overlay, and a game that asks is a game that +still works when somebody runs it at a different size. + +There is one thing that does not count in those pixels: `overlayPrint`, from +lesson one, counts in character cells. Each cell is 6 pixels across and 13 +down, so a 360 by 240 overlay is 60 columns by 18 rows of text. Sprites are in +pixels, `overlayPrint` is in cells, and mixing the two up is worth one +confused afternoon if you let it be. + +=== One Picture on Screen + +Start a new file next to your `art` folder and type this in. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local ship = spriteLoad(DIR .. "art/ship.png") + + +function onOverlayUpdate() + overlayClear() + spriteDraw(ship, 160, 100) + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(ship) +end +---- + +Run it. A small blue ship sits near the middle of a black screen, pointing up. + +=== What Just Happened + +Four new things, one at a time. + +[source,lua] +---- +local ship = spriteLoad(DIR .. "art/ship.png") +---- + +`spriteLoad` reads an image file off the disk, decodes it, and keeps the +picture in memory. What it hands back is not the picture. It is a *handle*: a +plain number, an entry ticket, the engine's way of saying "that one". You give +that number back to the engine every time you want to do something with the +picture, and you never need to know or care what the number is. + +Print it if you like -- `debugPrint(ship)` from lesson eight will show you a +small number -- but the number itself is not yours to reason about, and +nothing in your program should ever depend on what it turns out to be. The +only rule is that you keep it somewhere you can get at it again, which is +exactly what a variable is for. + +`DIR` is new too, and it solves a problem the layout you have been using since +lesson one creates. Singe looks for files relative to the folder you were +standing in when you started it, *not* relative to your script. You start it +from your work folder, so a plain `art/ship.png` sends the engine looking for +`singe/art/ship.png`, out beside the `Singe` folder. Your artwork is not +there. It is one level down, in `rocks/art/ship.png`, with your script. + +`DIR` closes that gap. It is a piece of text holding the folder your script is +in, with the slash already on the end, so `DIR .. "art/ship.png"` -- joining +two pieces of text with `..`, which you have been doing since part one -- +names the picture correctly however the game was started, and keeps naming it +correctly when you move the whole folder somewhere else. + +`DIR` is one of the names that `Singe/Framework.singe` defines, which is what +the `dofile` line at the top of your script is for. You met that line and what +is in it in lesson seven, and this is the moment it earns its place: without +it there is no `DIR`, and without `DIR` the engine cannot find your ship. + +Use `DIR` on every file your game loads, from here to the end of the book. + +[source,lua] +---- + spriteDraw(ship, 160, 100) +---- + +Draw the sprite with that handle, at that position. Like every drawing call, +it only works inside `onOverlayUpdate`. + +The position is the *top left corner* of the picture, not its middle. Ask for +`(160, 100)` and the topmost, leftmost pixel of the ship lands there and the +rest of it hangs down and to the right. That trips up everybody once, usually +when they try to centre something and it comes out low and to the right by +half its own size. + +If you would rather give the middle, say so: + +[source,lua] +---- + spriteDraw(ship, 160, 100, true) +---- + +A fourth argument of `true` means "treat that point as the centre". Both forms +are in the manual's entry for `spriteDraw`, along with two more that stretch +the picture into a rectangle. + +[source,lua] +---- +function onShutdown() + spriteUnload(ship) +end +---- + +`onShutdown` is a callback, like `onOverlayUpdate` and the two input callbacks +from lesson three. The engine calls it once, when your game is ending. It is +where you give back what you took: `spriteUnload` frees the picture and throws +the handle away. + +Strictly, you could leave it out. The engine frees everything your script +loaded when the game ends, so a missed `spriteUnload` is not a leak. Write it +anyway. It costs one line, it says what your program owns, and in a minute you +will see the case where forgetting to unload really does matter. + +One warning about that handle: once you have unloaded a sprite, the number is +dead. Drawing with it does not draw nothing, it ends your game with an error. + +=== Transparency, and Why It Is a PNG + +The ship is a triangle with two fins. The file it lives in is a rectangle, 32 +by 24, because every image file is a rectangle. So what happened to the +corners? + +They are *transparent*. A PNG can say, for every single pixel, how solid it +is, and the kit's pictures say "not there at all" for every pixel outside the +shape. Singe honours that, so the black background shows through the corners +and the ship has an outline instead of a box around it. + +This is the reason the kit is PNG and not JPEG. A JPEG cannot store +transparency at all -- it would give you the ship in a grey box -- and it also +smudges hard edges, which is the last thing small artwork needs. For game +artwork, PNG. For a photograph that fills the whole screen and has no +transparent parts, JPEG is fine and much smaller. The manual's entry for +`spriteLoad` lists every format Singe will read, and there are a lot of them. + +Try it yourself: change the black to something else by drawing a box behind +the ship before you draw the ship. The corners will show whatever is behind +them, which is the whole point. + +=== Do Not Load While You Draw + +Here is the mistake this section exists to stop. It looks completely +reasonable: + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + local ship = spriteLoad(DIR .. "art/ship.png") + spriteDraw(ship, 160, 100) + return OVERLAY_UPDATED +end +---- + +Run that and it works. It draws the ship. Nothing complains. You would have no +reason to think anything was wrong. + +`onOverlayUpdate` runs about sixty times a second. That is sixty fresh copies +of the ship loaded into memory every second, three thousand six hundred a +minute, and not one of them ever unloaded. The game runs beautifully for two +minutes, gets slower, and then the machine runs out of memory and it dies. +Worse, it dies during the demo and not while you were testing. + +The rule: *load once, draw many times*. Loading is slow and it takes memory. +Drawing is fast and takes none. So `spriteLoad` goes at the top of your file, +outside every function, where it runs one time as the script starts. + +This is not only about sprites. Every `somethingLoad` in the engine works this +way -- sounds in lesson ten, fonts in lesson nineteen, models in lesson +twenty-three. If you find yourself typing `Load` inside a function that runs +every frame, stop and move it out. + +=== How Big Is It? + +You know the ship is 32 by 24 because this book told you. Your program does +not, and it should not have to. Ask: + +[source,lua] +---- +local shipWidth = spriteGetWidth(ship) +local shipHeight = spriteGetHeight(ship) +---- + +Now you can keep the ship on the screen, because the rightmost position it may +sit at is `screenWidth - shipWidth`, and put it in the middle, because the +middle is `(screenWidth - shipWidth) / 2`. Neither line has a number in it +that would have to change if somebody redrew the ship a bit bigger. + +Both of these report the size *as the sprite would be drawn right now*. If you +ever scale or rotate a sprite, with `spriteScale` or `spriteRotate`, the +answer changes to match. That is usually what you want, and it will surprise +you exactly once. + +=== Drawing Order + +Sprites do not merge. Later covers earlier, pixel for pixel, exactly like +sticking paper cutouts on a wall. So the order of your `spriteDraw` calls is +the order from back to front: + +[source,lua] +---- + drawStars() + drawRocks() + drawShots() + spriteDraw(shipSprite, shipX, shipY) + drawHud() +---- + +Stars are furthest back, so they go down first. The ship is in front of +everything in the playfield. The score sits on top of all of it. Get this +backwards and your carefully drawn ship spends the game hiding behind the star +field, and there is no error to tell you so -- only a ship you cannot see. + +`overlayClear` still comes first, before all of it. Drawing never replaces, it +only covers, so the frame starts empty every time. + +=== Frames + +Open `art/walk.png` in any image viewer. It is not a picture of a person. It is +a picture of four people, side by side: the same figure at four points of a +walk, each one 24 pixels across in a strip 96 wide. + +One picture holding several frames of an animation is a *sprite sheet*, and it +is how nearly all 2D animation is stored. One file, one load, one handle, and +the game picks which slice to draw. + +Make a second file, `walk.singe`, beside the first one: + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local walk = spriteLoadFrames(4, DIR .. "art/walk.png") +local walkFrame = 1 +local walkTick = 0 +local walkX = 0 + + +function onOverlayUpdate() + overlayClear() + walkTick = walkTick + 1 + if walkTick >= 8 then + walkTick = 0 + walkFrame = walkFrame + 1 + if walkFrame > 4 then + walkFrame = 1 + end + end + walkX = walkX + 1 + if walkX > overlayGetWidth() then + walkX = -spriteFrameWidth(walk) + end + spriteDrawFrame(walk, walkX, 100, walkFrame) + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(walk) +end +---- + +Run it with `Singe walk`. A small figure walks steadily across the +screen, disappears off the right edge, and comes back on from the left. + +`spriteLoadFrames` is `spriteLoad` with one extra thing to say: how many +frames are in the strip. *The count comes first*, before the file name, which +is the opposite of what most people guess. It divides the width of the image +by the count and remembers the slices. Four frames in a 96 pixel strip means +four frames 24 pixels wide. Get the count wrong -- say 3 -- and you get three +32 pixel slices, each showing bits of two figures, which is a memorably silly +way to find out you typed the wrong number. + +`spriteDrawFrame` draws one slice. It takes the handle, where to put it, and +which frame, *in that order*, and frames are numbered *from 1*: frame 1 is the +leftmost figure and frame 4 the rightmost. + +Watch out for that 1. Elsewhere in the sprite family -- `spriteSetFrame` and +`spriteGetFrame` -- frames are numbered from 0. That is an accident of +history, both are in the manual, and this book uses `spriteDrawFrame` and +counts from 1. + +`spriteFrameWidth` reports the width of one frame, 24 here, rather than the 96 +of the whole strip. `spriteFrameHeight` is its partner. When you want to know +how wide a slice of a sheet is, those are the two to ask, and not +`spriteGetWidth`. + +The frame number is a number in a variable like any other, so the walking is +arithmetic: + +[source,lua] +---- + walkTick = walkTick + 1 + if walkTick >= 8 then + walkTick = 0 + walkFrame = walkFrame + 1 + if walkFrame > 4 then + walkFrame = 1 + end + end +---- + +Count the frames going by. Every eighth one, step to the next picture, and +after the fourth picture go back to the first. Sixty frames a second divided +by eight is between seven and eight steps a second, which for a four frame +cycle is a brisk but believable walk. Change the 8 and you change the speed: +bigger is slower. + +You may wonder why you are counting at all, when the engine has `spritePlay` +to run an animation for you. It does, and for an animated GIF it is the right +answer, because a GIF carries the timing for each of its frames inside the +file. A plain strip like `walk.png` carries no timing, so the engine runs it +as fast as it is allowed to -- a hundred frames a second, twenty-five complete +walk cycles every second, a blur. When the frames come from a strip, do the +counting yourself. + +=== The Game Gets Its Artwork + +Now put it together. The game from lesson seven steered a box along the bottom +of the screen and dropped other boxes on it. Every box becomes a picture, and +while you are in there the ship gets something to shoot with. + +The changes are all of a piece, so here is the whole script. It is longer than +anything you have written, and there is nothing in it you have not met. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local SHIP_SPEED = 3 +local SHOT_SPEED = 6 +local ROCK_COUNT = 6 +local STAR_COUNT = 40 +local START_LIVES = 3 +local HIT_SCORE = 10 + +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local starSprite = spriteLoad(DIR .. "art/star.png") + +local screenWidth = overlayGetWidth() +local screenHeight = overlayGetHeight() +local shipWidth = spriteGetWidth(shipSprite) +local shipHeight = spriteGetHeight(shipSprite) +local rockWidth = spriteGetWidth(rockSprite) +local rockHeight = spriteGetHeight(rockSprite) +local shotWidth = spriteGetWidth(shotSprite) +local shotHeight = spriteGetHeight(shotSprite) + +local shipX = (screenWidth - shipWidth) / 2 +local shipY = screenHeight - shipHeight - 4 +local goLeft = false +local goRight = false +local rocks = {} +local shots = {} +local stars = {} +local score = 0 +local lives = START_LIVES +local over = false + + +function drawHud() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(50, 1, "LIVES " .. lives) + if over then + overlayPrint(25, 8, "GAME OVER") + overlayPrint(17, 10, "PRESS 1 TO PLAY AGAIN") + end +end + + +function drawRocks() + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end +end + + +function drawShots() + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end + + +function drawStars() + for _, star in ipairs(stars) do + spriteDraw(starSprite, star.x, star.y) + end +end + + +function newRock(rock) + rock.x = math.random(0, screenWidth - rockWidth) + rock.y = -rockHeight - math.random(0, 160) + rock.speed = math.random(8, 20) / 10 +end + + +function onInputPressed(what) + if over then + if what == SWITCH_START1 then + startGame() + end + return + end + if what == SWITCH_LEFT then + goLeft = true + elseif what == SWITCH_RIGHT then + goRight = true + elseif what == SWITCH_BUTTON1 then + shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY } + end +end + + +function onInputReleased(what) + if what == SWITCH_LEFT then + goLeft = false + elseif what == SWITCH_RIGHT then + goRight = false + end +end + + +function onOverlayUpdate() + overlayClear() + updateStars() + if not over then + updateShip() + updateShots() + updateRocks() + end + drawStars() + drawRocks() + drawShots() + spriteDraw(shipSprite, shipX, shipY) + drawHud() + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(shipSprite) + spriteUnload(rockSprite) + spriteUnload(shotSprite) + spriteUnload(starSprite) +end + + +function overlapping(ax, ay, aw, ah, bx, by, bw, bh) + if ax + aw <= bx then + return false + end + if bx + bw <= ax then + return false + end + if ay + ah <= by then + return false + end + if by + bh <= ay then + return false + end + return true +end + + +function startGame() + shipX = (screenWidth - shipWidth) / 2 + score = 0 + lives = START_LIVES + over = false + shots = {} + for i = 1, ROCK_COUNT do + rocks[i] = {} + newRock(rocks[i]) + end +end + + +function updateRocks() + for _, rock in ipairs(rocks) do + rock.y = rock.y + rock.speed + if rock.y > screenHeight then + newRock(rock) + end + if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then + newRock(rock) + lives = lives - 1 + if lives <= 0 then + over = true + end + end + end +end + + +function updateShip() + if goLeft then + shipX = shipX - SHIP_SPEED + end + if goRight then + shipX = shipX + SHIP_SPEED + end + if shipX < 0 then + shipX = 0 + end + if shipX > screenWidth - shipWidth then + shipX = screenWidth - shipWidth + end +end + + +function updateShots() + for i = #shots, 1, -1 do + local shot = shots[i] + local gone = false + + shot.y = shot.y - SHOT_SPEED + if shot.y + shotHeight < 0 then + gone = true + end + for _, rock in ipairs(rocks) do + if not gone and overlapping(shot.x, shot.y, shotWidth, shotHeight, rock.x, rock.y, rockWidth, rockHeight) then + newRock(rock) + score = score + HIT_SCORE + gone = true + end + end + if gone then + table.remove(shots, i) + end + end +end + + +function updateStars() + for _, star in ipairs(stars) do + star.y = star.y + star.speed + if star.y > screenHeight then + star.y = 0 + star.x = math.random(0, screenWidth - 1) + end + end +end + + +for i = 1, STAR_COUNT do + stars[i] = { x = math.random(0, screenWidth - 1), y = math.random(0, screenHeight - 1), speed = math.random(1, 3) / 4 } +end + +startGame() +---- + +Left and right arrows steer. Space fires. When the rocks have taken your last +life, `1` starts a new game. + +A few things in there are worth pointing at. + +The four `spriteLoad` calls and the eight measurements are at the top, outside +every function, so they happen once. Everything after them is written in terms +of `screenWidth`, `shipWidth`, and their friends, and there is not one raw +pixel count in the whole of the playing code. + +The functions are in alphabetical order, which is a habit worth picking up +now. It does not matter to Lua in the slightest -- it is for you, six months +from now, looking for `updateShots` in a file with forty functions in it. + +The last two things in the file are not functions at all. They are plain +instructions at the bottom of the script: build the star field, then call +`startGame` to set up the rocks and zero the score. They run once, in order, +as the script loads, and they have to come after the functions they use. + +`overlapping` is the same idea you wrote in lesson seven, moved into a +function of its own and given proper arguments. Two rectangles miss each other +if either one is entirely left of, right of, above, or below the other; if +none of those four is true, they overlap. Lesson eleven replaces it with +something the engine provides, and explains why the rectangle is the wrong +shape for a ship. + +One last note for when you read other people's Singe code. Every sprite call +in this book takes the handle *first*. Singe 2.10 put it last, and a game can +ask the engine to keep doing that, so code you find online may read +`spriteDraw(x, y, ship)`. Do not copy it. Handle first is the current order +and the one the manual documents. + +=== Try It + +. *Centre the ship on its position.* Add `true` as a fourth argument to the + ship's `spriteDraw`. Watch where the ship jumps to, and work out from the + distance it moved what the fourth argument actually did. +. *More rocks.* Change `ROCK_COUNT` to 20. Then to 200. Somewhere between + those two the game stops being playable, and somewhere well past it the game + starts to slow down. Find both. +. *Grow the ship.* Add `spriteScale(shipSprite, 2)` at the very bottom of the + file, on the line above `startGame()`. Run it and steer into the right-hand + edge: the ship is twice the size but it still stops where the small one + stopped, and it hangs off the right edge and off the bottom. Work out why, + then move that one line up above the `spriteGetWidth` calls and watch it + come right. The manual's entry for `spriteScale` explains what scaling does + to the measurements. +. *Put the walker in.* Load `walk.png` in the game, and draw the walking + figure across the top of the screen as scenery. You will need the frame + counting from the walk script, and you will have to decide where in + `onOverlayUpdate` the draw goes -- in front of the stars, behind the rocks. +. *Unload something you are still using.* Add `spriteUnload(starSprite)` at + the bottom of the file, after `startGame()`, and run it. Read what you get. + +=== Break It on Purpose + +Capital letters matter, and artwork is where that bites hardest. Change the +ship's load to use a capital S: + +[source,lua] +---- +local shipSprite = spriteLoad(DIR .. "art/Ship.png") +---- + +The game refuses to start: + +---- +10:spriteLoad: Couldn't open art/Ship.png: No such file or directory +---- + +Read it the same way as the error in lesson one, but note that the shape is a +little different. `10` is the line. `spriteLoad` is the engine function that +gave up, rather than a file name -- when the complaint comes from Singe rather +than from Lua, this is what you get. Then the reason, which here is your +operating system's own wording and will differ slightly from machine to +machine. + +`No such file or directory` is the computer being precise rather than unkind. +There genuinely is no file called `Ship.png`; there is one called `ship.png`, +and to a Linux or macOS machine those are two different names. On Windows it +would have worked, which is worse, because it means the bug travels to +somebody else's machine and appears there for the first time. + +The other half of this error you will meet is the folder. If you put the art +kit in the wrong place, or started Singe from the wrong directory, the message +is exactly the same and the fix is not. Check three things, in this order: is +the file named exactly what you typed, is it in the `art` folder, and is that +`art` folder beside your script. + +=== What You Learned + +* A picture drawn onto the overlay is a sprite, loaded with `spriteLoad`. +* `spriteLoad` hands back a handle: a number that means "that picture" to the + engine, which you keep in a variable. +* `DIR` is the folder your script is in, and every file your game loads should + be named with it. +* `spriteDraw(handle, x, y)` draws it, from its top left corner, and only + inside `onOverlayUpdate`. A fourth argument of `true` means the centre + instead. +* Load once, at the top of the file. Loading inside `onOverlayUpdate` eats + memory until the game dies. +* `spriteUnload` gives a picture back, usually from `onShutdown`, and the + handle is dead afterwards. +* PNG carries transparency, which is why the corners of the ship are not + black boxes. +* `spriteGetWidth` and `spriteGetHeight` measure a sprite as it would be drawn + now, so you never have to type its size into your program. +* Sprites cover each other. The order you draw in is back to front. +* `spriteLoadFrames(count, file)` slices a strip into frames, and + `spriteDrawFrame(handle, x, y, frame)` draws one of them, counting from 1. + +=== Next Time + +The game looks right and sounds like nothing at all. In lesson ten the shots +get a noise, the rocks get an explosion, and something plays underneath the +whole thing -- and you meet your third callback, the one the engine uses to +tell you a sound has finished. diff --git a/docs/lessons/10-sound.adoc b/docs/lessons/10-sound.adoc new file mode 100644 index 000000000..80f46fc2b --- /dev/null +++ b/docs/lessons/10-sound.adoc @@ -0,0 +1,589 @@ +== Lesson 10: Sound + +image::learn/10-sound.png[The finished lesson, 480] + +A game with no sound feels broken in a way that is hard to put a finger on. +The shots go out and nothing happens. The rocks break and nothing happens. Put +a half second noise on each of those and the same game suddenly feels like it +is made of something. + +This lesson gives your game a shot, an explosion, and music underneath. On the +way it introduces your third callback: the one the engine uses to tell you +that a sound has finished playing. + +=== One Noise + +The art kit you copied in lesson nine has two sounds in it, `shoot.wav` and +`boom.wav`, and the explosion is the easier of the two to hear. Make a new +file beside your `art` folder and type this in. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local boom = soundLoad(DIR .. "art/boom.wav") + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + soundPlay(boom) + end +end + + +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Press space.") + return OVERLAY_UPDATED +end + + +function onShutdown() + soundUnload(boom) +end +---- + +Run it and tap the space bar three or four times quickly. The bangs pile up on +top of each other instead of cutting each other off. + +Holding the space bar down gives you exactly one bang, not a stream of them. +That is the same behaviour you met in lesson three: a held key is one press, +reported once, and the engine throws away the repeats your keyboard sends. It +is why steering needs `onInputReleased` and a variable rather than counting +presses. + +=== What Just Happened + +The shape is the same as lesson nine's: load once at the top, use the handle, +give it back at the end. + +[source,lua] +---- +local boom = soundLoad(DIR .. "art/boom.wav") +---- + +`soundLoad` reads an audio file and keeps it in memory, and hands back a +handle -- the same idea as a sprite handle, a number that means "that clip" to +the engine. `DIR` is there for the same reason it was there in lesson nine: +the sound lives in your game's folder, and the game has to be able to find it +from wherever Singe was started. + +Everything lesson nine said about loading applies here word for word. Load at +the top of the file, once. A `soundLoad` inside `onOverlayUpdate` loads the +clip sixty times a second and never gives one back, and the game dies of it +some minutes later. + +[source,lua] +---- + soundPlay(boom) +---- + +Play it. That is the whole of playing a sound effect, and the engine takes +care of the rest: the clip starts, it finishes, and it never gets in the way +of your program. + +But `soundPlay` hands something back too, and it is *not* the same number you +gave it. Change the line and look: + +[source,lua] +---- + local channel = soundPlay(boom) + debugPrint("clip " .. boom .. " on channel " .. channel) +---- + +Tap the space bar quickly half a dozen times and watch the two numbers. The +clip number never changes. The channel number climbs -- 0, then 1, then 2 -- +and once the earlier bangs have finished it drops back and starts reusing the +low numbers again. + +This is the single thing about sound that catches everybody, so it is worth +saying slowly. *Two different kinds of number are in play.* + +The *clip handle* from `soundLoad` is the recording. There is one of it. It +does not change, it is not playing or not playing, it is the sound sitting +in memory waiting to be used. + +The *channel* from `soundPlay` is one performance of that recording. Singe has +sixteen of them, numbered `0` to `15`, and every sound that is currently +audible is using one. That is why the bangs overlap: the second press did not +interrupt the first, it started a second performance on the next free channel. + +Everything that acts on a *sound you can hear* takes a channel: `soundStop`, +`soundIsPlaying`, `soundPause`, `soundResume`. Everything that acts on the +*recording* takes the handle: `soundPlay` and `soundUnload`. Hand one where +the other belongs and you will not always get an error, because `3` is a valid +channel number and also a perfectly good clip handle -- you will get silence, +or the wrong sound stopping, which is far harder to find. + +Sixteen is a lot, but it is not endless. When every channel is busy, nothing +plays and `soundPlay` hands back `-1` instead of a channel number. A game that +fires a great many sounds at once should expect that, and you will write one +in a minute. + +=== Volume + +There are three volumes, and they multiply together. + +The *master* effects volume covers everything. `soundGetVolume` reads it and +`soundSetVolume` sets it, on a scale of `0` to `63`. + +[source,lua] +---- + soundSetVolume(40) +---- + +Resist. That number is the player's, not yours: it starts at whatever they +asked for when they launched the game, and a game that overwrites it is a game +that is too loud in somebody's quiet room. Change the master volume only when +the player asks you to, from an options screen. `soundGetVolume` is there so +that an options screen can show what it currently is instead of guessing. + +What you should reach for is the *channel* volume, which sits under the +master. Give `soundPlay` a third argument and this one performance is quieter: + +[source,lua] +---- + soundPlay(shootClip, 0, 35) +---- + +The `0` in the middle is how many times to repeat, which you have to give +because the volume comes after it. `0` means play it once. `-1` means loop +until something stops it, which is how you would do an engine drone or wind. +The manual's entry for `soundPlay` lists all three arguments. + +The third volume is distance, for sounds placed in a 3D world. That is for +part four, when the game moves into three dimensions. + +Music has a volume of its own, and it is on a different scale. More on that in +a moment. + +=== Stopping, and Not Stopping + +`soundStop` takes a channel and cuts it off where it is: + +[source,lua] +---- + soundStop(engineChannel) + engineChannel = -1 +---- + +Note the second line. As soon as a channel stops, the engine is free to hand +that same number to the next `soundPlay`, so a channel number you are still +holding may now belong to a completely different sound. Throw it away the +moment you stop it. `-1` is the useful thing to put there, because it is the +same value `soundPlay` gives you when it could not play at all, so one test +covers both: `if channel >= 0 then`. + +`soundStop` is for a sound that is wrong now -- the engine noise when the +engine dies, the alarm when the alarm is answered. It is the wrong tool for a +short effect. A half second explosion chopped off after a tenth of a second +sounds like a mistake, because it is one. + +So most of the time, do nothing at all. Let it finish. A sound effect that you +start and never think about again ends by itself and frees its channel by +itself, which is exactly what you want. + +This holds even at the end. `soundUnload` does not cut off a clip that is +still playing: the engine keeps the audio alive until the last channel using +it has stopped. You can unload in `onShutdown` without worrying that you have +silenced something. + +`soundIsPlaying(channel)` answers whether a channel is making noise right now. +Its main use is not curiosity but restraint: an alarm that should sound once, +and not restart every time the game notices the danger is still there. + +[source,lua] +---- +function raiseAlarm() + if alarmChannel < 0 or not soundIsPlaying(alarmChannel) then + alarmChannel = soundPlay(alarmClip) + end +end +---- + +Read the condition aloud: play the alarm if there is no alarm channel, or if +the one you have has stopped. Otherwise leave it alone. + +=== The Sound That Stutters + +Here is the mistake. Everybody writes it, usually within a week of discovering +sound. + +Suppose you wanted the ship to keep firing while the button is held down, +rather than once per press. You would do it the way you steer: a `firing` +variable set to `true` in `onInputPressed` and back to `false` in +`onInputReleased`, and the shot created in `onOverlayUpdate` while it is true. +That is a perfectly sensible thing to want, and the noise looks like it +belongs right there with the shot: + +[source,lua] +---- +function onOverlayUpdate() + if firing then + shots[#shots + 1] = { x = shipX, y = shipY } + soundPlay(shootClip) + end + ... +---- + +What comes out is not a shot. It is a buzzing rattle, or a hard flat tone, or +a sound like a small machine breaking. + +`onOverlayUpdate` runs about sixty times a second. Hold the button for one +second and you have asked for sixty performances of a clip that lasts about +a fifth of a second, each one starting on top of the last. Sixteen of them are +playing at once within a quarter of a second, the other forty-four come back +`-1` and are silently dropped, and what you hear is sixty copies of the same +attack piled on each other. + +The fix is not a cleverer sound. It is playing the sound where the *event* +happens rather than where the *state* is true: + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + soundPlay(shootClip, 0, 35) + end +end +---- + +`onInputPressed` happens once, when the button goes down. One press, one shot, +one noise. + +If you really do want held fire, the answer is the same one in a different +shape: make the firing itself an event. Count frames, exactly as the walk +cycle counted them in lesson nine, and create a shot only every eighth frame. +The noise goes with the shot, so it plays seven or eight times a second +instead of sixty, and that is a machine gun rather than a rattle. + +That is the rule, and it is worth carrying out of this lesson: *sounds belong +with events, not with conditions*. A press, a hit, a death, a pickup -- each +of those happens once and deserves one sound. "The button is down" and "the +ship is moving" are not events; they are true for hundreds of frames in a row, +and anything you play from them plays hundreds of times. + +When a sound really does belong to a state that lasts -- an engine, wind, a +siren -- start it once with `-1` for the loop count, keep the channel, and +stop it when the state ends. + +=== The Third Callback + +Sometimes you do need to know when a sound has finished. Write this function +and the engine will call it: + +[source,lua] +---- +function onSoundCompleted(channel) +end +---- + +That is your third callback, after `onOverlayUpdate` in lesson one and the two +input callbacks in lesson three, and it works the same way as all of them: you +write it, you never call it, the engine calls you. The `channel` it hands you +is the channel that has just gone quiet -- the number `soundPlay` gave you +when you started it. + +It fires when a clip runs out, when the last repeat of a loop finishes, *and* +when you stop a channel yourself with `soundStop`. That last one surprises +people who use the callback to chain one sound into the next: stopping a +channel on purpose still reports it as completed, and if you are not careful +the next sound in the chain starts anyway. + +The game is about to use it for something worth doing. When the last life +goes, the ship's explosion plays, and the game holds still -- no GAME OVER, +no music fading -- until that explosion has actually finished. Then everything +stops at once. It takes a handful of lines and it is the difference between an +ending and a cut. + +=== Music Is Not a Sound Effect + +You could load a three minute tune with `soundLoad` and play it with +`soundPlay`. Do not. Music gets its own family of calls, for reasons that +matter: + +* Music has its own volume, separate from the effects, so a player can turn + the music down and still hear the shots. +* There is no sixteen channel limit and no channel number. One handle is one + piece of music, and you stop it, pause it, and resume it by that handle. +* `musicStop` can fade a piece out over a second or two instead of cutting it. + +The calls mirror the ones you already know: + +[source,lua] +---- +local theme = musicLoad("Singe/menuIntro.flac") + +musicSetVolume(50) +musicPlay(theme, -1) +---- + +`musicLoad` gives a handle. `musicPlay` starts it, with the same loop count as +`soundPlay`: `-1` for forever. `musicStop(theme)` ends it, and +`musicStop(theme, 1500)` takes a second and a half to fade it away. +`musicUnload` gives it back. + +Now the trap. *Music volume runs from 0 to 128, not 0 to 63.* Sound effects +use one scale and music uses another, twice as long, for historical reasons +that are no comfort at all when your music comes out at half the volume you +meant. When you are working with music, double the number you had in mind for +an effect. + +The piece being loaded there is the engine's own menu music, out of the +`Singe` folder the engine unpacked into your work folder on your first run. +It is ten seconds long and it was written to fade out at the end, so looping +it sounds exactly like a ten second loop that keeps fading out. That is fine +for learning the calls and no good at all for a finished +game, which ships a longer piece of its own. Any of the usual formats will do: +the manual's Music section lists them, and Ogg Vorbis or MP3 are the sensible +choices, because a few minutes of music as a WAV is an enormous file. + +=== The Game Gets a Soundtrack + +Open the game from lesson nine. Everything here is an addition to it; nothing +comes out. + +Four things to load, at the top, with the sprites: + +[source,lua] +---- +local shootClip = soundLoad(DIR .. "art/shoot.wav") +local boomClip = soundLoad(DIR .. "art/boom.wav") +local clickClip = soundLoad("Singe/click.wav") +local theme = musicLoad("Singe/menuIntro.flac") +---- + +The first two have `DIR` on them and the last two do not, and that is on +purpose. `DIR` means *your* game's folder. A name that begins with `Singe/` +means the engine's own folder instead, wherever that is -- which is why +`dofile("Singe/Framework.singe")` has worked since lesson seven without a +`DIR` either. Your own files need `DIR`; the engine's never do. + +Two new variables, beside the others: + +[source,lua] +---- +local dying = false +local deathChannel = -1 +---- + +`dying` is the moment between the last life going and the game being over: +the explosion is still sounding, and nothing on screen is moving. + +The shot gets its noise in `onInputPressed`, right where the shot itself is +created, and the restart gets a click so that pressing `1` feels like it did +something: + +[source,lua] +---- +function onInputPressed(what) + if over then + if what == SWITCH_START1 then + soundPlay(clickClip) + startGame() + end + return + end + if what == SWITCH_LEFT then + goLeft = true + elseif what == SWITCH_RIGHT then + goRight = true + elseif what == SWITCH_BUTTON1 then + shots[#shots + 1] = { x = shipX + shipWidth / 2 - shotWidth / 2, y = shipY } + soundPlay(shootClip, 0, 35) + end +end +---- + +A rock that is shot bangs, and so does a rock that hits you. In `updateShots`, +beside the score: + +[source,lua] +---- + newRock(rock) + score = score + HIT_SCORE + gone = true + soundPlay(boomClip, 0, 45) +---- + +And in `updateRocks`, where the life is lost, the interesting part: + +[source,lua] +---- + if overlapping(shipX, shipY, shipWidth, shipHeight, rock.x, rock.y, rockWidth, rockHeight) then + local channel = soundPlay(boomClip) + + newRock(rock) + lives = lives - 1 + if lives <= 0 then + dying = true + deathChannel = channel + if channel < 0 then + endGame() + end + end + end +---- + +When the last life goes, the explosion plays and its channel is remembered. +The game is now `dying`, which stops everything moving but does not yet say +GAME OVER. + +The `if channel < 0` is the sixteen channel limit, handled. If every channel +happened to be busy at that exact moment, there is no explosion and no channel +to wait for, and without those two lines the game would sit in `dying` +forever, waiting for a sound that never played. Waiting for something that +cannot arrive is one of the easiest ways to hang a program, and the guard is +always cheaper than the bug. + +Now the new callback, and the little function it shares with that guard: + +[source,lua] +---- +function endGame() + dying = false + deathChannel = -1 + over = true + musicStop(theme, 1500) +end + + +function onSoundCompleted(channel) + if channel == deathChannel then + endGame() + end +end +---- + +The engine calls `onSoundCompleted` for *every* channel that stops, so the +first thing it does is check whether this one is the channel it cares about. +Everything else -- every shot, every rock -- passes through and is ignored. + +`onOverlayUpdate` needs to hold still while the explosion runs, which is one +word: + +[source,lua] +---- + if not over and not dying then + updateShip() + updateShots() + updateRocks() + end +---- + +`startGame` clears the two new variables and starts the music over: + +[source,lua] +---- + dying = false + deathChannel = -1 + musicSetVolume(50) + musicPlay(theme, -1) +---- + +And `onShutdown` gives back what was loaded: + +[source,lua] +---- +function onShutdown() + spriteUnload(shipSprite) + spriteUnload(rockSprite) + spriteUnload(shotSprite) + spriteUnload(starSprite) + soundUnload(shootClip) + soundUnload(boomClip) + soundUnload(clickClip) + musicUnload(theme) +end +---- + +Run it. Fire at the rocks, let one hit you three times, and listen to the end: +the last bang plays out in full, and only when it is gone does the music start +to fade and the words come up. + +The finished script is in the `learn` folder as `10-sound.singe` if you want +to compare. + +=== Try It + +. *Make the shot quieter still.* Change the `35` in the shot's `soundPlay` to + `10`, then to `63`. Find the number where it sits under the explosions + instead of on top of them. +. *Turn the music down without touching the effects.* Change + `musicSetVolume(50)` to `musicSetVolume(15)`. Confirm that the bangs are + exactly as loud as they were, which is the whole reason music is its own + system. +. *Cut the ending off.* In `endGame`, change `musicStop(theme, 1500)` to + `musicStop(theme)`. Listen to both endings twice. The difference is a number + in one argument and it is not a small difference. +. *Give the ship an engine.* Start `soundPlay(clickClip, -1, 12)` in + `startGame`, keep the channel it gives you in a variable of its own, and + stop it in `endGame`. The click lasts a fiftieth of a second, so looping it + is a buzz, which is as close to an engine as this kit gets. Then take the + `soundStop` out again, play three games in a row, and listen to what you + have built. +. *Count the channels.* Print the channel number every time a rock explodes. + Then raise `ROCK_COUNT` until several explode at once, and keep raising it + until you see a `-1` go by. + +=== Break It on Purpose + +You have two volume scales in your head now, and sooner or later the wrong one +comes out of your fingers. Add this line on its own, just below the four +`soundLoad` and `musicLoad` lines: + +[source,lua] +---- +soundSetVolume(100) +---- + +The game refuses to start: + +---- +20:soundSetVolume: Invalid sound volume value: 100 +---- + +The line number you get is the line you actually typed it on, so yours may not +be 20. Then the function that objected, and the value it objected to. The +message does not tell you what the range is -- that is what the manual's entry +for `soundSetVolume` is for -- but it does tell you the number it would not +take, and once you know that effects stop at 63 the fix is one digit. + +`100` is a suspicious number to find in a volume, and it is worth knowing why +you typed it. Almost certainly you thought of volume as a percentage, because +every volume slider you have ever used goes to 100. Singe's does not. Effects +are `0` to `63` and music is `0` to `128`, and neither is a percentage of +anything. + +This is the general shape of an engine error, as opposed to the Lua errors in +lesson one: a line number, the name of the engine function you called, and a +complaint about the values you handed it. When you see one, the manual's entry +for that function is the next thing to read. + +=== What You Learned + +* `soundLoad` reads a clip and returns a handle. Load once, at the top of the + file. +* `soundPlay` starts a clip and returns a *channel*, which is a different kind + of number from the handle. +* Singe mixes sixteen channels. When they are all busy, `soundPlay` returns + `-1` and nothing plays. +* Channel numbers are reused. Forget one as soon as you have stopped it. +* Short effects should be left alone to finish; `soundStop` is for sounds that + have become wrong. +* `soundUnload` does not cut off a clip that is still playing. +* Play a sound where the event happens, not where a condition is true, or it + plays sixty times a second. +* `onSoundCompleted(channel)` is a callback the engine calls each time a + channel goes quiet, for any reason. +* Music is a separate system with its own handles, its own volume, and no + channel limit. +* Effects volume is `0` to `63`. Music volume is `0` to `128`. They are not + percentages. + +=== Next Time + +Your rocks are hit when their rectangles overlap, which is why a shot that +passes through a corner of the sky still counts. In lesson eleven you get the +engine's own collision tests, learn what a hitbox is, and find out why the +rectangle around a round rock is the wrong shape. diff --git a/docs/lessons/11-hitting-things.adoc b/docs/lessons/11-hitting-things.adoc new file mode 100644 index 000000000..aacf64075 --- /dev/null +++ b/docs/lessons/11-hitting-things.adoc @@ -0,0 +1,488 @@ +== Lesson 11: Hitting Things + +image::learn/11-hitting-things.png[The finished lesson, 480] + +Your ship slides along the bottom of the screen and the rocks fall past it. +Nothing has ever touched anything. In this lesson the ship fires, the rocks +break, and the game finally pushes back. + +Two things in a game touch when the numbers say they do. Nobody looks at the +pictures. Working out what the numbers have to say is the whole of collision, +and you can do it yourself in a dozen lines before the engine does it for you +in one. + +=== Where You Are + +The script from lesson ten loads its artwork from the `art` folder beside it, +draws the ship near the bottom, and drops rocks down the screen. It looks like +this, with the details of the rocks left out: + +[source,lua] +---- +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shootSound = soundLoad(DIR .. "art/shoot.wav") +local boomSound = soundLoad(DIR .. "art/boom.wav") +---- + +Everything in this lesson is added to that script. The whole finished thing is +in `learn/11-hitting-things.singe` if you lose your way. + +=== Work It Out Yourself First + +The ship is a picture 32 wide and 24 tall, drawn with its top left corner at +`shipX`, `shipY`. A rock is 24 by 24 at `rock.x`, `rock.y`. Are they touching? + +That question is hard to answer directly and easy to answer backwards. Two +rectangles are *not* touching when one of them is entirely to the left of the +other, or entirely to the right, or entirely above, or entirely below. There is +no fifth way to miss. If none of those four is true, they are touching. + +Type this in, above your other functions, and read it rather than trusting it: + +[source,lua] +---- +local function overlap(ax, ay, aw, ah, bx, by, bw, bh) + if ax + aw < bx then + return false + end + if bx + bw < ax then + return false + end + if ay + ah < by then + return false + end + if by + bh < ay then + return false + end + return true +end +---- + +Each `if` is one of the four ways to miss. `ax + aw` is the right hand edge of +the first rectangle, so `ax + aw < bx` reads "the first one ends before the +second one starts", which is what "entirely to the left" means in numbers. The +same shape three times more -- for right, above, and below, with `y` and the +heights in place of `x` and the widths -- and then the only thing left is +`true`. + +Those are the eight numbers any collision test needs: a corner and a size, for +each of two things. Keep the shape in your head, because you have now written +it once and will never write it again. + +=== The Engine Already Has It + +Delete `overlap`. Singe ships the same test, and it is called `collideRects`: + +[source,lua] +---- +if collideRects(shipX, shipY, 32, 24, rock.x, rock.y, 24, 24) then + soundPlay(boomSound) +end +---- + +Eight numbers in, `true` or `false` out, in the same order you just wrote them: +the first rectangle's corner and size, then the second one's. It is one of a +small family of collision functions, all of them plain questions with no +handles to keep and nothing to set up, and the manual's Collide section lists +every one. Two edges that touch exactly count as touching, and a rectangle with +no width or height touches nothing at all. + +None of them move anything. `collideRects` tells you that the ship and the rock +are in the same place; what to do about it is yours to decide, and everything +interesting in a game happens in that decision. + +=== The Picture Is Not the Hit Box + +Draw the ship's 32 by 24 rectangle on paper and shade in the ship. There is +sky in the corners. A rock that clips the top right corner of that rectangle +has not hit the ship, it has flown past the wing, and a player who loses a life +for it will say the game cheated -- and be right. + +So the box you test is not the picture you draw. It is smaller, and it sits +inside the picture: + +[source,lua] +---- +local SHIP_WIDTH = 32 +local SHIP_HEIGHT = 24 +local SHIP_PAD_X = 6 +local SHIP_PAD_Y = 5 +local ROCK_WIDTH = 24 +local ROCK_HEIGHT = 24 +local ROCK_PAD = 4 +---- + +The pad is how far in from each edge the real thing starts. Six pixels off each +side of the ship and five off the top and bottom leaves a box 20 by 14 in the +middle of a 32 by 24 picture, which is about where the hull is. + +Rather than write that arithmetic out at every test, write it once per kind of +thing: + +[source,lua] +---- +local function rockBox(rock) + return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2 +end + + +local function shipBox() + return shipX + SHIP_PAD_X, shipY + SHIP_PAD_Y, SHIP_WIDTH - SHIP_PAD_X * 2, SHIP_HEIGHT - SHIP_PAD_Y * 2 +end +---- + +Each of those hands back four numbers at once, separated by commas. A function +may return as many values as it likes, and the way to catch them is a list of +names on the left of the `=`: + +[source,lua] +---- +local function shipHitsRock(rock) + local sx, sy, sw, sh = shipBox() + local rx, ry, rw, rh = rockBox(rock) + return collideRects(sx, sy, sw, sh, rx, ry, rw, rh) +end +---- + +The padding arithmetic is now in exactly two places, and the numbers it uses +are at the top of the file, so you can tune the game's fairness by changing one +number rather than by hunting through your tests. + +=== Firing Back + +A shot is a small record in a list, exactly like a rock. `shot.png` is 4 wide +and 10 tall, and shots travel up the screen instead of down: + +[source,lua] +---- +local SHOT_WIDTH = 4 +local SHOT_HEIGHT = 10 +local SHOT_SPEED = 6 + +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local shots = {} + + +local function fireShot() + local shot = {} + shot.x = shipX + SHIP_WIDTH / 2 - SHOT_WIDTH / 2 + shot.y = shipY - SHOT_HEIGHT + shots[#shots + 1] = shot + soundPlay(shootSound) +end + + +local function moveShots() + for i = #shots, 1, -1 do + local shot = shots[i] + shot.y = shot.y - SHOT_SPEED + if shot.y + SHOT_HEIGHT < 0 then + table.remove(shots, i) + end + end +end +---- + +The fire button calls it, alongside the movement you already have: + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + fireShot() + end +end +---- + +And the shots are drawn in `onOverlayUpdate` the way the rocks are: + +[source,lua] +---- + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +---- + +=== A Shot Is Almost a Point + +A shot is four pixels wide. Shrinking that into a hit box would leave nearly +nothing, and testing a whole rectangle against a rock to find out whether a +sliver of light touched it is more machinery than the question deserves. + +Test the tip instead. `collidePointRect` asks whether one point falls inside +one rectangle -- two numbers for the point, then the rectangle's corner and +size: + +[source,lua] +---- +local function shotHitsRock(shot, rock) + local rx, ry, rw, rh = rockBox(rock) + return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh) +end +---- + +`shot.y` is the top of the shot and `shot.x + SHOT_WIDTH / 2` is halfway across +it, so the point being tested is the middle of the shot's nose. That is where a +player thinks a shot is. + +=== Deciding What Happened + +One function, called once a frame, asks every question and acts on the answers: + +[source,lua] +---- +local function checkHits() + for s = #shots, 1, -1 do + for r = #rocks, 1, -1 do + if shotHitsRock(shots[s], rocks[r]) then + table.remove(rocks, r) + table.remove(shots, s) + soundPlay(boomSound) + break + end + end + end + for r = #rocks, 1, -1 do + if shipHitsRock(rocks[r]) then + table.remove(rocks, r) + soundPlay(boomSound) + end + end +end +---- + +Call it from `onOverlayUpdate`, after everything has moved and before anything +is drawn: + +[source,lua] +---- + moveShip() + moveRocks() + moveShots() + checkHits() +---- + +Run it. Rocks fall, shots rise, and where they meet there is a bang and a gap. +The rock that reaches the ship makes the same noise and disappears, which is +not yet a punishment; lesson twelve turns it into one. + +=== Seeing the Boxes + +The hit boxes are invisible, which makes a mistake in them invisible too. Draw +them while you are working: + +[source,lua] +---- +local SHOW_BOXES = false + + +local function drawBox(x, y, width, height) + overlayBox(x, y, x + width - 1, y + height - 1) +end + + +local function drawBoxes() + colorForeground(0, 255, 0, 255) + drawBox(shipBox()) + for _, rock in ipairs(rocks) do + drawBox(rockBox(rock)) + end +end +---- + +And at the end of the drawing in `onOverlayUpdate`: + +[source,lua] +---- + if SHOW_BOXES then + drawBoxes() + end +---- + +Change `SHOW_BOXES` to `true` and the green outlines appear over the artwork. +Change it back when you are done. A switch like this, one word at the top of a +file that turns a picture of what the program believes on and off, is worth +more than any amount of staring. + +=== What Just Happened + +[source,lua] +---- +local function rockBox(rock) + return rock.x + ROCK_PAD, rock.y + ROCK_PAD, ROCK_WIDTH - ROCK_PAD * 2, ROCK_HEIGHT - ROCK_PAD * 2 +end +---- + +Four values from one `return`, separated by commas. Lua is happy to hand back +as many as you want, and the caller decides how many to keep. When a call like +this is the *last* thing inside another call's parentheses, all four are passed +along, which is why `drawBox(rockBox(rock))` works and gives `drawBox` its four +arguments. Anywhere else in an argument list, only the first value survives. +That rule bites exactly once, and it bites at the end of this lesson. + +[source,lua] +---- + return collideRects(sx, sy, sw, sh, rx, ry, rw, rh) +end +---- + +`collideRects` wants eight numbers: corner and size, corner and size. It gives +back `true` or `false` and changes nothing. Because the answer comes straight +back out of the function with `return`, `shipHitsRock(rock)` reads like a +question wherever it is used. + +[source,lua] +---- + return collidePointRect(shot.x + SHOT_WIDTH / 2, shot.y, rx, ry, rw, rh) +---- + +`collidePointRect` wants six: a point, then a rectangle. The edge counts as +inside, so a shot exactly on the boundary hits. Use it whenever one side of the +question is small enough that its size does not matter. + +[source,lua] +---- + for s = #shots, 1, -1 do +---- + +The loop runs backwards, from the last shot down to the first, because it +removes things as it goes. `table.remove(shots, 3)` shuffles every later shot +down one place, so a forward loop would step straight over the shot that moved +into the gap. Counting down, everything you have not looked at yet is below +you, and removing something above you cannot disturb it. You met this in lesson +six; this is where it earns its keep. + +[source,lua] +---- + break + end + end + end +---- + +`break` leaves the loop it is inside immediately. One shot destroys one rock, +so once a rock is gone there is no point comparing the same shot against the +rest of them -- and the shot itself has been removed, so carrying on would +compare a shot that no longer exists. `break` stops the inner loop over rocks; +the outer loop over shots carries on with the next shot. + +[source,lua] +---- + colorForeground(0, 255, 0, 255) + overlayBox(x, y, x + width - 1, y + height - 1) +---- + +`overlayBox` draws the outline of a rectangle, and it takes two *corners*, not +a corner and a size -- the one place in this lesson where the numbers change +shape. The `- 1` is because both corners are included: a box 20 wide that +starts at 100 ends at 119. The colour is not an argument. `colorForeground` +sets it, and every shape drawn afterwards uses it until something changes it +again. + +=== The Rest of the Family + +`collideRects` and `collidePointRect` will carry most 2D games. The others are +there for the shapes rectangles describe badly, and each is the same kind of +plain question: + +* `collideCircles` and `collidePointCircle`, for anything round. A circle is a + centre and a radius. +* `collideRectCircle`, for a round thing against a square one. +* `collidePointPolygon`, for a shape with corners of its own -- a dragon, a + continent, an odd-shaped button. You give it a flat list of `x`, `y`, `x`, + `y` numbers. +* `collideSegments`, for two line segments crossing. + +That last one matters more than it sounds. Your shot moves six pixels a frame +and the rocks are sixteen pixels of hit box, so a shot cannot get past one +without being tested inside it. Make the shot fast enough -- forty pixels a +frame -- and it teleports from above a rock to below it, never once being in +the same place as the rock, and it never hits anything. Drawing the line from +where the shot was to where it is now, and asking `collideSegments` whether +that line crosses an edge, is the cure. Slow things do not need it. + +There is one collision callback in Singe, `onCollision`, and it is not for +this. It belongs to the 3D physics engine: bodies with mass, falling and +bouncing off one another in three dimensions, telling you where they touched +and how hard. You will meet it in lesson twenty-four. Nothing on the overlay +reports itself, and nothing needs to. Your rocks are in a list, you know how +many there are, and asking is cheap. + +And when you want to know not just *whether* two things met but where the +moving one should end up -- sliding along a wall instead of stopping dead +inside it -- the collide calls deliberately do not answer that. A library +called `bump` is bundled with Singe for exactly that job, and the manual's +Included Libraries section says how to reach it. + +=== Try It + +. *Take the padding out.* Set `ROCK_PAD` to `0` and play for a minute. Then set + it to `8` and play again. One of them feels like the game is lying to you. +. *Look at what you are testing.* Set `SHOW_BOXES` to `true` and watch the + green outlines while a rock passes the ship. Now set `SHIP_PAD_X` to `20` and + look again. +. *Make the shot too fast.* Set `SHOT_SPEED` to `40`. Count how many rocks you + hit out of ten. Work out from the numbers why that happens before you change + it back. +. *Use the whole shot.* Rewrite `shotHitsRock` to use `collideRects` with the + shot's full 4 by 10 rectangle instead of `collidePointRect` with its nose. + Play both and decide which one you prefer; there is no right answer, which is + worth knowing. +. *Rocks against rocks.* Write a loop that compares every rock with every other + rock and prints a line with `debugPrint` when two of them overlap. Watch how + many comparisons that is, and why nobody does it for a thousand rocks. + +=== Break It on Purpose + +The two boxes are already four values each, so it is tempting to feed them +straight in and skip the eight local names: + +[source,lua] +---- +local function shipHitsRock(rock) + return collideRects(shipBox(), rockBox(rock)) +end +---- + +That looks right and it is wrong. Run it and the game stops with: + +---- +62:collideRects: Expected 8 argument(s), got 5. +---- + +Five. `rockBox(rock)` is the last thing in the parentheses, so all four of its +values went in. `shipBox()` is not, so it was cut down to one. One plus four is +five, and `collideRects` will not guess at the other three. + +This error comes from the engine rather than from Lua, so it is shaped a little +differently from the ones in lesson eight: the number at the front is the line, +then the name of the function that refused, then the complaint. Put the eight +local names back. + +=== What You Learned + +* Collision is arithmetic on rectangles, and you can write it yourself. +* Two rectangles miss in exactly four ways; if none of them is true, they hit. +* `collideRects` takes a corner and a size for each of two rectangles and + answers `true` or `false`. +* `collidePointRect` asks whether a point is inside a rectangle, which is the + right question for anything small. +* The box you test is smaller than the picture you draw, or the game feels + unfair. +* A function can return several values, and only the last call in an argument + list passes all of them on. +* Loop backwards through a list you are removing things from, and `break` out + of a loop that has nothing left to find. +* Drawing your hit boxes on screen turns an invisible bug into a visible one. +* `onCollision` is for 3D physics bodies, not for sprites on the overlay. + +=== Next Time + +Rocks break and the ship gets hit, and neither costs anything. Lesson twelve +adds a score that goes up, lives that run out, a title screen to come back to, +and a high score that is still there tomorrow. diff --git a/docs/lessons/12-score.adoc b/docs/lessons/12-score.adoc new file mode 100644 index 000000000..2f490696d --- /dev/null +++ b/docs/lessons/12-score.adoc @@ -0,0 +1,480 @@ +== Lesson 12: Score, Lives, and Game Over + +image::learn/12-score.png[The finished lesson, 480] + +What you have is a toy: it starts in the middle of the action, it never ends, +and nothing you do in it counts. What separates a toy from a game is that the +game keeps score, takes something away when you fail, stops, and lets you try +again. + +This lesson adds all of that, and then adds the one thing that makes a player +come back tomorrow: a high score that is still there when they do. + +=== A Game Is Always in One of Three States + +Watch any arcade machine for two minutes. It shows a title and waits for +somebody. Somebody presses start and it plays. The player runs out of lives and +it says so, then goes back to waiting. Three states, and the machine is always +in exactly one of them. + +Your script needs a variable for which one it is in: + +[source,lua] +---- +local TITLE = "title" +local PLAYING = "playing" +local OVER = "over" + +local state = TITLE +---- + +`state` holds a string, and the three constants hold the only three strings it +is allowed to hold. You could write `state = "playing"` everywhere instead and +the program would run exactly the same -- until the day you type +`state = "palying"`. That is a working line of Lua and the game silently stops +doing anything, because no test anywhere matches it. Typing `PLAYING` wrong +gets you `nil` instead, which goes wrong loudly and at once. Spend three lines, +buy yourself an error message. + +Lesson seven had an `if` that decided what the game did. This is the same idea, +grown up and given a name: a *state machine*. It is worth the fancy phrase +because almost every game ever written has one somewhere. + +=== One Place That Decides + +The rule that makes states worth having is that there is exactly one place that +looks at `state` and decides what happens. Put it in `onOverlayUpdate`, and +give each state a function of its own: + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + + if state == TITLE then + updateTitle() + elseif state == PLAYING then + updatePlaying() + else + updateOver() + end + + return OVERLAY_UPDATED +end +---- + +Everything the game was doing every frame -- moving the ship, the rocks, and +the shots, checking hits, drawing all of it -- moves inside `updatePlaying`: + +[source,lua] +---- +local function updatePlaying() + moveShip() + moveRocks() + moveShots() + checkHits() + drawWorld() + drawScore() +end +---- + +`drawWorld` is the drawing you already had, lifted out of `onOverlayUpdate` and +given a name: + +[source,lua] +---- +local function drawWorld() + spriteDraw(shipSprite, shipX, shipY) + for _, rock in ipairs(rocks) do + spriteDraw(rockSprite, rock.x, rock.y) + end + for _, shot in ipairs(shots) do + spriteDraw(shotSprite, shot.x, shot.y) + end +end +---- + +The other two states are quieter, because nothing moves in them: + +[source,lua] +---- +local function updateTitle() + overlayPrint(25, 5, "R O C K S") + overlayPrint(24, 8, "BEST " .. best) + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end + + +local function updateOver() + drawWorld() + drawScore() + overlayPrint(25, 8, "GAME OVER") + overlayPrint(21, 11, "PRESS FIRE TO PLAY") +end +---- + +`updateOver` draws the world again so the wreck stays on screen under the +words, frozen, because nothing is moving it any more. You get that for free by +not calling the movement functions. Stopping a game is not a special feature; +it is leaving things out. + +The input callback needs the same single decision. While you are playing, left, +right, and fire mean what they meant in lesson eleven. In the other two states +there is only one thing a button can mean: + +[source,lua] +---- +function onInputPressed(what) + if state == PLAYING then + if what == SWITCH_LEFT then + movingLeft = true + elseif what == SWITCH_RIGHT then + movingRight = true + elseif what == SWITCH_BUTTON1 then + fireShot() + end + elseif what == SWITCH_BUTTON1 or what == SWITCH_START1 then + startGame() + end +end +---- + +Two states are handled by one branch there, because "press anything to play" is +the same answer on the title screen and on the game over screen. When they stop +being the same -- a game over screen that waits three seconds first, say -- you +will split them, and the shape of the code will tell you where to cut. + +=== Score and Lives + +Two numbers and three constants: + +[source,lua] +---- +local ROCK_POINTS = 10 +local START_LIVES = 3 + +local score = 0 +local lives = START_LIVES +---- + +They change in `checkHits`, where the game already knows what touched what. A +destroyed rock is worth points, so add the middle line of these three to the +shot-against-rock loop you wrote in lesson eleven: + +[source,lua] +---- + soundPlay(boomSound) + score = score + ROCK_POINTS + break +---- + +And a rock that reaches the ship costs a life, and possibly the game: + +[source,lua] +---- + for r = #rocks, 1, -1 do + if shipHitsRock(rocks[r]) then + table.remove(rocks, r) + soundPlay(boomSound) + lives = lives - 1 + if lives <= 0 then + endGame() + end + end + end +---- + +`lives <= 0` rather than `lives == 0` is a small piece of insurance. Today +nothing can take two lives at once, so they can only ever be equal. The day you +add a rock that costs two, `== 0` steps straight over zero and the player keeps +playing on minus one life, while `<= 0` keeps working. Ask for what you mean. + +Show them both in a line across the top: + +[source,lua] +---- +local function drawScore() + overlayPrint(1, 1, "SCORE " .. score) + overlayPrint(25, 1, "BEST " .. best) + overlayPrint(48, 1, "LIVES " .. lives) +end +---- + +Those numbers are character cells, not pixels, as they were in lesson one. The +overlay is 360 by 240 unless you change it, and a cell of the console font is +six pixels wide, so a line holds sixty of them. + +=== Starting Again + +Going from the title screen to playing is not one line. Everything the last +game left behind has to be put back: + +[source,lua] +---- +local function startGame() + score = 0 + lives = START_LIVES + rocks = {} + shots = {} + shipX = (overlayGetWidth() - SHIP_WIDTH) / 2 + movingLeft = false + movingRight = false + state = PLAYING +end +---- + +`rocks = {}` throws the old list away and puts an empty one in its place. The +two `false` lines matter more than they look: if the player was holding right +when the last rock hit, `movingRight` is still `true`, and the new game starts +with the ship pinned to the wall until they press and release the key again. + +Bugs in this function are the most common bugs in any game, and they all feel +the same to play: the second game is subtly wrong and the first one was fine. +When that happens, read `startGame` and ask what the last game changed that +this one did not put back. + +Ending a game is shorter, and it is where the interesting part of this lesson +lives: + +[source,lua] +---- +local function endGame() + if score > best then + best = score + saveSet("highScore", best) + saveFlush() + end + state = OVER +end +---- + +=== A Number That Outlives the Program + +Everything your game has held so far -- `score`, `lives`, the list of rocks -- +lives in memory, and memory is gone the instant the program stops. Quit the +game and the best score anyone ever got goes with it. + +Singe gives every game a save of its own: a set of names with values under +them, written to disk for you. + +[source,lua] +---- +saveSet("highScore", 4200) +---- + +`saveSet` takes a name -- the *key* -- and a value. The key is yours to choose +and never changes; the value is whatever you want to remember. Numbers, +strings, `true` and `false`, and whole tables of those all go in. Things that +only make sense while the program is running do not: a sprite handle is a +number that means nothing tomorrow, and a function cannot be written down at +all. + +Reading it back is the other half: + +[source,lua] +---- +local best = saveGet("highScore", 0) +---- + +That line goes near the top of your script, beside the other variables, and it +runs once when the game starts. The second argument is the *default*: what to +answer when nothing has ever been saved under that key. More about that in a +moment, because it is the whole of this lesson's error. + +You do not have to ask Singe to write the file. It writes once at the end of +any frame in which something changed, however many keys you set, and again when +the game shuts down. `saveFlush` writes it immediately instead, which is what +`endGame` uses: a new high score is worth a file write of its own, because an +arcade cabinet gets switched off at the wall and a frame is a long time. + +The manual's Save section lists the rest of the family. `saveDelete` forgets one +key, `saveClear` forgets everything -- that is the "reset high scores" in a +service menu -- and `saveGetAll` hands you a copy of the lot, which is useful +when you want to print the whole save while you are hunting something. + +=== Where the File Actually Goes + +It does not go beside your script, and this is not an accident. + +A finished game may live on a disc, on a network share, or inside a single +packed file (lesson twenty-nine), and none of those can be written to. A game +that saves next to its own script works perfectly on the machine you wrote it +on and fails on half the machines it is installed on. Singe 2 did it that way +and it was wrong. + +So everything Singe writes for a game -- the save, screenshots, its log -- +goes in one place, and `singeGetDataPath` will tell you where: + +[source,lua] +---- +debugPrint(singeGetDataPath()) +---- + +Put that at the top of your script for a moment and run it. You get a path +ending in a separator, under a `data` folder, and inside it you will find +`save.json`. Open it in your text editor: it is plain text, and your high score +is in there under the name you gave it. + +That is the answer whenever you need a file of your own as well. Build the +name from `singeGetDataPath()` and it lands somewhere writable; write to a name +of your own invention and sooner or later it does not. + +=== The First Time, There Is Nothing + +The very first time anybody runs your game, no high score has ever been saved. +`saveGet` has nothing to hand back, so it hands back the default you gave it: + +[source,lua] +---- +local best = saveGet("highScore", 0) +---- + +Nothing saved, so `best` is `0`, the title screen says `BEST 0`, and the first +game anybody plays sets a record. That is exactly what you want, and it costs +one extra argument. + +Leave the `0` out and `saveGet` has no default to fall back on, so it answers +`nil` -- the value Lua uses for "there is nothing here", which you met in lesson +eight. `nil` is not zero. You cannot add to it, you cannot compare it with a +number, and you cannot glue it onto a string. It goes wrong on the first frame, +every time, on every machine where the game has not been played -- which +includes every machine your players are about to install it on, and not the one +you are testing on. + +Give every `saveGet` a default. It is the cheapest habit in this book. + +=== What Just Happened + +[source,lua] +---- +local TITLE = "title" +---- + +A constant: a variable you set once and never change, with a name in capitals +so that you can see at a glance that it is one. Lua does not enforce that -- +nothing stops you assigning to `TITLE` -- so the capitals are a message to the +next person reading, who is you in a fortnight. + +[source,lua] +---- + if state == TITLE then + updateTitle() + elseif state == PLAYING then + updatePlaying() + else + updateOver() + end +---- + +The one decision. Every frame goes through here, and nothing else in the game +asks what state it is in except `onInputPressed`. When you add a fourth state +later -- a pause, or a bonus round -- this is where it is added, and you will +know it is the only place. + +The `else` on the end catches "any other state". With three states that is the +same as `elseif state == OVER`, and either is fine. What matters is that there +is no way for a frame to fall through all the branches and draw nothing. + +[source,lua] +---- + score = score + ROCK_POINTS +---- + +Not `score = score + 10`. A number with a meaning gets a name, once, at the top +of the file. When you decide a rock is worth twenty-five, you change one line, +and you change it without reading the collision code. + +[source,lua] +---- + saveSet("highScore", best) + saveFlush() +---- + +`saveSet` puts the number in the save; `saveFlush` puts the save on the disk +now rather than at the end of the frame. Both are inside the `if`, so the file +is only written when the record is actually broken, which is a few times a +session rather than sixty times a second. + +[source,lua] +---- +local best = saveGet("highScore", 0) +---- + +Read once, at startup, into an ordinary variable. The rest of the game reads +`best` rather than calling `saveGet` again, because the save is a file and a +variable is a variable. Write to both when the record falls. + +=== Try It + +. *Make it harder as it goes.* Multiply the rock speed, or the chance of a new + rock, by something that grows with the score. Two lines will do it. +. *Show the lives as ships.* Draw `shipSprite` once per life in the top right + corner with `spriteDraw` instead of printing the number. A `for` loop from + lesson four, and the ship is 32 wide. +. *Save more than a number.* Add `saveSet("gamesPlayed", saveGet("gamesPlayed", + 0) + 1)` to `startGame`, and print it on the title screen. Quit, run again, + and watch it remember. +. *Find the file and break it.* Print `singeGetDataPath()`, open `save.json` in + your editor, change the high score to something absurd, and run the game. + Then delete the file entirely and run it again. Both of those will happen to + your players. +. *Reset it from inside the game.* Make a key you would never press by accident + call `saveClear()`, and check the title screen afterwards. Careful: `best` is + still in memory, and `saveClear` does not touch it. Work out what else has to + happen. + +=== Break It on Purpose + +Take the default out, so the line reads: + +[source,lua] +---- +local best = saveGet("highScore") +---- + +If you have already set a high score, nothing happens at all, which is the +trap: the save has the key, so `saveGet` hands it back. Delete `save.json` +first, then run it. The title screen never appears, and you get this, with +`rocks.singe` standing in for whatever you called your script: + +---- +Error executing function 'onOverlayUpdate': rocks.singe:190: attempt to concatenate a nil value (upvalue 'best') +stack traceback: +---- + +Read it the way lesson eight taught you. The file and the line take you to +`overlayPrint(24, 8, "BEST " .. best)`, and the complaint says something that +was `nil` was glued onto a string. `..` joins two strings, and `nil` is not +one. + +The line it names is not where the mistake is. Line 190 is where the game +finally tripped over it; the mistake was made at the top of the file, where +`best` was asked for and nothing was there. That gap between where a bad value +is made and where it is noticed is the hardest thing about `nil`, and the +reason a default costs one argument and saves an evening. + +Put the `0` back. + +=== What You Learned + +* A game is in exactly one state at a time: attract, playing, or over. +* Keep the state in one variable, with named constants for its values, and let + one `if` decide what a frame does. +* Ending the game is a matter of not calling the things that move it. +* `startGame` has to put back everything the last game changed, held keys + included. +* `saveSet` stores a value under a key, and `saveGet` reads it back on a later + run. +* `saveFlush` writes the file now, for the things you would hate to lose. +* The save goes in the game's own data directory, which `singeGetDataPath` + names. Never write beside your script. +* `saveGet` with no default answers `nil` when nothing was saved, and `nil` + breaks the first frame on a machine that has never run your game. +* A number with a meaning deserves a name at the top of the file. + +=== Next Time + +Your game is a game now, and it is a loose script in a folder you happen to +know the name of. Lesson thirteen turns it into something the engine can find +by itself, with a title, artwork, and an entry in the menu, so that a player +who has never heard of you can start it without typing anything. diff --git a/docs/lessons/13-game-folder.adoc b/docs/lessons/13-game-folder.adoc new file mode 100644 index 000000000..8b2eb99d6 --- /dev/null +++ b/docs/lessons/13-game-folder.adoc @@ -0,0 +1,315 @@ +== Lesson 13: A Game Folder + +image::learn/13-game-folder.png[The finished lesson, 480] + +Your game is a script in a folder you remember the name of, started by typing +its name at a terminal. That is fine for you and useless for anybody else. A +player should be able to copy a folder next to Singe, start the menu, see your +game listed with its title and its artwork, and press a button. + +That takes one new file, about six lines long, and one change to your script. +Nothing else about the game changes at all. + +=== Give the Game a Name + +A game lives in a folder of its own, and the folder, the script inside it, and +the game's name should all match. Rename your folder `Rocks` and the script +inside it `Rocks.singe`. + +Do not put a space in the folder name. Match the capitals exactly wherever you +type it: on most computers that are not Windows, `rocks` and `Rocks` are two +different folders, and a game that only runs on your machine is not a game +anybody else can have. + +When the folder and the script share a name, Singe will find the script from +the folder alone, so both of these run your game: + +---- +Singe Rocks/Rocks.singe +Singe Rocks +---- + +=== What Goes in the Folder + +Everything your game needs, and nothing else: + +---- +Rocks/ + games.dat What the menu reads + Rocks.singe The game + art/ + ship.png + rock.png + shot.png + star.png + shoot.wav + boom.wav + cabinet.png Artwork the menu shows beside the list + marquee.png Artwork the menu shows above it +---- + +The rule behind that layout is that a game never reaches outside its own +folder. Not for artwork, not for a sound, and not for a library you also use in +another game -- if you share code between two games, each one keeps its own +copy. There is exactly one file outside the folder a game may load, and you are +already loading it: `Singe/Framework.singe`. + +The folder itself sits beside the Singe program: + +---- +Singe-v3.00-Linux-x86_64 The engine itself, whatever yours is called +Menu.sh Starts the menu. Menu.bat on Windows +Singe/ Support files. Not yours; leave them alone +Rocks/ Your game +data/ Everything anything writes, your save among it +---- + +The engine wrote `Menu.sh`, the `Singe` folder, and `data` for you, the first +time you ever ran it. Those are what appeared in your work folder in lesson +one. + +=== Paths Count from Where Singe Started + +Here is the change your script needs, and the reason it needs it. + +Every name your game loads -- `art/ship.png`, `art/boom.wav` -- is looked for +starting at the folder Singe was *started in*, not the folder your script is +in. All through this book those have been the same folder, because you stood +inside the game's folder and typed `Singe rocks`. Start it from the +folder above, or start it from the menu, and they are not the same folder at +all, and not one of your files is found. + +`Framework.singe` sets a variable for the answer. `DIR` is your script's own +folder, with the separator already on the end, so a name glued to it points at +your file from wherever Singe was started: + +[source,lua] +---- +local shipSprite = spriteLoad(DIR .. "art/ship.png") +local rockSprite = spriteLoad(DIR .. "art/rock.png") +local shotSprite = spriteLoad(DIR .. "art/shot.png") +local shootSound = soundLoad(DIR .. "art/shoot.wav") +local boomSound = soundLoad(DIR .. "art/boom.wav") +---- + +`..` is the same glue you used for `"SCORE " .. score` in lesson twelve, doing +the same job on a file name. + +Do that to every name your game loads, every time, from now on. It costs seven +characters and it is the difference between a game that runs on your machine +and a game that runs. + +=== games.dat + +`games.dat` is how a game introduces itself. The menu reads the `games.dat` of +every game folder beside it, and lists what it finds. No `games.dat`, no +listing: your folder is invisible, however good the game inside it is. + +Make a new file called `games.dat` in your `Rocks` folder. This is the smallest +one that works: + +[source,lua] +---- +GAMES = { + { + TITLE = "Rocks", + SCRIPT = "Rocks/Rocks.singe" + } +} +---- + +That is Lua, and it is a table of the kind you met in lesson six: `GAMES` is a +list, and each thing in the list is a record describing one game. One folder +can hold several games -- that is why it is a list -- but yours holds one. + +`SCRIPT` is the only field the engine truly needs, and its path is counted from +the folder *above* the `games.dat`, not from the folder the `games.dat` is in. +So a `games.dat` inside `Rocks` names its own script as `Rocks/Rocks.singe`. +That looks like it says `Rocks` twice and it is correct. Every other path in +the file works the same way. + +`TITLE` is what the menu shows. Games are listed in title order, so a title is +also where your game sits in the list. + +=== Telling the Menu More + +The menu has a whole panel to fill beside the list, and it fills it from the +rest of the entry: + +[source,lua] +---- +GAMES = { + { + TITLE = "Rocks", + SCRIPT = "Rocks/Rocks.singe", + CANVAS_X = 720, + CANVAS_Y = 480, + YEAR = 2026, + GENRE = "Shooter", + DEVELOPER = "Your Name Here", + PUBLISHER = "Your Name Here", + DESCRIPTION = "Shoot the rocks before they reach your ship.", + CABINET = "Rocks/cabinet.png", + MARQUEE = "Rocks/marquee.png" + } +} +---- + +`CABINET` and `MARQUEE` are ordinary image files in your game folder, in any +format Singe can read. The cabinet art is the big picture beside the list and +the marquee is the strip above it. Make them whatever size you like; the menu +fits them into their panels. Leave either out and the menu manages without it. + +`YEAR`, `GENRE`, `DEVELOPER`, `PUBLISHER`, and `DESCRIPTION` are the text +underneath, and the engine never looks at them. They are for the person reading +the menu, which is the point. + +`CANVAS_X` and `CANVAS_Y` are the size of your game's world, and 720 by 480 is +the default -- the same world you have been drawing in since lesson one. Set +them only if your game wants a different shape. A game with no `VIDEO` field is +a game with no video, which is what yours is; lesson fourteen is where that +field starts to matter. + +The manual's `games.dat` section lists every field there is, including several +for hardware you do not have yet. Two are worth knowing the names of now. +`GAME_ID` is a unique identity for your game, which the online high score +tables in lesson twenty-eight need and nothing else does. `RESOLUTION_X` and +`RESOLUTION_Y` ask for a particular window size. Ignore the rest until +something sends you looking. + +=== Running It from the Menu + +Run `Menu.sh` -- `Menu.bat` on Windows -- from the folder Singe lives in. + +Your game is in the list, sorted among anything else that is there, with its +artwork beside it and its description underneath. Up and down move one game, +left and right move a page, and start or a fire button plays the one that is +selected. Quit the game with Escape and you are back at the menu, on the same +game, ready to go again. The menu remembers which game you were on between +runs. + +That is the whole of shipping a game, as far as the engine is concerned. A +player copies your folder next to Singe and it appears. + +Typing the name still works, and works the same way: + +---- +Singe Rocks +---- + +When you launch a script by hand like that, Singe looks for a `games.dat` +beside it -- and in up to three folders above it -- and if an entry names the +script you launched, that entry's settings apply to it. So a game plays the +same from a terminal as from the menu, which means you can go on working the +way you have been, with `-R` and a text editor, and still trust what a player +will get. + +=== Later: One File + +A folder is easy to work in and slightly awkward to give away: a dozen files, a +folder inside a folder, and a player who unzips it into the wrong place. A +finished Singe game can be packed into a single `.game` file that the engine +runs directly, artwork and sounds and all, which a player copies next to Singe +and is done. + +Nothing in your game has to change for that. It is lesson twenty-nine, and it +is worth waiting for: pack a game that is not finished and you will pack it +again tomorrow. + +=== What Just Happened + +[source,lua] +---- +local shipSprite = spriteLoad(DIR .. "art/ship.png") +---- + +`DIR` is a variable `Singe/Framework.singe` sets before your script runs, and +holds your script's own folder with a trailing `/` on it. It exists only +because your script loads the framework, so if the first line of your script is +not `dofile("Singe/Framework.singe")`, put it there now. This is the first time +the framework has given you something you could not manage without. + +[source,lua] +---- +GAMES = { + { +---- + +Two braces, because it is a list of records. The outer one is the list. The +inner one is the first thing in the list, which is the record for one game. +Getting one of them wrong is the usual mistake, and Lua will tell you the line. + +[source,lua] +---- + SCRIPT = "Rocks/Rocks.singe", +---- + +The comma at the end of each line separates one field from the next, exactly as +in lesson six. The last field in a record has no comma after it -- Lua does not +mind if it does, but leave it off and your file will look like everybody +else's. + +=== Try It + +. *Make the artwork.* Draw a `cabinet.png` and a `marquee.png`, put them in + your game folder, name them in `games.dat`, and look at the menu. +. *Move your game in the list.* Change `TITLE` to `Asteroid Field` and start + the menu again. The list is sorted by title, not by folder. +. *Break `games.dat`.* Delete one of the closing braces and start the menu. + Your game is gone from the list; the others are still there. Put it back. +. *Two entries, one folder.* Add a second record to `GAMES` pointing at your + lesson seven script, with a title of its own. One folder, two games in the + menu. +. *Find your save.* You changed how the game is started, so ask yourself where + its high score went. Print `singeGetDataPath()` and look. It is not the same + folder it was last lesson, and now you know why it is worth asking rather + than assuming. + +=== Break It on Purpose + +Take the `DIR ..` back off one line, so it reads `spriteLoad("art/ship.png")` +again, and start the game from the folder above -- `Singe Rocks`, or from the +menu. The game dies before it draws anything, and the terminal says: + +---- +24:spriteLoad: Couldn't open art/ship.png: No such file or directory +---- + +The exact wording after the colon comes from your operating system and differs +a little between them, but the shape is the same everywhere: the line, the +engine function that gave up, and the name it could not find. + +It could not find `art/ship.png` because it looked in the folder you started +Singe in, which is the folder holding `Rocks`, and there is no `art` there. +The file it wanted is one folder further down, which is exactly what `DIR` +knows and a bare name does not. + +This is the error you will meet the first time you hand your game to somebody +else, if you meet it at all, because it is invisible from inside the game's own +folder. Put the `DIR ..` back and start it from both places. + +=== What You Learned + +* A game is a folder: the script, its artwork, and a `games.dat`, and nothing + from outside it except `Singe/Framework.singe`. +* Name the folder and the script the same, with no spaces, and `Singe Rocks` + will find it. +* Names your script loads are counted from where Singe was started, so build + every one of them from `DIR`. +* `games.dat` is a Lua file holding a `GAMES` list, and each entry is one game. +* An entry needs `TITLE` and `SCRIPT` and nothing else; paths in it count from + the folder above the `games.dat`. +* `CABINET`, `MARQUEE`, `YEAR`, `GENRE`, `DEVELOPER`, `PUBLISHER`, and + `DESCRIPTION` are what the menu shows the player. +* `Menu.sh` or `Menu.bat` starts the bundled menu, which lists every game + folder beside it, sorted by title. +* A game launched by name gets its `games.dat` entry too, so it behaves the + same either way. +* A finished game can be packed into one file later, in lesson twenty-nine. + +=== Next Time + +That is part two finished: you have a game, with art, sound, collision, score, +and a way for somebody else to run it. Part three is about the thing almost no +other engine does -- playing film as the world the game happens in -- and +lesson fourteen starts with the disc, the frame, and the wait. diff --git a/docs/lessons/14-video.adoc b/docs/lessons/14-video.adoc new file mode 100644 index 000000000..d25e292e6 --- /dev/null +++ b/docs/lessons/14-video.adoc @@ -0,0 +1,482 @@ +== Lesson 14: Playing Video + +image::learn/14-video.png[The finished lesson, 480] + +Singe can play a film and run your game inside it. The picture becomes the +world, and the overlay you have been drawing on since lesson one is laid over +the top of it. In this lesson you will start a video, find out which frame is +showing, stop it, and jump to any moment you like. + +Before any of that, one thing needs saying plainly, because it decides whether +you should read this part of the book at all. + +=== A Game Plays Video Only If It Asks To + +Singe is a game engine. Everything you built in parts one and two -- the +sprites, the sound, the collisions, the score, the game folder -- is the +whole of Singe, and none of it involves video. What part three adds is an +extra: a game may ask for a film to be playing behind it, and then the film +is the world instead of a black canvas. + +Nothing else changes. `onOverlayUpdate` is still called sixty times a second. +`overlayPrint` still counts in character cells. Your sprites draw where they +drew before. A game with video and a game without it are written the same +way, with the same functions, and share the same book. + +So if the game you want to make has no video in it, you may close this part +now and go to part four. Nothing in part four depends on anything in part +three, and the only lessons that need this one are the four that follow it. + +Still here? Good. + +=== You Already Have a Video + +You do not need to find one, buy one, or make one. The first time you ran +Singe in lesson one, a folder called `Singe` appeared in your work folder. +Open it and look: among the files the engine unpacked there is +`menuBackground.mkv`. It is the backdrop the engine's own menu uses, it is +720 by 480 pixels, and it is 420 frames long -- about fourteen seconds. + +That is the video for this lesson and the two after it. + +Make a folder called `movie` next to your other work, and a file inside it +called `movie.singe`. Then run Singe once in that folder, the plain way, so +that the engine unpacks its `Singe` folder there too: + +---- +Singe movie +---- + +A black window opens and sits there doing nothing, because your script is +still empty and there is nothing for the engine to call. That is fine. Press +Escape, and look in the folder: the `Singe` folder is there now, with the +video inside it. + +=== Type This In + +[source,lua] +---- +dofile("Singe/Framework.singe") + + +function onOverlayUpdate() + overlayClear() + overlayPrint(2, 2, "Frame " .. discGetFrame()) + return OVERLAY_UPDATED +end + + +discPlay() +---- + +Save it. + +=== Run It + +The video is not part of your script, so you do not load it in your script. +You hand it to the engine on the command line, with `-v`: + +---- +Singe -R -v Singe/menuBackground.mkv movie +---- + +`-v` is short for `--framefile`, and it names the video the game is to play. +The name of your script comes last, as it always has. `-R` is the same `-R` +you have been using since lesson one, and it still restarts the game when you +save. + +Asking is the whole point of `-v`. If you drop a video file next to your +script and say nothing, the engine notices it, says so on the console, and +runs your game without it. A game is a video game only when somebody says it +is. + +A window opens. A ball of orange fire fills it, a dragon forms out of the +fire, the word `SINGE` appears under it, and then a magenta grid rolls away +under an orange sun. In the top left corner, over all of it, is a number +counting up. + +Watch it to the end. Then watch what happens at the end. + +=== What Just Happened + +[source,lua] +---- +discPlay() +---- + +Singe calls the video *the disc*, and every function that drives it begins +with `disc`. The name is historical: the engine began life playing games that +kept their film on a laserdisc, and the name stuck to the idea. It is a video +file on your hard drive and nothing more. + +A game has at most one disc, so none of the `disc` functions takes an +argument saying which one. There is only the one. + +This line is not inside any function. It sits at the bottom of the file, on +its own, and it runs once when Singe loads your script -- before the first +`onOverlayUpdate`, before anything else. Singe has no `onStartup` callback +because it does not need one: a Lua script *is* a list of instructions, and +anything you write outside a function happens as the file is read. Startup +code goes at the bottom, below the functions, by convention. + +You need `discPlay()` because the disc does not start itself. When the game +begins, the engine parks the disc on frame one and pauses it there. Nothing +moves until you ask. + +[source,lua] +---- + overlayPrint(2, 2, "Frame " .. discGetFrame()) +---- + +`discGetFrame` answers one question: which frame is showing right now? It +takes no arguments and hands back a number. + +Notice where that number is drawn. Column 2, row 2 -- exactly where `Hello!` +went in lesson one, and it lands in the same place on screen. The overlay you +draw on is half the size of the video on each side, which for a 720 by 480 +video is 360 by 240, which is exactly the size of the canvas you have been +drawing on for thirteen lessons. Your coordinates did not move. If you ever +play a video of some other size, the overlay changes size with it, and +`overlaySetResolution` sets it to whatever you want; the manual's entry has +the details. + +This is the whole idea of the overlay. The video is underneath. Your drawing +is on top, and wherever you have not drawn, the film shows through. That is +why `overlayClear` matters even more now than it did: clear the overlay and +you are looking at the film. + +=== A Frame Is an Address + +The number `discGetFrame` returns is a *frame* number. A frame is one still +picture. This video holds 420 of them and shows about thirty every second, so +it lasts about fourteen seconds. + +Frames are counted from zero, so this video has frames 0 to 419. The count +and the last number are not the same, which catches everybody once. +`discGetFrameCount` tells you the count -- 420 -- and the highest frame you +can ask for is one less than that. + +The important thing about a frame number is that it never changes and never +drifts. Frame 110 is the moment the dragon breathes fire, in this copy of the +file, on this machine, today and next year. Seconds drift, because a video +does not always play at exactly the rate you think. A frame number is the +address of a moment, and addresses are what a video game is built out of: you +write down the frame where the dragon appears, and from then on you can send +the film there whenever you like. + +Finding those numbers is a job you do once per video, and the script you +wrote is the tool for it: run the film, watch the counter, write down the +frames where things happen. Everything in lesson sixteen is built on a list +of numbers found exactly that way. + +=== What Happened at the End + +The counter reached 419, and then it was 0 again and the fire was back. + +When the disc runs off the end, the engine starts it again from frame zero. +It does not stop, and it does not tell you. A real game never lets it get +there: it watches the frame number and sends the disc somewhere else before +the end arrives. Forgetting to do that is why a half finished video game +suddenly plays its own title sequence in the middle of a fight. + +=== Stopping, Jumping, and Asking + +Add this function above `onOverlayUpdate`: + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + if discGetState() == DISC_PLAYING then + discPause() + else + discPlay() + end + elseif what == SWITCH_LEFT then + discSearch(0) + elseif what == SWITCH_RIGHT then + discSkipForward(30) + elseif what == SWITCH_START1 then + discStop() + end +end +---- + +Save, and try each one. Space plays and pauses. Left goes back to the start. +Right jumps on about a second. The `1` key stops the disc altogether, and +what you get is a blue screen -- the colour a laserdisc player showed when it +had stopped, kept because a great many games drew their score table on it. + +Five new calls there, and they divide into two kinds. + +*Asking.* `discGetState` hands back one of four names the engine has already +defined for you: `DISC_PLAYING`, `DISC_PAUSED`, `DISC_STOPPED`, and +`DISC_EJECTED`. They are like `OVERLAY_UPDATED` -- no quotes, because they +are names for values rather than text. Compare with `==` as you compared +numbers in lesson three. + +*Telling.* `discPlay` starts or resumes. `discPause` freezes on the frame +that is showing and leaves it on screen. `discStop` is the heavier one: the +picture is replaced by the blue screen, `discGetFrame` answers `0` until the +disc runs again, and `discPause` and the skip calls are ignored while it is +stopped. Any of `discPlay`, `discSearch`, or `discSkipToFrame` un-stops it. + +Two calls send the disc to a frame, and the difference between them is the +only thing in this lesson worth memorising: + +* `discSearch(frame)` goes there and *pauses* on it. +* `discSkipToFrame(frame)` goes there and *plays* from it. + +Search when you want to park on a picture and wait. Skip when you want a +scene to start. `discSkipForward(30)` and `discSkipBackward(30)` are the same +idea measured from where you are rather than from the beginning, and they +leave the disc playing if it was playing and paused if it was paused. + +There is also `discPauseAtFrame`, which does exactly what `discSearch` does +under an older name. The manual marks it as a legacy alias. You will see it +in other people's scripts; write `discSearch` in your own. + +=== Waiting Is Harder Than It Sounds + +Here is the sentence every video game is made of: + +____ +Play from frame 0 to frame 120, then ask the player a question. +____ + +Try to write it. You cannot write "play, then wait, then ask", because you do +not have anywhere to write it. You have `onOverlayUpdate`, which Singe calls +sixty times a second and which must return immediately every single time. A +function that sat and waited would stop the engine dead: no drawing, no +input, no video. + +So you cannot say "then". You have to turn "then" into a variable that +remembers where you have got to, and check it on every one of those sixty +calls: + +[source,lua] +---- +local asking = false + + +function onOverlayUpdate() + overlayClear() + if not asking and discGetFrame() >= 120 then + discPause() + asking = true + end + if asking then + overlayPrint(2, 4, "Left or right?") + end + return OVERLAY_UPDATED +end +---- + +That works. Read it and you can see it works. But notice what it cost: one +variable, whose only job is to remember which part of the sentence you are +in, and two `if` statements to keep it honest. + +Now add the rest of the scene. The player answers, so one of two things plays +next. Each of those ends somewhere and asks another question. A modest +branching game has forty of these moments, and every one of them wants its +own variable and its own `if`. You end up with a script where the story is +not written down anywhere -- it is spread across a dozen flags, and to find +out what happens after the dragon you have to read all of them. + +Hold that thought. Lesson fifteen is nothing but the fix for it. + +=== When There Is No Video + +Run your script the plain way again, with no `-v`: + +---- +Singe -R movie +---- + +Nothing crashes. You get a black window with `Frame 0` in the corner. + +This is worth understanding, because it is the reassuring half of the promise +at the top of this lesson. A game without a disc still runs every `disc` call +you make; they do nothing useful. The engine documents exactly what they +answer, and it never changes: `discGetFrame` and `discGetFrameCount` +return `0`, `discGetState` returns `DISC_EJECTED`, `discGetWidth` and +`discGetHeight` return the size of the canvas, and `discPlay`, `discPause`, +`discSearch`, and the rest are recorded in the log and otherwise ignored. + +You cannot break a script by calling a disc function when there is no disc. + +When a script needs to *know*, it asks `SINGE_DISC`. That is another name the +engine defines before your script runs, and it is `true` when the game has a +video and `false` when the world is a plain canvas. It is the right way to +write a game that can run both ways: + +[source,lua] +---- + if not SINGE_DISC then + overlayPrint(2, 2, "This game has no video.") + overlayPrint(2, 4, "Run it again with -v Singe/menuBackground.mkv") + return OVERLAY_UPDATED + end +---- + +`not` flips a true or false value over, as it did in lesson three. + +=== The Whole Thing + +Put together, with a small function that turns the state into words and a +frame to hold at, this is the finished script. It is a frame finder: the tool +you will use every time you meet a new video. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local stopAt = 400 +local held = false + + +local function stateName() + local state = discGetState() + + if state == DISC_PLAYING then + return "playing" + elseif state == DISC_PAUSED then + return "paused" + elseif state == DISC_STOPPED then + return "stopped" + end + return "not there" +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + if discGetState() == DISC_PLAYING then + discPause() + else + discPlay() + end + elseif what == SWITCH_LEFT then + discSearch(0) + held = false + elseif what == SWITCH_RIGHT then + discSkipForward(30) + elseif what == SWITCH_START1 then + discStop() + held = false + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + overlayClear() + if not SINGE_DISC then + overlayPrint(2, 2, "This game has no video.") + overlayPrint(2, 4, "Run it again with -v Singe/menuBackground.mkv") + return OVERLAY_UPDATED + end + + if not held and frame >= stopAt then + discPause() + held = true + end + + overlayPrint(2, 2, "Frame " .. frame .. " of " .. discGetFrameCount()) + overlayPrint(2, 4, "The disc is " .. stateName() .. ".") + overlayPrint(2, 6, "Space plays and pauses. Left rewinds.") + overlayPrint(2, 7, "Right jumps on a second. 1 stops the disc.") + if held then + overlayPrint(2, 9, "Holding at frame " .. stopAt .. ".") + end + + return OVERLAY_UPDATED +end + + +discPlay() +---- + +`held` is the same kind of variable as `asking` above, and it is here for a +reason you can test: take it out, leave `if frame >= stopAt then discPause() +end`, and then try to press space to carry on past frame 400. You cannot. The +disc resumes for a sixtieth of a second and the next `onOverlayUpdate` pauses +it again, forever. `held` is how you say "I have already done this once". + +=== Try It + +. *Find the dragon.* Run it, watch the counter, and write down the frame + where the dragon first appears, the frame where it breathes fire, and the + frame where the grid takes over the screen. You will want these three + numbers in lesson sixteen. +. *Step through it.* Change `discSkipForward(30)` to `discSkipForward(1)` and + hold the right arrow down. Now change `stopAt` to a frame in the middle of + the dragon and use `discSearch` and the arrow key to land on it exactly. +. *Make it loop.* Change the hold so that instead of pausing at frame 400 it + calls `discSkipToFrame(0)`. You have built an attract mode: a scene that + plays forever until somebody presses a button. +. *Ask for a frame that is not there.* Change the `SWITCH_RIGHT` line to + `discSkipToFrame(100000)`. It does not crash. Read the manual's entry for + `discSkipToFrame` and find the sentence that says why. +. *Play it without the video.* Run `Singe -R movie` and then press + every key. Nothing you press can break it. + +=== Break It on Purpose + +The mistake that costs the most time here is not a Lua mistake. Run this: + +---- +Singe -R -v menuBackground.mkv movie +---- + +The path is wrong -- the video is in the `Singe` folder, not beside your +script -- and Singe will not start. What it does instead is print its entire +list of command line options, which is alarming the first time, and then this +as the very last line: + +---- +Error: Unable to locate video. +---- + +A wall of options like that always means the same thing: the engine never got +as far as your game. Ignore the wall and read the last line. + +That one is easy. The unhelpful version of it is running with no `-v` at all +and forgetting you did. There is no error, because a game without a video is +a perfectly good game. You get a black screen and a counter stuck on `0`, and +you go looking for the bug in your script for twenty minutes. + +That is what the `SINGE_DISC` block in the finished script is for. Three +lines of it, and the game tells you what is wrong instead of sulking. Put a +check like that in every script you write that needs a video. + +=== What You Learned + +* A game plays video only when it asks to, and everything else in Singe works + the same either way. +* Singe calls the video the disc. A game has one, and the `disc` functions + take no handle because there is nothing to choose between. +* The video goes on the command line with `-v`, not in the script. +* The overlay is drawn over the picture, and the film shows through wherever + you have not drawn. +* A frame is one still picture, counted from zero, and a frame number is the + address of a moment. +* `discGetFrame` asks where the disc is; `discGetFrameCount` says how many + frames there are; `discGetState` says what the disc is doing. +* `discPlay`, `discPause`, and `discStop` drive it. `discSearch` goes to a + frame and pauses; `discSkipToFrame` goes to a frame and plays. +* A disc that runs off the end starts again at frame zero without telling + you. +* Without a disc every `disc` call is harmless, and `SINGE_DISC` is how a + script tells which kind of game it is in. +* You cannot wait inside `onOverlayUpdate`, so "then" has to be spelled out + as a variable. + +=== Next Time + +That last point is the sore one. A video game is a list of steps -- play +this, ask that, play the other -- and you have just seen that the callback +model cannot say "then" without a flag and an `if` for every step. Lesson +fifteen shows you the other way Singe lets you write a script, in which "then" +is a new line. diff --git a/docs/lessons/15-threaded.adoc b/docs/lessons/15-threaded.adoc new file mode 100644 index 000000000..36e323003 --- /dev/null +++ b/docs/lessons/15-threaded.adoc @@ -0,0 +1,405 @@ +== Lesson 15: The Other Way to Write It + +image::learn/15-threaded.png[The finished lesson, 480] + +Every lesson so far has been written the same way. You write functions whose +names begin with `on`, Singe calls them when it feels like it, and you never +say what order anything happens in. That is the *event driven* model, and it +is how most game engines work. + +Singe has a second model. In it you write your game as a list of steps, from +the first line to the last, and you are allowed to wait. In this lesson you +will write the same short video sequence both ways and see the difference +with your own eyes. + +=== The Sequence + +Here is what the game does, in English: + +. Start the film at the beginning and play it up to frame 120. +. Stop there and ask the player to press the space bar. +. When they press it, play on to frame 300. +. Stop there, say "The end.", wait four seconds, and quit. + +Four steps. Read them again and notice that every one of them contains the +word "then", spoken or not. + +=== The Way You Know + +Make a file called `events.singe` in the `movie` folder from lesson fourteen, +and type this: + +[source,lua] +---- +dofile("Singe/Framework.singe") + +local step = 1 +local endedAt = 0 + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 and step == 2 then + step = 3 + discPlay() + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + overlayClear() + overlayPrint(2, 2, "Frame " .. frame) + + if step == 1 then + if frame >= 120 then + discPause() + step = 2 + end + elseif step == 2 then + overlayPrint(2, 4, "Press the space bar to go on.") + elseif step == 3 then + if frame >= 300 then + discPause() + endedAt = singeGetTicks() + step = 4 + end + elseif step == 4 then + overlayPrint(2, 4, "The end.") + if singeGetTicks() - endedAt > 4000 then + singeQuit() + end + end + + return OVERLAY_UPDATED +end + + +discSearch(0) +discPlay() +---- + +Run it: + +---- +Singe -R -v Singe/menuBackground.mkv events +---- + +It works. It does exactly the four steps. `singeGetTicks` is new -- it hands +back the number of milliseconds since the engine started, which is the +ordinary way to measure a stretch of time -- and `singeQuit` ends the game, +which is what the engine does for you when you press Escape. + +Now read the script and try to find the four steps in it. They are there, but +they are inside out. The order of the story is written in the *values* of +`step`, not in the order of the lines, and `step` is set in one function and +read in another. To answer "what happens after the player presses space?" you +have to hold the whole thing in your head at once. + +And this is the easy version. There are two decisions in it. + +=== The Other Way + +Start a new file, `steps.singe`, and type this: + +[source,lua] +---- +local message = "" +local pressed = false + + +local function drawFrame() + overlayClear() + overlayPrint(2, 2, "Frame " .. discGetFrame()) + overlayPrint(2, 4, message) +end + + +local function playTo(frame) + while discGetFrame() < frame do + drawFrame() + singeYield() + end +end + + +local function waitForButton() + pressed = false + while not pressed do + drawFrame() + singeYield() + end +end + + +local function waitSeconds(seconds) + local stopTime = singeGetTicks() + seconds * 1000 + + while singeGetTicks() < stopTime do + drawFrame() + singeYield() + end +end + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + pressed = true + end +end + + +function singeMain() + discSearch(0) + discPlay() + playTo(120) + discPause() + + message = "Press the space bar to go on." + waitForButton() + + message = "" + discPlay() + playTo(300) + discPause() + + message = "The end." + waitSeconds(4) +end + + +dofile("Singe/Framework.singe") +---- + +Run it the same way. It does the same four things. + +Now read `singeMain`. The four steps are the four paragraphs, in order, top +to bottom, and there is no `step` variable anywhere because the position in +the story *is* the position in the function. That is the whole point of this +lesson. + +=== What Just Happened + +[source,lua] +---- +function singeMain() +---- + +`singeMain` is a name Singe looks for, like `onOverlayUpdate`. If your script +defines it, the framework runs your game from it: it is called once, at the +start, and your game lasts as long as it lasts. When it returns, the game +quits. + +[source,lua] +---- + singeYield() +---- + +This is the call that makes it possible, and it means "let a frame happen". + +Singe is not doing two things at once -- nothing here is running in parallel, +whatever the word *threaded* suggests. What happens is that `singeYield` +stops your function where it stands, hands control back to the engine, and +remembers the exact spot. The engine draws the frame, reads the input, moves +the video on, and then starts your function again from the line after the +`singeYield`, with every variable exactly as you left it. + +So a `while` loop with a `singeYield` in it is a way of waiting. It is the +only way of waiting, and you must put one in every loop that is waiting for +something. This is a deal you are making with the engine: you may sit and +wait, as long as you give the frame back sixty times a second. + +[source,lua] +---- +local function playTo(frame) + while discGetFrame() < frame do + drawFrame() + singeYield() + end +end +---- + +Singe has no "wait until the video reaches frame 120" function. It does not +need one, because you can write it in four lines, and you just did. Keep +going while the disc is short of the frame you want; give a frame back each +time round. + +`waitForButton` is the same shape with a different question, and +`waitSeconds` is the same shape again with a clock. Three small functions, +written once, and `singeMain` reads like the English sentences at the top of +the lesson because of them. This is exactly what lesson five said your own +functions were for. + +[source,lua] +---- + drawFrame() + singeYield() +---- + +Every wait loop draws before it yields, and this is the part that surprises +people. In the threaded model there is no `onOverlayUpdate` of your own, so +nothing draws unless you draw it. Whatever you draw before a `singeYield` is +what appears on that frame. Stop drawing and the overlay stops changing. + +That is why `message` exists. `singeMain` sets it, and `drawFrame` prints it, +and the three wait loops all call `drawFrame`. It is a small price for +getting the story back in order. + +[source,lua] +---- +dofile("Singe/Framework.singe") +---- + +At the *bottom*. This matters more than anything else in the lesson, so it is +worth knowing why. + +`dofile` runs another script inside yours, and one of the things the +framework does as it runs is look for a function called `singeMain`. If it +finds one it wraps your game around it and writes an `onOverlayUpdate` of its +own, whose entire job is to start your `singeMain` again once per frame. If +it does not find one, it does nothing of the sort, and your game is an +ordinary event driven game. + +So the framework has to be loaded *after* `singeMain` exists. Load it at the +top, the way you have every lesson since the third, and at the moment it +looks there is no `singeMain` yet, and you get a game with no game in it. + +=== The Rules + +There are four, and between them they cover every mistake you can make here. + +*The framework goes last.* For the reason above. + +*Do not write your own `onOverlayUpdate`.* The framework has written one, and +there can only be one: whichever of the two is written last wins, and the +other is thrown away without a word. Above the `dofile`, yours goes and your +drawing never appears. Below it, the framework's goes and your `singeMain` +never runs again. Draw from inside `singeMain` instead. + +*Every waiting loop needs a `singeYield`.* A loop without one never gives the +frame back. The window stops redrawing, stops responding, and the game is +gone; you close the terminal to get out of it. There is no error message, +because from the engine's point of view your function has not finished yet. + +*Returning from `singeMain` quits the game.* Which is tidy when you mean it +and a surprise when you do not. A game that should keep going forever ends +with a loop that never exits: + +[source,lua] +---- + while true do + drawFrame() + singeYield() + end +---- + +=== Input Still Arrives + +Look again at where `pressed` is set: + +[source,lua] +---- +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + pressed = true + end +end +---- + +That is a callback, in a script that is written as a list of steps. Both +models are running at once, and they are meant to be. + +When `singeMain` exists, only `onOverlayUpdate` is taken away from you. +Everything else -- `onInputPressed`, `onInputReleased`, `onKeyPressed`, +`onSoundCompleted`, `onShutdown`, all of them -- fires exactly as it always +did, in the gaps between your yields. So the usual way to write a game in +this model is the way you just did: the story in `singeMain`, and a small +callback that catches an event and leaves a note for the story to find. + +`waitForButton` sets `pressed` to `false` before it starts waiting, which +throws away anything the player mashed earlier. Leave that line out and a +button pressed during the video counts as the answer to a question that has +not been asked yet. + +=== Which One to Use + +Neither model is the better one. They suit different jobs, and a real game +uses whichever fits. + +The threaded model suits anything that is a *sequence*. Play this, then ask, +then play that. An opening. A cut scene. A tutorial. A boss fight with three +phases. Anything you would naturally describe as a numbered list is a +`singeMain` waiting to be written, and video is the most sequential thing +there is, which is why part three arrives at it here. + +The event driven model suits anything that is a *situation*. Ten enemies and +a player, all moving, all colliding, none of them in any order. The game you +wrote in lesson seven has no sequence in it at all: every frame is the same +question asked again, which is exactly the shape of `onOverlayUpdate`. Write +that as a list of steps and you gain nothing and lose the clarity. + +When a game is both -- and most are -- it is written as both. `singeMain` +walks through the levels in order, and inside each level a loop does the +lesson seven work sixty times a second until the level ends. + +=== Try It + +. *Add a step.* After `waitForButton`, make the film jump backwards instead: + `discSkipToFrame(0)`, then `playTo(60)`, then carry on as before. One new + line in one place, and nothing else in the script has to know. +. *Add the same step to the other one.* Now do it to `events.singe`. Count how + many places you had to touch. +. *Write `waitFrames`.* A fourth helper, next to the other three, that waits + for a given number of frames of video rather than a given number of + seconds. Use `discGetFrame` and remember where you started. +. *Take a yield out.* Delete the `singeYield()` inside `playTo` and run it. + Be ready to close the window. Now put it back and delete `drawFrame()` + instead, and work out from what you see why the frame counter stops but the + film does not. +. *Make it a loop.* Put the whole of `singeMain` inside `while true do ... + end` so the sequence starts over instead of quitting. Escape still gets you + out, because that has never been your job. + +=== Break It on Purpose + +Move the `dofile("Singe/Framework.singe")` line from the bottom of +`steps.singe` to the top, and run it. + +There is no error. Nothing is printed. The window opens, the first frame of +the film sits there, and that is all that ever happens. + +This is the worst kind of bug and the reason it is worth meeting on purpose. +Nothing has gone wrong, in the engine's view: your script loaded, the +framework loaded, and neither of them defined an `onOverlayUpdate`, so the +engine has nothing to call. The disc is parked on frame one, paused, exactly +as the engine left it, because the `discPlay` that would have started it is +inside a `singeMain` that nobody ever runs. + +When a threaded game does nothing at all, look at the last line of the file +first. It is almost always that. + +=== What You Learned + +* Singe has two ways to write a script, and you choose by defining + `singeMain` or not. +* In the threaded model your game is a list of steps and runs from top to + bottom. +* `singeYield()` gives one frame back to the engine and carries on from the + next line. +* Waiting is a `while` loop with a `singeYield` in it. You write the waiting + function you need; the engine does not provide one. +* Nothing draws unless you draw it, before a yield, on every frame. +* `dofile("Singe/Framework.singe")` goes at the *end* of a threaded script. +* Do not write your own `onOverlayUpdate` when `singeMain` exists. +* Every other callback still fires, so events and steps mix freely. +* Returning from `singeMain` ends the game. +* Sequences want the threaded model; situations want the event driven one. + +=== Next Time + +You can now write "play this, then ask, then play that" as three lines. A +branching video game is nothing but that sentence, a few hundred times, with +the answers deciding which line comes next. Lesson sixteen builds one, and +the interesting part turns out not to be the video at all. It is keeping the +map of what leads where in a form you can still read when the game has forty +scenes in it. diff --git a/docs/lessons/16-branching.adoc b/docs/lessons/16-branching.adoc new file mode 100644 index 000000000..f9f757620 --- /dev/null +++ b/docs/lessons/16-branching.adoc @@ -0,0 +1,520 @@ +== Lesson 16: Branching + +image::learn/16-branching.png[The finished lesson, 480] + +A branching video game is a film with forks in it. A scene plays, the player +chooses, and a different scene plays next. Do that twenty or thirty times and +you have a game people will replay for years trying to find the ending they +have not seen. + +In this lesson you will build one. It has four scenes, two choices, a timer +on each choice, a death, and three lives. The code that plays it is about +thirty lines, and it would still be about thirty lines if the game had two +hundred scenes -- because the game itself will not be written in code at all. + +=== Imagine It Is a Dragon + +The video everybody has is `Singe/menuBackground.mkv`, the same 420 frames +you have been driving since lesson fourteen. It is a ball of fire, a dragon +made of paper, the word `SINGE`, and then a magenta grid rolling away under +an orange sun forever. + +It is not a drama. There is nobody in it to be in peril, and the sunset does +not care what you decide. + +Play along anyway. The dragon is real enough for two minutes, and everything +you are about to write works exactly the same when the footage is a stunt +performer falling off a cliff. Here is the map, and the frame numbers are the +ones you found for yourself in lesson fourteen: + +[cols="1,1,3",options="header"] +|=== +| Scene | Frames | What happens +| `arrive` | 0 to 119 | Fire, and the dragon lands. *Left or Right?* +| `sunset` | 120 to 259 | It breathes fire. *Duck left, or run right?* +| `pit` | 260 to 339 | You are eaten. You lose a life. +| `escape` | 340 to 410 | The grid rolls away and you got out. +|=== + +Left is always right, so to speak: left from `arrive` goes to `sunset`, left +from `sunset` gets you to `escape`. Right goes to the `pit` both times, and +so does taking too long. + +=== The Map Is Data, Not Code + +Here is the whole game, and it is a table. + +[source,lua] +---- +local scenes = { + arrive = { + first = 0, + last = 119, + ask = 30, + prompt = "The dragon lands. Left or Right?", + left = "sunset", + right = "pit", + goesTo = "pit" + }, + sunset = { + first = 120, + last = 259, + ask = 170, + prompt = "It breathes fire. Duck Left or run Right?", + left = "escape", + right = "pit", + goesTo = "pit" + }, + pit = { + first = 260, + last = 339, + death = true, + goesTo = "arrive" + }, + escape = { + first = 340, + last = 410 + } +} +---- + +A table with names down the left instead of numbers, exactly as in lesson +six, and every value in it is another table. Each of those inner tables is a +*record*: one scene, described field by field. + +`scenes.arrive` is the first scene. `scenes.arrive.first` is `0`. +`scenes.arrive.left` is the string `"sunset"`, which is the name of another +scene in the same table -- which is how the forks are written down. A scene +does not contain the scene after it. It contains the *name* of the scene +after it, and the name is looked up when the time comes. + +The fields mean this: + +`first` and `last`:: The frames the scene runs between. +`ask`:: The frame the question appears on. The player has from `ask` to +`last` to answer, which is the timer. +`prompt`:: What the question says. +`left` and `right`:: The scene each answer leads to. +`goesTo`:: Where the scene goes when nobody chose -- because the player ran +out of time, or because there was no question in the first place. For `pit`, +which is a death, it is where the retry goes back to. +`death`:: Present, and `true`, only on a scene that costs a life. + +Three things are worth noticing about that table. + +The first is that it is the design document. You can hand it to somebody who +has never written a line of Lua and they can read the game out of it. When +you want the dragon to fork three ways, you add a field. When you want +another scene, you add another record. You do not go looking for the code +that plays scenes, because you do not change it. + +The second is that the records are not all the same shape. `pit` has no +`ask`, no `prompt`, and no `left` -- there is nothing to choose in it. +`escape` has no `goesTo`, because it is the end and goes nowhere. A field +that is not in a table reads as `nil`, which is the word you met in lesson +six for "nothing is there", and asking for `scenes.escape.goesTo` is not an +error. It is `nil`, and `nil` is an answer. Code that reads these records has +to be ready for that, and in a minute you will see the one line that is. + +The third is what the table does *not* contain: nothing in it draws, plays, +or waits. It is a description. That separation is the entire trick of this +lesson. + +=== One Function That Plays Any Scene + +This is the code half, and there is only one function of any size. Written in +the threaded model from lesson fifteen, because a branching game is the most +sequential thing there is. + +[source,lua] +---- +local function playScene(name) + local scene = scenes[name] + + discSkipToFrame(scene.first) + while discGetFrame() < scene.last do + local frame = discGetFrame() + local asking = scene.ask ~= nil and frame >= scene.ask + + if not asking then + answer = nil + elseif answer == "left" or answer == "right" then + return scene[answer] + end + drawScene(scene, frame, asking) + singeYield() + end + discPause() + + if scene.death then + lives = lives - 1 + if lives == 0 then + ending = "The dragon wins. Game over." + return nil + end + waitForGo("Press the space bar to try again.") + end + + return scene.goesTo +end +---- + +It takes the name of a scene, plays it, and returns the name of the scene +that comes next. That is its whole contract, and the game is then three +lines: + +[source,lua] +---- +function singeMain() + local scene = "arrive" + + while scene ~= nil do + scene = playScene(scene) + end + waitForGo(ending .. " Space to quit.") +end +---- + +Start at `arrive`. Play whatever scene you are holding, and hold whatever it +hands back. When it hands back `nil` there is no next scene, the loop ends, +and the game is over. + +=== The Whole Thing + +Make `dragon.singe` in your `movie` folder and type this in. It is the +longest script in the book so far, and every piece of it has been explained +except the two small helpers, which come after. + +[source,lua] +---- +local FPS = 30 + +local scenes = { + arrive = { + first = 0, + last = 119, + ask = 30, + prompt = "The dragon lands. Left or Right?", + left = "sunset", + right = "pit", + goesTo = "pit" + }, + sunset = { + first = 120, + last = 259, + ask = 170, + prompt = "It breathes fire. Duck Left or run Right?", + left = "escape", + right = "pit", + goesTo = "pit" + }, + pit = { + first = 260, + last = 339, + death = true, + goesTo = "arrive" + }, + escape = { + first = 340, + last = 410 + } +} + +local answer = nil +local ending = "You got out alive." +local lives = 3 + + +local function drawScene(scene, frame, asking) + overlayClear() + overlayPrint(2, 2, "Lives: " .. lives) + if scene.death then + overlayPrint(2, 4, "That did not go well.") + elseif asking then + overlayPrint(2, 4, scene.prompt) + overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") + end +end + + +local function waitForGo(text) + answer = nil + while answer ~= "go" do + overlayClear() + overlayPrint(2, 2, "Lives: " .. lives) + overlayPrint(2, 4, text) + singeYield() + end +end + + +local function playScene(name) + local scene = scenes[name] + + discSkipToFrame(scene.first) + while discGetFrame() < scene.last do + local frame = discGetFrame() + local asking = scene.ask ~= nil and frame >= scene.ask + + if not asking then + answer = nil + elseif answer == "left" or answer == "right" then + return scene[answer] + end + drawScene(scene, frame, asking) + singeYield() + end + discPause() + + if scene.death then + lives = lives - 1 + if lives == 0 then + ending = "The dragon wins. Game over." + return nil + end + waitForGo("Press the space bar to try again.") + end + + return scene.goesTo +end + + +function onInputPressed(what) + if what == SWITCH_LEFT then + answer = "left" + elseif what == SWITCH_RIGHT then + answer = "right" + elseif what == SWITCH_BUTTON1 then + answer = "go" + end +end + + +function singeMain() + local scene = "arrive" + + while scene ~= nil do + scene = playScene(scene) + end + waitForGo(ending .. " Space to quit.") +end + + +dofile("Singe/Framework.singe") +---- + +Run it: + +---- +Singe -R -v Singe/menuBackground.mkv dragon +---- + +Play it four or five times. Go left twice and get out. Go right and get +eaten. Sit on your hands and get eaten anyway. Lose all three lives. + +=== What Just Happened + +[source,lua] +---- + discSkipToFrame(scene.first) + while discGetFrame() < scene.last do +---- + +A scene is a jump followed by a wait, which is the pattern from lesson +fifteen with the numbers coming out of the record instead of being typed in. +`discSkipToFrame` goes to a frame and plays from it, which is what a scene +change is. + +The frame number is right the instant the call returns, so the `while` never +thinks it is still in the scene you just left. + +[source,lua] +---- + local asking = scene.ask ~= nil and frame >= scene.ask +---- + +The one line that is ready for a missing field, and the reason the death +scene can leave `ask` out. + +`and` means both halves have to be true, as it did in lesson three, and Lua +checks the left half first. If `scene.ask` is `nil` the left half is false, +Lua stops there, and the comparison on the right never happens. Which is just +as well, because comparing `nil` to a number is an error and you will meet it +at the end of this lesson. + +`asking` is the answer to "is the question on screen right now?", worked out +once a frame and used three times. + +[source,lua] +---- + if not asking then + answer = nil + elseif answer == "left" or answer == "right" then + return scene[answer] + end +---- + +While the question is not up, anything the player presses is thrown away. It +has to be, or a player leaning on the left arrow during the film answers a +question they have not read. + +Once the question is up, an answer ends the scene at once. `return +scene[answer]` is worth a second look: `answer` holds the string `"left"` or +`"right"`, and `scene["left"]` is the same thing as `scene.left`. Putting the +name in brackets instead of after a dot lets you look up a field whose name +you are holding in a variable. Two lines of code for both answers, and for +five answers it would still be two lines. + +The `elseif` also explains why only `"left"` and `"right"` are allowed +through. The space bar sets `answer` to `"go"`, and `scene["go"]` is `nil`, +so a stray space bar during a question would end the game in silence. Say +what you accept. + +[source,lua] +---- + discPause() +---- + +Right after the loop, and easy to forget. The scene has reached its last +frame but the disc has no idea the scene is over -- it plays straight on into +whatever footage happens to sit next in the file, which in a real game is the +middle of a scene from somewhere else entirely. Stop it the moment you stop +wanting it. + +[source,lua] +---- + if scene.death then + lives = lives - 1 +---- + +`scene.death` is `nil` for every scene except `pit`, and `nil` counts as +false in an `if`, so this reads as "if this scene was a death". You do not +have to write `death = false` in the other three records, and you should not: +the absence of the field says it just as well, and there is then only one +place that decides which scenes are deadly. + +The retry is the two lines under it. Take a life, and if there are any left, +hold on the frame and wait for the space bar. Then `return scene.goesTo` +sends the player back to `arrive`, and the record is what knows that. Change +`goesTo` to `"sunset"` and the retry becomes a checkpoint instead of a +restart -- one word, and the game's difficulty changes. + +[source,lua] +---- + overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.") +---- + +The timer. `scene.last - frame` is how many frames are left, and dividing by +the frames in a second turns that into seconds. `math.ceil` rounds a number +up to the next whole one, so the countdown reads `3`, `2`, `1` and never +`0.4` or `2.966666`. + +`FPS` is `30` because this video runs at a little over thirty frames a +second. It is the one number in the script that is a guess, and it is a named +guess at the top of the file rather than a `30` buried in the middle of a +line, so that when you change videos there is exactly one thing to change. + +Notice what the timer is measured against: the film. The player is racing the +video, not a clock, so the question can never outstay the shot it belongs to. +That is the reason branching games count in frames. + +=== Why Not Write It Out by Hand + +You could write this game as one long function. Play frames 0 to 119; if +left, play 120 to 259; if left again, play 340 to 410; else play 260 to 339, +take a life, go back to the top. For four scenes it would be shorter than +what you have. + +For forty scenes it would be a disaster, and it is worth being precise about +why. + +The frame numbers would be scattered through the code, each written once and +never named, so changing where a scene starts means finding every place it is +mentioned. The shape of the story would be buried in the shape of the `if` +statements, so nobody could see the game without reading all of it. And +adding a scene would mean editing the playing code, which is the code that +already works -- every new scene another chance to break an old one. + +The version you wrote has one piece of code that plays scenes and one piece +of data that says what the scenes are. Adding a scene touches only the data. +That is worth the extra half hour it took, and it is the same idea underneath +the `games.dat` file from lesson thirteen: describe the thing, then write one +piece of code that reads the description. + +=== Try It + +. *Add an ending.* Give `escape` a question and a second ending scene that + reuses frames from somewhere in the film. Only the table changes. +. *Make it fair.* Lower `ask` in both scenes so the player has five seconds + instead of three, then raise it so they have one. Find the number you would + actually ship. +. *Move the checkpoint.* Change `pit`'s `goesTo` to `"sunset"` and play it. + Then think about what happens if a player dies in `arrive` -- and whether + the death scene should know where it came from rather than being told. +. *Count what the player has seen.* Add a table beside `scenes` that records + which scene names have been played, and show the count in the corner. Now + you have a game that can say "you have found three of the five endings". +. *Break the map.* Change `arrive`'s `left` to `"cave"`, a scene that does + not exist. Run it, go left, and read what Lua says. That error is the most + common one a data-driven game has, and knowing its shape saves an hour. + +=== Break It on Purpose + +Take the guard off the `asking` line, so it reads: + +[source,lua] +---- + local asking = frame >= scene.ask +---- + +Run it and go right. The `arrive` scene plays fine, because `arrive` has an +`ask`. Then the `pit` scene starts, which does not, and the game stops with +this: + +---- +Error executing function 'onOverlayUpdate': dragon.singe:68: attempt to compare nil with number +---- + +followed by a traceback -- a list of the functions that were running at the +moment it went wrong, innermost first. + +Read it the way lesson one taught. The file and the line come first, and they +are exactly right: line 68 is the line you just changed. The complaint is the +rest: you asked Lua whether a number was greater than `nil`, and Lua has no +idea. `scene.ask` is `nil` because the `pit` record has no `ask` field, and a +field that is not there is `nil`. + +One part of that message deserves an explanation, because it is a lie of +omission. The function named is `onOverlayUpdate`, which you did not write. +That is the framework's own, the one lesson fifteen said was installed to +drive your `singeMain`. The error came out through it, so that is the name +the engine had to hand. Ignore it. The file and the line are yours, and they +are the ones that are true. + +=== What You Learned + +* A branching game is a set of scenes, each of which names the scenes that + come after it. +* Keep the scenes as data -- a table of records -- and the code that plays + them stays one function however big the game gets. +* A record holds the name of the next scene, not the scene, and the name is + looked up when it is needed. +* A field that is not in a record is `nil`, which is not an error, and code + that reads records must expect it. +* `scene[answer]` looks up the field whose name is in a variable; + `scene.left` and `scene["left"]` are the same field. +* Put the timer on the video: a choice lasts from one frame to another, so it + can never outlast its own shot. +* Throw away anything the player pressed before the question appeared. +* Pause the disc the moment a scene ends, or it plays on into the next + scene's footage. +* A missing field standing for false -- `death` on three records out of four + -- keeps the description short and honest. +* Comparing `nil` with a number is an error, and `and` is how you avoid it. + +=== Next Time + +Your game asks left or right. The arcade machines this kind of game grew up +in asked something harder: point at the thing on screen and shoot it. Lesson +seventeen is about light guns -- where the player is aiming, what the game +does with that, and the calibration a real cabinet cannot do without. diff --git a/docs/lessons/17-light-guns.adoc b/docs/lessons/17-light-guns.adoc new file mode 100644 index 000000000..91e2ac68c --- /dev/null +++ b/docs/lessons/17-light-guns.adoc @@ -0,0 +1,550 @@ +== Lesson 17: Light Guns + +image::learn/17-light-guns.png[The finished lesson, 480] + +You are going to make a shooting gallery. Targets appear over the video at +frames you choose, you aim at the screen and pull a trigger, and the ones you +hit score. + +You do not need a light gun to write this. You need a mouse, because to Singe +a light gun *is* a mouse: it reports a position in the same coordinates, +through the same calls, and its trigger arrives as the same switch as a mouse +button. Write the game for the mouse on your desk and it works on a cabinet +with a gun bolted to it, which is the only sane way round to do it. A gun is +not a different kind of input. It is a mouse that somebody aimed. + +=== What You Are Pointing At + +Start the same way as lesson fourteen, with the video the engine unpacked for +you: + +---- +Singe -R -v Singe/menuBackground.mkv gallery +---- + +That footage is a purple grid and a sunset. It is not a bank robbery. Every +frame number in this lesson refers to it so that the code runs the day you +type it, and the whole clip is 720 by 480 and about 420 frames long, which at +roughly thirty frames a second is fourteen seconds. Imagine better footage. +The code does not care what is in the picture. + +=== Match the Overlay to the Video + +Put this at the top of `gallery.singe`, under the `dofile` line: + +[source,lua] +---- +overlaySetResolution(discGetWidth(), discGetHeight()) +---- + +Without that call the overlay is *half* the video in each direction -- 360 by +240 here -- and every coordinate you draw with, and every mouse position you +are handed, is in that smaller space. Nothing is wrong with the small overlay, +but it is one more conversion to keep in your head while you are working out +where a shot landed. Matching the video means a target at `x = 80` is eighty +pixels across the picture, and so is a shot at `x = 80`. The manual's entry +for `overlaySetResolution` explains what it costs. + +=== Where the Gun Is Pointing + +One call answers that: + +[source,lua] +---- +local x, y = mouseGetPosition(0) +---- + +`mouseGetPosition` hands back two numbers, which is something you have not +seen a function do before and which Lua is perfectly happy about: a function +may return as many values as it likes, and you catch them by listing that many +names on the left of the `=`. The `0` is *which* pointing device you are +asking about, counting from zero. With one mouse, or one gun, the answer is +always device `0`. + +The numbers are in overlay coordinates, not desktop pixels, which is why the +call above was worth making. The position is the last one the device reported, +so you can ask for it whenever you like, as often as you like. Ask for it in +`onOverlayUpdate` and draw with the answer. + +There is another way to get the same numbers. `onMouseMoved` is a callback, +like `onOverlayUpdate`, and the engine calls it every time a pointer moves, +handing it the new position. It is the right choice when you want to *react* +to movement. For drawing a crosshair you do not: you want to know where the +thing is right now, and `mouseGetPosition` tells you that without your having +to keep a copy. + +=== Drawing a Crosshair + +The engine will not draw one for you. A crosshair is four lines and a circle: + +[source,lua] +---- +local function drawCrosshair(x, y) + colorForeground(255, 255, 255, 255) + overlayCircle(x, y, 10) + overlayLine(x - 18, y, x - 4, y) + overlayLine(x + 4, y, x + 18, y) + overlayLine(x, y - 18, x, y - 4) + overlayLine(x, y + 4, x, y + 18) +end +---- + +The gaps matter more than the lines. Leaving the four spokes short of the +circle gives you a hole in the middle to see the target through, which is the +whole point of a crosshair and the first thing beginners draw over. + +One thing you should ask before drawing it: + +[source,lua] +---- +local drawAim = singeWantsCrosshairs() +---- + +That returns `false` when the player started Singe with `--nocrosshair`. Some +light guns put a spot of their own on the screen, and two crosshairs a few +pixels apart are worse than none. The answer cannot change while the game +runs, so read it once at the top and keep it in a variable. + +You do not have to hide the system pointer, because Singe already has. It +grabs the mouse at startup, which confines it to the window and hides the +desktop arrow. If you ever want it back -- for a level editor, say -- +`mouseSetCaptured(false)` releases it, and the key mapped to `INPUT_GRAB`, +which is `G` in the shipped controls, toggles the same thing while you play. + +=== The Trigger + +A trigger is a button, and buttons arrive at `onInputPressed`, which you first +met in lesson three: + +[source,lua] +---- +function onInputPressed(what, device) + if what ~= SWITCH_BUTTON3 then + return + end + + local x, y = mouseGetPosition(device or 0) + -- ... +end +---- + +`SWITCH_BUTTON3` is the left mouse button, and on a gun it is the trigger. The +shipped `controls.cfg` maps the left button to `SWITCH_BUTTON3`, the right to +`SWITCH_BUTTON1`, and the middle to `SWITCH_BUTTON2`. Those numbers look +shuffled because they are: the switches are the arcade panel's buttons, and +which physical control is wired to which is a cabinet's business, not the +game's. Left Shift is mapped to `SWITCH_BUTTON3` too, so you can fire without +a mouse while you are testing. + +The second argument is the new part. When the switch came from a mouse or a +light gun, `device` is that device's index -- `0` for the first, `1` for the +second, and so on. When it came from a key or a gamepad it is `nil`, because +no pointing device was involved. That is why the call above says `device or +0`: if `device` is `nil`, use `0` instead. You will use `or` like that +constantly, and it reads exactly as it sounds -- "device, or nothing, in which +case zero". + +That one extra argument is what makes a two gun cabinet possible. Player one's +gun is device `0` and player two's is device `1`, and the same +`onInputPressed` tells them apart without your guessing. + +=== Did It Hit Anything + +You already know how to answer this. It is lesson eleven: + +[source,lua] +---- +if collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then +---- + +A shot is a point. A target is a rectangle. `collidePointRect` asks whether +the point is inside the rectangle, and the edge counts as inside. There is +nothing special about shooting. A gun game's hit test is the same hit test a +platform game uses to find out whether you landed on a block, and the manual's +`collide` family has the rest of the shapes. + +=== Targets That Come and Go + +The only thing video changes is *when* a target exists. In lesson eleven a rock +was there until you shot it. Here a target is on screen while the film is +showing the part of the scene it belongs to, and then it is gone whether you +shot it or not. + +That is one more field on the record, and lesson six built you the tool: + +[source,lua] +---- +local targets = { + { first = 40, last = 110, x = 80, y = 110 }, + { first = 120, last = 190, x = 470, y = 140 }, + { first = 200, last = 270, x = 290, y = 250 }, + { first = 280, last = 360, x = 150, y = 300 }, +} +---- + +Four records, each with the frame the target appears on, the frame it leaves +on, and where it sits. Adding a fifth target is adding a line. That is the +shape to reach for every time you find yourself about to write the same four +lines of code with different numbers in them. + +Whether a target counts is then a small function of its own: + +[source,lua] +---- +local function targetIsUp(target, frame) + return not target.hit and frame >= target.first and frame <= target.last +end +---- + +The drawing asks it, and so does the shooting, which is the point of putting +it in one place: a target you can see and a target you can hit can never +disagree. + +=== The Whole Thing + +[source,lua] +---- +-- Learn to Program with Singe -- Lesson 17: Light Guns + +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local FIRST_FRAME = 20 +local LAST_FRAME = 400 +local TARGET_SIZE = 96 +local HIT_SCORE = 100 +local FLASH_TIME = 12 + +local targets = { + { first = 40, last = 110, x = 80, y = 110 }, + { first = 120, last = 190, x = 470, y = 140 }, + { first = 200, last = 270, x = 290, y = 250 }, + { first = 280, last = 360, x = 150, y = 300 }, +} + +local aimX = overlayGetWidth() / 2 +local aimY = overlayGetHeight() / 2 +local score = 0 +local shots = 0 +local hits = 0 +local flash = 0 +local flashX = 0 +local flashY = 0 +local drawAim = singeWantsCrosshairs() + + +local function drawCrosshair(x, y) + colorForeground(255, 255, 255, 255) + overlayCircle(x, y, 10) + overlayLine(x - 18, y, x - 4, y) + overlayLine(x + 4, y, x + 18, y) + overlayLine(x, y - 18, x, y - 4) + overlayLine(x, y + 4, x, y + 18) +end + + +local function targetIsUp(target, frame) + return not target.hit and frame >= target.first and frame <= target.last +end + + +local function startRound() + score = 0 + shots = 0 + hits = 0 + flash = 0 + for _, target in ipairs(targets) do + target.hit = false + end + discSkipToFrame(FIRST_FRAME) +end + + +function onInputPressed(what, device) + if what == SWITCH_START1 then + startRound() + return + end + + if what ~= SWITCH_BUTTON3 then + return + end + + local x, y = mouseGetPosition(device or 0) + local frame = discGetFrame() + + shots = shots + 1 + + for _, target in ipairs(targets) do + if targetIsUp(target, frame) and collidePointRect(x, y, target.x, target.y, TARGET_SIZE, TARGET_SIZE) then + target.hit = true + hits = hits + 1 + score = score + HIT_SCORE + flash = FLASH_TIME + flashX = x + flashY = y + return + end + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + if frame >= LAST_FRAME then + startRound() + frame = FIRST_FRAME + end + + aimX, aimY = mouseGetPosition(0) + + overlayClear() + + for _, target in ipairs(targets) do + if targetIsUp(target, frame) then + colorForeground(255, 200, 0, 255) + overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) + overlayCircle(target.x + TARGET_SIZE / 2, target.y + TARGET_SIZE / 2, TARGET_SIZE / 3) + end + end + + if flash > 0 then + colorForeground(255, 255, 255, 255) + overlayCircle(flashX, flashY, 30 - flash * 2) + flash = flash - 1 + end + + overlayPrint(2, 1, "SCORE " .. score .. " HITS " .. hits .. "/" .. shots .. " FRAME " .. frame) + + if drawAim then + drawCrosshair(aimX, aimY) + end + + return OVERLAY_UPDATED +end + + +startRound() +---- + +Aim at a yellow box and click. The counter in the corner goes up, a white ring +opens where you hit, and the box disappears. Miss and the shot count goes up +on its own, which is the cheapest scoreboard there is and tells a player more +about how they are doing than the score does. The round restarts by itself +when the film runs out, and `1` restarts it whenever you like. + +=== What Just Happened + +[source,lua] +---- +local frame = discGetFrame() + +if frame >= LAST_FRAME then + startRound() + frame = FIRST_FRAME +end +---- + +The video is the clock, as it was in lesson fourteen, and `discGetFrame` is +how you read it. Everything else in the frame is decided from that one number. +When the film runs off the end of the last target, the round starts over: +`startRound` clears the score, marks every target unshot, and sends the disc +back to the beginning with `discSkipToFrame`. The line under it sets the local +copy of `frame` as well, because `discSkipToFrame` asks the disc to move and +`discGetFrame` will not catch up until the next frame. Without it the drawing +below would spend one frame working from a number that is no longer true. + +[source,lua] +---- + shots = shots + 1 + + for _, target in ipairs(targets) do + if targetIsUp(target, frame) and collidePointRect(...) then +---- + +Count the shot before you look for a hit, so that a miss counts too. Then walk +the list. `return` inside the loop stops at the first target hit, which means +one bullet cannot take two overlapping targets -- a rule you have to decide +one way or the other, and this is the line that decides it. + +[source,lua] +---- +overlayBox(target.x, target.y, target.x + TARGET_SIZE - 1, target.y + TARGET_SIZE - 1) +---- + +`overlayBox` wants two opposite corners, not a corner and a size, so the far +corner is the near one plus the size. The `- 1` is because both corners are +included: a box from `80` to `175` is ninety six pixels wide. `collidePointRect` +wants a corner and a size instead, which is why `TARGET_SIZE` goes in twice +there and not at all here. Mixing those two up is a genuinely common bug, and +it shows as targets you can hit slightly outside where they are drawn. + +[source,lua] +---- +if flash > 0 then + colorForeground(255, 255, 255, 255) + overlayCircle(flashX, flashY, 30 - flash * 2) + flash = flash - 1 +end +---- + +`flash` is a countdown in frames, set to twelve when a shot lands and reduced +by one every time the screen is drawn. Because the radius is worked out *from* +the countdown, the ring grows as the number shrinks. Twelve frames is under +half a second. Feedback that lasts longer than that stops feeling like a +consequence of the trigger and starts feeling like weather. + +=== Two Guns + +You are two changes away from a two player cabinet, and neither is in the game +loop. + +The first is `mouseSetMode(MOUSE_MANY)`, which tells Singe to read each device +separately instead of pooling them into one cursor. `mouseHowMany()` says how +many it found at startup, so a game normally asks before it commits: + +[source,lua] +---- +if mouseHowMany() >= 2 then + mouseSetMode(MOUSE_MANY) +end +---- + +After that, `mouseGetPosition(1)` is the second gun, and the `device` argument +of `onInputPressed` says which gun fired. Keep two crosshairs, two scores, and +one list of targets, and you have a co-operative game. + +The second change is not in your script at all. The shipped `controls.cfg` +binds only the *first* mouse's buttons, so the second gun's trigger reaches +nothing. A cabinet with two guns needs its own line in that file naming both: + +[source,lua] +---- +INPUT_ACTION_3 = { SCANCODE.LSHIFT, GAMEPAD_0.BUTTON_X, MOUSE_0.BUTTON_LEFT, MOUSE_1.BUTTON_LEFT } +---- + +Say so in your instructions. A player whose second gun does nothing will blame +your game, and they will be half right. + +=== The Other Way to Test a Hit + +There is a second way to answer "did that shot hit something", and it is worth +knowing about even though you are not going to use it today. `vldpGetPixel(x, +y)` reads the colour of one pixel of the video frame currently on screen. +Games have used it since the very first version of Singe: paint every target +in the footage a colour that appears nowhere else, and on a trigger pull ask +what colour is under the crosshair. + +It buys you hit shapes that follow the action exactly, for free, with no +rectangles to author. It costs you footage you have to prepare that way, and a +hit test you cannot see or debug. The manual's entry for `vldpGetPixel` +has the details. Rectangles are the right default; keep this in your pocket. + +=== What Only a Cabinet Cares About + +Everything above works on your desk. Several things do not exist on your desk +at all, and this is a good moment to name them so that they are not a surprise +later. + +*Calibration.* A real light gun has to be told where the screen is, because it +is a camera looking at a monitor from wherever the player is standing. Singe's +service tools have a Light Gun screen that puts targets in the middle and at +each corner and marks where the gun actually pointed, so you can see which way +it is out. Your game does not do the calibrating and should not try. + +*The Sinden border.* One popular family of guns finds the screen by looking +for a bright frame drawn around the picture. `--sindengun` draws that frame, +and the engine maps positions back into the smaller picture inside it, so your +game keeps getting coordinates that mean what they used to mean. + +*Screens that are not the shape of the film.* `ratioGetX()` and `ratioGetY()` +hand you the numbers a player gave on the command line for a display whose +proportions do not match the footage. Singe does not apply them; a game that +wants them does the arithmetic itself. + +*Guns going away.* `SWITCH_MOUSE_DISCONNECT` arrives at `onInputPressed` when +a gun is unplugged. No button produces it; the engine raises it. A game that +watches for it can put "PLAYER 2 GUN DISCONNECTED" on screen instead of +appearing to have died. + +*No mouse at all.* A cabinet with a joystick and no pointing device can drive +the cursor from the stick, which `joyMouseEnable` turns on. + +None of that changes the game you wrote. All of it is lesson thirty. + +=== Try It + +. *Move a target.* Change the third target's `x` to `680` and run it. Most of + it now hangs off the right edge, because the overlay is 720 wide and the + target is 96. You can still shoot the sliver that is left. Decide what you + want to happen at the edges. +. *Make the targets easier.* Change `TARGET_SIZE` to `140`. Notice that the + drawing and the hit test both change, and that you only edited one number. + That is what a named constant is for. +. *Keep score for accuracy.* Add a line that prints `hits` as a percentage of + `shots`. Guard against dividing by zero before the first shot, and find out + what Lua prints if you do not. +. *Make a target punish you.* Add a `penalty = true` field to one record and + subtract points when it is hit. Notice you are adding a field to one record, + not to all four, and that the ones without it have `nil` there. +. *Take the `return` out* of the loop in `onInputPressed`, then overlap two + targets by giving them the same frames and nearly the same position. One + shot now takes both. Decide which behaviour your game wants. + +=== Break It on Purpose + +Change the fire test to use `SWITCH_BUTTON1` instead of `SWITCH_BUTTON3` and +run it. Nothing breaks. No error appears. The left button stops doing +anything, and the *right* button now fires. + +This is the worst kind of bug and the most common one in input code: the +program is not wrong, it is just wired to the wrong thing. There is no error +message to read, because you asked a sensible question about a switch that +exists and it answered honestly. + +Two things find it. The first is `debugPrint`, from lesson eight: + +[source,lua] +---- +function onInputPressed(what, device) + debugPrint("switch " .. what .. " from device " .. tostring(device)) +---- + +Now every press prints a line, and you can see what the trigger actually +sends. Note `tostring(device)`: `device` is `nil` for a key press, and joining +`nil` onto a string with `..` is an error. `tostring` turns it into the word +`nil` and the line prints. + +The second is the engine's own Input Test, in the service tools, which shows +every device it can see and the last switch the game would have received. When +a button does nothing, that screen tells you which half of the problem you +have: either the switch is arriving and your game is ignoring it, or it is not +arriving at all. + +=== What You Learned + +* A light gun reports as a mouse, so a game written for the mouse works with a + gun. +* `mouseGetPosition(device)` returns two values, the pointer's position in + overlay coordinates. +* A function in Lua can return more than one value, and you catch them by + listing that many names. +* `singeWantsCrosshairs()` says whether to draw your own reticle. Read it once. +* Singe grabs and hides the system pointer at startup; you do not have to. +* `onInputPressed` takes a second argument naming which mouse or gun fired, + and `nil` when it was a key or a pad. +* A hit test against a rectangle is `collidePointRect`, the same collision you + learned in lesson eleven. +* A target's frame window is just two more fields on its record. +* `a or b` gives you `b` when `a` is `nil`. +* Calibration, the Sinden border, and two gun wiring live outside your script. + +=== Next Time + +Your gallery asks for one thing: hit that, there. The other arcade convention +video games are built on asks for something harder -- press *this*, *now*, and +you have half a second. Lesson eighteen builds a quick-time event, with a +prompt, a window measured in frames, and three different endings depending on +whether you were right, wrong, or too slow. diff --git a/docs/lessons/18-qte.adoc b/docs/lessons/18-qte.adoc new file mode 100644 index 000000000..210863482 --- /dev/null +++ b/docs/lessons/18-qte.adoc @@ -0,0 +1,589 @@ +== Lesson 18: Quick-Time Events + +image::learn/18-qte.png[The finished lesson, 480] + +A prompt appears. You have half a second. Press the right thing and the story +goes on; press the wrong thing, or nothing at all, and it goes somewhere else. + +That is the whole idea, and a surprising number of video games are made of +almost nothing else. You already have every piece: lesson fourteen gave you +frames, lesson six gave you a list of records, lesson eleven gave you input, +and lesson sixteen gave you branching. This lesson puts them together and adds +one thing that is genuinely new -- a window of time, and what happens when it +closes. + +=== The Clock Is the Film + +Everything here is measured in *frames*, not in seconds, and that is worth +stopping on because it is the single decision that makes this work. + +Lesson fourteen said a frame is the address of a moment in the film. The +prompt has to appear when the actor starts to swing, and the window has to +close when the fist arrives. Those are not times, they are places in the +footage, and you found them by stepping through it. If you write the window in +seconds you have to start a stopwatch when the prompt appears, and then the +two clocks -- yours and the film's -- can drift apart. If the video stutters +on a slow machine, your stopwatch keeps running and the player is punished for +their hardware. + +Frames cannot drift, because there is only one clock. `discGetFrame()` is the +time, always. A window is two frame numbers, and "how long is left" is +subtraction. + +Run with the same video as before: + +---- +Singe -R -v Singe/menuBackground.mkv moves +---- + +Still the purple grid and the sunset. Picture a fight. + +=== A Move Is a Record + +Here is the table the whole lesson hangs on: + +[source,lua] +---- +local moves = { + { first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" }, + { first = 140, last = 185, switch = SWITCH_UP, name = "UP" }, + { first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" }, + { first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" }, +} +---- + +Four moves. Each one says when the window opens, when it closes, which switch +answers it, and what to put on screen. Forty five frames is about a second and +a half at this video's rate, which is generous; real games often give you less +than half that. + +Everything that makes a move a move is in that table. There is no code +anywhere below that knows there are four moves, or that the second one is up, +or that the first window opens at frame sixty. Adding a fifth move is adding a +line. Retiming every move is editing numbers. Nobody has to read a function to +change the fight. + +That is not a trick for this lesson. It is the difference between a game you +can tune and a game you cannot, and it costs nothing to do from the start. +When you find yourself about to write `if frame == 60 then` a fourth time, +stop and make a table instead. + +=== What Happens When + +Three things can happen to a move, and they are three different outcomes, not +two: + +* The player presses the right switch inside the window. Score it and play on. +* The player presses a *wrong* switch inside the window. They tried and failed. +* The window closes with no answer at all. They froze. + +Lazy code treats the last two the same. Games almost never do, because they +feel completely different to play: guessing wrong is a mistake, and freezing is +a different mistake, and the film usually goes somewhere different for each. +Here the wrong answer sends the disc to frame `362` and no answer sends it to +frame `388`, which on this footage means two slightly different amounts of +sunset. On real footage they would be two different shots of you losing. + +So each record grows a fourth field while the run is going -- `answer` -- which +is `nil` until something happens to the move, and then one of three strings: + +[source,lua] +---- +move.answer = "right" +move.answer = "wrong" +move.answer = "late" +---- + +`nil` is the value Lua gives you for a field nobody has set. You met it in +lesson six. Here it earns its keep: "no answer yet" is genuinely a different +state from any of the three answers, and `nil` says so without your inventing a +word for it. + +=== Finding the Move That Is Being Asked + +Everything else needs to know one thing: which move, if any, is open right +now? + +[source,lua] +---- +local function moveAt(frame) + for _, move in ipairs(moves) do + if move.answer == nil and frame >= move.first and frame <= move.last then + return move + end + end + return nil +end +---- + +Walk the list, and answer with the first move whose window covers this frame +and which has not been answered. If nothing matches, answer `nil`. + +Returning `nil` on purpose is worth noticing. A function that sometimes has no +answer should say so, and "no move is open" is a perfectly good answer. The +caller then writes `if move ~= nil then`, and the code that draws the prompt +never runs when there is nothing to prompt for. + +The `move.answer == nil` test is what stops a move being asked twice. The +moment the player gets it right, the prompt vanishes even though the window is +still open -- which is exactly what it should do, because they have answered. + +=== Reading the Answer + +[source,lua] +---- +function onInputPressed(what) + if not isDirection(what) then + return + end + + local move = moveAt(discGetFrame()) + if move == nil then + return + end + + if what == move.switch then + move.answer = "right" + score = score + RIGHT_SCORE + else + move.answer = "wrong" + endRun("WRONG WAY", WRONG_FRAME) + end +end +---- + +Read it top to bottom. If the switch is not one of the four directions, it is +none of our business -- leave. If no move is open, a direction press means +nothing -- leave. Otherwise there is exactly one move to compare against, and +one comparison to make. + +Both of those early `return` lines are guards: they throw out the cases you do +not care about so that the code underneath only ever runs in the one situation +it was written for. Writing it the other way round, with the real work nested +three levels deep inside three `if` blocks, says the same thing and is much +harder to read. + +`isDirection` is a tiny function that does nothing but keep that first line +short: + +[source,lua] +---- +local function isDirection(what) + return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT +end +---- + +Notice that it returns the comparison itself rather than saying `if ... then +return true else return false end`. A comparison is already `true` or `false`. +Testing whether something is true so that you can say it is true is a habit +worth losing early. + +=== The Window Closing + +Nobody presses anything, and the frames keep going by. Something has to notice. +That something is `onOverlayUpdate`, because it runs every frame whether or not +the player does anything: + +[source,lua] +---- +if not runOver then + for _, move in ipairs(moves) do + if move.answer == nil and frame > move.last then + move.answer = "late" + endRun("TOO SLOW", LATE_FRAME) + break + end + end +end +---- + +A move that is unanswered and whose last frame has gone past was missed. +`break` leaves the loop immediately, because once the run is over there is no +point looking at the rest. + +`endRun` is the branch, and it is the same branch you wrote in lesson sixteen: + +[source,lua] +---- +local function endRun(reason, frame) + verdict = reason + runOver = true + discSkipToFrame(frame) +end +---- + +Three lines. Remember what to say, remember that the run is finished, and send +the film somewhere else. A missed quick-time event is not a special mechanism. +It is a choice the player made by not making one, and the film branches on it +exactly as it branches on a door they picked. + +=== A Gauge That Drains + +The player cannot see frame numbers. They need to see time running out, and +they need to see it without looking away from the action, which means a shape +that changes rather than a number that counts. + +Two numbers give you everything: + +[source,lua] +---- +local left = move.last - frame +local span = move.last - move.first +---- + +`span` is how long the window is. `left` is how much of it remains. The bar is +`left` out of `span` of the full width: + +[source,lua] +---- +fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H) +---- + +`//` is division that throws away the fraction, which you want here because a +bar three hundred and seventeen and a half pixels wide is not a thing. Multiply +before you divide -- `GAUGE_W * left // span` and not `GAUGE_W * (left // +span)` -- or the fraction is thrown away while it is still the only information +you have, and the bar jumps between full and empty with nothing in between. + +There is no filled rectangle in the overlay. `overlayBox` draws an outline, +which is what you want for the frame around the gauge, and the fill is your +own: + +[source,lua] +---- +local function fillBar(x, y, width, height) + if width < 1 then + return + end + for row = 0, height - 1 do + overlayLine(x, y + row, x + width - 1, y + row) + end +end +---- + +Eighteen horizontal lines stacked on top of each other. The guard at the top +matters: at the very last frame of the window `left` is zero, the width is +zero, and without it `x + width - 1` is one pixel to the *left* of `x` and you +draw a short backwards line instead of nothing. + +The last touch is a colour change when it gets desperate: + +[source,lua] +---- +if left * 3 < span then + colorForeground(255, 60, 60, 255) +else + colorForeground(255, 200, 0, 255) +end +---- + +The bar turns red in its last third. Written that way -- multiplying the time +left rather than dividing the span -- it stays whole numbers and works for a +window of any length. + +=== The Whole Thing + +[source,lua] +---- +-- Learn to Program with Singe -- Lesson 18: Quick-Time Events + +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local FIRST_FRAME = 20 +local WRONG_FRAME = 362 +local LATE_FRAME = 388 +local LAST_FRAME = 415 + +local PROMPT_COL = 54 +local PROMPT_ROW = 22 +local GAUGE_X = 210 +local GAUGE_Y = 330 +local GAUGE_W = 300 +local GAUGE_H = 18 + +local RIGHT_SCORE = 100 + +local moves = { + { first = 60, last = 105, switch = SWITCH_LEFT, name = "LEFT" }, + { first = 140, last = 185, switch = SWITCH_UP, name = "UP" }, + { first = 220, last = 265, switch = SWITCH_RIGHT, name = "RIGHT" }, + { first = 300, last = 345, switch = SWITCH_DOWN, name = "DOWN" }, +} + +local score = 0 +local runOver = false +local verdict = "" + + +local function fillBar(x, y, width, height) + if width < 1 then + return + end + for row = 0, height - 1 do + overlayLine(x, y + row, x + width - 1, y + row) + end +end + + +local function isDirection(what) + return what == SWITCH_UP or what == SWITCH_DOWN or what == SWITCH_LEFT or what == SWITCH_RIGHT +end + + +local function moveAt(frame) + for _, move in ipairs(moves) do + if move.answer == nil and frame >= move.first and frame <= move.last then + return move + end + end + return nil +end + + +local function startRun() + score = 0 + runOver = false + verdict = "" + for _, move in ipairs(moves) do + move.answer = nil + end + discSkipToFrame(FIRST_FRAME) +end + + +local function endRun(reason, frame) + verdict = reason + runOver = true + discSkipToFrame(frame) +end + + +function onInputPressed(what) + if runOver then + if what == SWITCH_START1 then + startRun() + end + return + end + + if not isDirection(what) then + return + end + + local move = moveAt(discGetFrame()) + if move == nil then + return + end + + if what == move.switch then + move.answer = "right" + score = score + RIGHT_SCORE + else + move.answer = "wrong" + endRun("WRONG WAY", WRONG_FRAME) + end +end + + +function onOverlayUpdate() + local frame = discGetFrame() + + if not runOver then + for _, move in ipairs(moves) do + if move.answer == nil and frame > move.last then + move.answer = "late" + endRun("TOO SLOW", LATE_FRAME) + break + end + end + end + + if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then + if not runOver then + verdict = "CLEAR" + runOver = true + end + discSearch(LAST_FRAME) + end + + overlayClear() + + local move = nil + if not runOver then + move = moveAt(frame) + end + + if move ~= nil then + local left = move.last - frame + local span = move.last - move.first + + overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name) + + colorForeground(255, 255, 255, 255) + overlayBox(GAUGE_X - 2, GAUGE_Y - 2, GAUGE_X + GAUGE_W + 1, GAUGE_Y + GAUGE_H + 1) + + if left * 3 < span then + colorForeground(255, 60, 60, 255) + else + colorForeground(255, 200, 0, 255) + end + fillBar(GAUGE_X, GAUGE_Y, GAUGE_W * left // span, GAUGE_H) + end + + overlayPrint(2, 1, "SCORE " .. score .. " FRAME " .. frame) + + if runOver then + overlayPrint(2, 3, verdict) + overlayPrint(2, 4, "PRESS 1 TO RUN IT AGAIN") + end + + return OVERLAY_UPDATED +end + + +startRun() +---- + +Arrow keys answer the prompts. Get all four and the film runs to the end and +holds there on `CLEAR`. Get one wrong, or sit on your hands, and it cuts to a +different frame with a different word on screen. `1` runs it again. + +=== What Just Happened + +Most of it you have already read. Four pieces are worth a second look. + +[source,lua] +---- +if frame >= LAST_FRAME and discGetState() == DISC_PLAYING then +---- + +The end of the film has to be caught, or it runs off the end and there is +nothing to see. `discSearch` goes to a frame and *pauses* there, which is +exactly what a final shot wants. The `discGetState()` half stops that +happening again on every one of the following frames: once the disc is paused +it is no longer `DISC_PLAYING`, so the condition is false and the seek is not +repeated sixty times a second. `discGetState` and the `DISC_*` constants are +in the manual under `discGetState`. + +[source,lua] +---- +local move = nil +if not runOver then + move = moveAt(frame) +end +---- + +`onOverlayUpdate` asks for the open move again, rather than being told by +`onInputPressed`. That is deliberate. There is exactly one function that knows +which move is open, and everybody asks it. If the prompt came from a variable +that the input code kept up to date, then two pieces of code would be +remembering the same fact, and sooner or later one of them would be wrong. +When you are choosing between asking and remembering, ask. + +[source,lua] +---- +overlayPrint(PROMPT_COL, PROMPT_ROW, "PRESS " .. move.name) +---- + +`overlayPrint` counts in character cells, as it has since lesson one, so the +prompt is positioned by eye: column fifty four is near enough the middle for +words of this length. It is near enough, not right, and "PRESS RIGHT" sits a +character further left than "PRESS UP". Lesson nineteen loads a real font, and +with it the ability to measure a string in pixels and centre it properly. + +[source,lua] +---- +function onInputPressed(what) + if runOver then + if what == SWITCH_START1 then + startRun() + end + return + end +---- + +One callback, two completely different jobs, chosen by `runOver`. While the +run is going the directions mean moves; once it is over the only key that +means anything is start. A variable that decides which rules apply is a *state*, +which lesson twelve called by that name, and a game is mostly a small number +of them. + +=== Try It + +. *Make it harder.* Change the first move's `last` from `105` to `75`. Fifteen + frames is half a second. Play it and find out whether you can. +. *Add a move.* Put a fifth line in the table with a window between frames + `370` and `400`, and push `LAST_FRAME` up to `418`. You should not have to + touch any other line. If you do, something in the code knows too much about + the table. +. *Score the speed.* Award more points for answering early. In + `onInputPressed`, where the right answer is scored, work out `left` and + `span` from `move` and `discGetFrame()` the same way the gauge does, and add + `RIGHT_SCORE * left // span` instead of `RIGHT_SCORE`. Decide whether that + makes the game better or only busier. +. *Give a wrong answer its own consequence.* Instead of ending the run, let a + wrong press cost fifty points and leave the window open to try again. You + will need to stop setting `move.answer`, and you will meet the reason it was + set in the first place. +. *Two prompts at once.* Overlap two moves' windows and see what happens. + `moveAt` returns the first match, so only one is ever asked. Decide what you + would want, and what it would take. + +=== Break It on Purpose + +Delete the `break` from the loop that finds missed moves, and miss the first +one on purpose. The run ends, the disc jumps, and everything looks right. + +Now put `debugPrint(move.name .. " missed")` next to `move.answer = "late"` +and do it again. The console shows one line, as you would expect. + +Then take out the `if not runOver then` around the loop as well, and miss the +first move again: + +---- +LEFT missed +UP missed +RIGHT missed +DOWN missed +---- + +The first line arrives when the window closes, as before. The other three +arrive together on the very next frame, the instant the disc lands at 388: +every remaining window is now in the past, so every unanswered move is missed +at once, and each one calls `endRun` and seeks all over again. The game still +more or less works, which is what makes it dangerous. Three extra seeks in a +single frame, three overwrites of the verdict, and nothing on screen says so. + +There is no error message here either. The lesson is that `break` and that +guard are not tidiness. They are the two lines that say "this has already been +decided", and code that keeps deciding something it has already decided is one +of the most reliable sources of bugs you will ever write. + +=== What You Learned + +* A quick-time event is a prompt, a window, and a consequence. +* Measure the window in frames, because the film is the clock and there must + only ever be one clock. +* Keep the moves in a table of records, so that adding a move is adding a line. +* Right, wrong, and no answer are three outcomes, and they usually lead three + different places. +* `nil` in a record field is a real state: nobody has answered yet. +* One function answers "which move is open", and everybody asks it rather than + keeping a copy. +* Guard clauses -- early `return` for the cases you do not care about -- keep + the real work unnested. +* `//` divides and throws away the fraction; multiply before you divide. +* `overlayBox` draws an outline, so a filled bar is a stack of `overlayLine` + calls. +* A missed move is a branch, and `discSkipToFrame` takes it, exactly as in + lesson sixteen. + +=== Next Time + +Part three is done. You can play film, wait on it, branch on it, aim at it, +and time the player against it. What you have been writing on top of it all is +the engine's console font, which is six pixels wide and meant for diagnostics. +Lesson nineteen loads a real typeface, measures it, and makes the words on +screen look like they belong to the game. diff --git a/docs/lessons/19-text.adoc b/docs/lessons/19-text.adoc new file mode 100644 index 000000000..86ebd7f5f --- /dev/null +++ b/docs/lessons/19-text.adoc @@ -0,0 +1,491 @@ +== Lesson 19: Text That Looks Good + +image::learn/19-text.png[The finished lesson, 480] + +Every word you have put on screen since lesson one came out of `overlayPrint`, +and `overlayPrint` has exactly one font: a fixed-width block of pixels built +into the engine, the same size forever, in whatever colour it happens to be. +It is perfect for a frame counter. It is wrong for a title, a score, a menu, or +anything a player is meant to look at rather than read past. + +This lesson loads a real font off disk, prints with it, colours it, measures it +so you can centre things, and shows you the one performance trap that catches +everybody. At the end you will have a title screen that looks deliberate +instead of accidental. + +=== Set the Overlay to a Size You Can Think In + +Start a new file called `text.singe` with two lines: + +[source,lua] +---- +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) +---- + +The first line you have written many times; it loads the framework, which +defines `DIR`, the `SWITCH_*` names, and the `FONT_QUALITY_*` names you are +about to use. + +The second matters more than it looks. The overlay -- the surface everything +you draw lands on -- starts at *half* the size of the world. A game with no +video has a 720 by 480 canvas, so the overlay starts at 360 by 240, and a +36 point font on a 360 pixel wide overlay is enormous. `overlaySetResolution` +replaces the overlay with one of the size you ask for, and from then on every +coordinate you give, and everything `overlayGetWidth` and `overlayGetHeight` +tell you, is in that space. + +`discGetWidth()` and `discGetHeight()` answer the size of the video when there +is one and the size of the canvas when there is not, so that one line gives you +an overlay that matches the world whichever kind of game this is. Call it once, +at the start, before you draw anything. + +=== Load a Font + +Under that, add: + +[source,lua] +---- +fontQuality(FONT_QUALITY_BLENDED) + +local titleFont = fontLoad("Singe/FreeSansBold.ttf", 48) +---- + +`fontLoad` opens a TrueType font file and hands you back a number -- a *handle* +-- that you use whenever you want that font. The second argument is the point +size, and it is fixed for the life of the handle. There is no way to change the +size of a loaded font afterwards. If you want the same face at three sizes, you +call `fontLoad` three times and keep three handles. + +`Singe/FreeSansBold.ttf` is the font the engine unpacked into the `Singe` +folder in your work folder the first time you ran anything. Every copy of Singe +has it, so you can rely on it. Any other `.ttf` file works the same way; put it +beside your script and load it as `DIR .. "myfont.ttf"`. + +Now print with it: + +[source,lua] +---- +function onOverlayUpdate() + overlayClear() + fontPrint(24, 60, "ASTEROID PATROL") + return OVERLAY_UPDATED +end +---- + +Run it. Big white letters near the top left. + +Notice what you did *not* do: you never told `fontPrint` which font to use. +`fontLoad` makes the font it just loaded the selected one, and `fontPrint` +always draws with whatever is selected. With more than one font loaded you +choose between them with `fontSelect`, which you will do in a moment. + +Notice also that `24` and `60` are pixels, not character cells. This is the +difference between `fontPrint` and `overlayPrint` that trips people up: +`overlayPrint(2, 2, ...)` means two letters in and two lines down, while +`fontPrint(2, 2, ...)` means two pixels in and two pixels down, which is +jammed into the corner. The `y` you give is the *top* of the text, not the +baseline it sits on. + +=== Colour + +`fontPrint` takes three arguments and none of them is a colour. The colour +comes from somewhere else: + +[source,lua] +---- + colorForeground(255, 211, 90) + fontPrint(24, 60, "ASTEROID PATROL") +---- + +`colorForeground` sets red, green, and blue from `0` to `255`, and an optional +fourth number for opacity. It is not an argument to anything; it is a setting +that the engine remembers. Everything drawn after it comes out in that colour +until you change it again, exactly the way `overlayBox` and `overlayLine` +already work. So the pattern is always the same: set the colour, then draw. + +This is the first thing people get wrong with fonts, and the second thing is +coming up in a few pages. + +=== Quality + +`fontQuality` decides how the letters are rasterized -- how the curves of a +letter are turned into pixels -- and there are three choices. Try each one by +putting it above the `fontLoad` line and looking at the result closely. + +`FONT_QUALITY_SOLID` is the fastest. Every pixel is either the letter or it is +not, so the diagonals and curves come out with visible stair steps. At 48 point +it looks cheap. At 12 point on a small overlay it can look *sharper* than the +alternatives, which is sometimes what you want. + +`FONT_QUALITY_SHADED` smooths the edges, but it does it by blending them into +the current background colour, and it draws that background as a solid +rectangle behind the whole string. You get a label with a box around it whether +you wanted one or not. It is the right answer for a debug readout over a busy +picture and the wrong answer for almost everything else. + +`FONT_QUALITY_BLENDED` smooths the edges and keeps them see-through, so the +letters sit on whatever is behind them. It costs the most and it is what you +want nearly all the time. Use it unless you have a reason not to. + +The setting is global, not per font, and it applies from the moment you call it +onwards. Sprites you have already made keep the quality they were made with. +The manual's entry for `fontQuality` lists the three names and their values. + +=== Measuring, so You Can Centre Things + +Sooner or later -- usually about ten minutes in -- you want the title in the +middle of the screen. You know the screen is `overlayGetWidth()` wide. You do +not know how wide the text is, and there is no `fontMeasure` call to ask. + +What there is instead is `fontToSprite`. It renders a string with the selected +font, the current quality, and the current colour, and instead of putting it on +screen it hands you back a *sprite*: the same kind of handle `spriteLoad` gives +you, which means every `sprite*` call you learned in lesson nine works on it. +Including the two that measure it. + +[source,lua] +---- +fontSelect(titleFont) +colorForeground(255, 211, 90) +local titleImage = fontToSprite("ASTEROID PATROL") +---- + +And then, in `onOverlayUpdate`: + +[source,lua] +---- + local x = (overlayGetWidth() - spriteGetWidth(titleImage)) / 2 + spriteDraw(titleImage, x, 60) +---- + +That is centring, and it is the whole trick. Take the width of the space, take +away the width of the thing, and half of what is left is the gap at each side. + +Right alignment is the same idea with less arithmetic. To put text against the +right hand margin: + +[source,lua] +---- + spriteDraw(scoreImage, overlayGetWidth() - MARGIN - spriteGetWidth(scoreImage), y) +---- + +`spriteGetHeight` measures the other direction, for stacking lines or centring +vertically. + +There is a catch in that first code block, and it is the second thing everybody +gets wrong. *The colour is baked in when the sprite is made, not when it is +drawn.* `colorForeground` above `fontToSprite` changes the text. Putting it +above `spriteDraw` instead does nothing at all, because a sprite is drawn with +its own pixels and is never tinted. If your carefully coloured title comes out +white, this is why. + +=== The Trap: fontPrint Is Not Free + +Here is the thing the manual says plainly and everyone reads past. + +`fontPrint` does not draw a picture it made earlier. Every single call +rasterizes the whole string from the font outlines, builds an image of it, +copies that image onto the overlay, and throws it away. Then, one sixtieth of a +second later, it does all of that again. For a title that has not changed since +the game started, you are re-drawing the same letters from scratch sixty times +a second, forever. + +On a desktop with three lines of text you will never notice. With a screen full +of text, or on a Raspberry Pi, or in a cabinet, you will notice a great deal. + +The fix is the call you just met. `fontToSprite` does that work *once*. +`spriteDraw` afterwards is a plain copy, which is what the hardware is for. So +the rule is short: + +* Text that never changes -- titles, labels, instructions, menu entries -- + `fontToSprite` once at startup, `spriteDraw` every frame. +* Text that changes every frame and nobody is staring at -- a debug readout, a + frame number -- `fontPrint` is fine. +* Text that changes sometimes -- a score, a name, a timer in whole seconds -- + render a new sprite when it changes, and unload the old one. + +That last case is worth writing out, because "unload the old one" is easy to +forget and forgetting it leaks a sprite per change: + +[source,lua] +---- +local function renderScore() + if scoreImage ~= nil then + spriteUnload(scoreImage) + end + fontSelect(scoreFont) + colorForeground(255, 255, 255) + scoreImage = fontToSprite(string.format("%06d", score)) + scoreShown = score +end +---- + +`scoreShown` remembers which score that sprite says, so the drawing code can +tell whether it is still right: + +[source,lua] +---- + if score ~= scoreShown then + renderScore() + end +---- + +A score changes perhaps twice a second. This renders twice a second instead of +sixty times, and the code to do it is nine lines. + +`string.format("%06d", score)` is plain Lua, not Singe. It turns a number into +a string, and `%06d` means "as a whole number, at least six digits, padded with +zeros", which is why arcade scores have leading zeros. Change it to `%d` and +the zeros go away. + +=== Putting Fonts Away + +`fontUnload` closes a font and makes its handle invalid. Sprites you made with +`fontToSprite` are not affected -- once rendered, a sprite has nothing more to +do with the font -- so you can unload a font and go on drawing text made from +it. + +One sharp edge: if you unload the font that was selected, *no* font is selected +afterwards, and the next `fontPrint` ends your game with an error. Select +another one first if you are going to keep printing. + +The tidy place for all this is `onShutdown`, a callback the engine calls once +when the game is closing: + +[source,lua] +---- +function onShutdown() + spriteUnload(titleImage) + fontUnload(titleFont) +end +---- + +Singe cleans up after you when the game ends, so nothing catches fire if you +skip this. Write it anyway. A game that releases what it loaded is a game you +can load twice. + +=== The Whole Script + +[source,lua] +---- +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local MARGIN = 24 +local TITLE_Y = 60 +local SCORE_Y = 170 +local LABEL_Y = 186 +local HINT_Y = 300 +local CLOCK_Y = 430 + +local score = 0 +local titleFont = nil +local scoreFont = nil +local smallFont = nil +local titleImage = nil +local hintImage = nil +local scoreImage = nil +local scoreShown = nil + + +local function centred(image) + return (overlayGetWidth() - spriteGetWidth(image)) / 2 +end + + +local function renderScore() + if scoreImage ~= nil then + spriteUnload(scoreImage) + end + fontSelect(scoreFont) + colorForeground(255, 255, 255) + scoreImage = fontToSprite(string.format("%06d", score)) + scoreShown = score +end + + +fontQuality(FONT_QUALITY_BLENDED) + +titleFont = fontLoad("Singe/FreeSansBold.ttf", 48) +scoreFont = fontLoad("Singe/FreeSansBold.ttf", 36) +smallFont = fontLoad("Singe/FreeSansBold.ttf", 18) + +fontSelect(titleFont) +colorForeground(255, 211, 90) +titleImage = fontToSprite("ASTEROID PATROL") + +fontSelect(smallFont) +colorForeground(140, 200, 255) +hintImage = fontToSprite("Press the fire button to score") + +renderScore() + + +function onInputPressed(what) + if what == SWITCH_BUTTON1 then + score = score + 125 + end +end + + +function onOverlayUpdate() + overlayClear() + + spriteDraw(titleImage, centred(titleImage), TITLE_Y) + + if score ~= scoreShown then + renderScore() + end + spriteDraw(scoreImage, overlayGetWidth() - MARGIN - spriteGetWidth(scoreImage), SCORE_Y) + + fontSelect(smallFont) + colorForeground(160, 160, 160) + fontPrint(MARGIN, LABEL_Y, "SCORE") + fontPrint(MARGIN, CLOCK_Y, "Running for " .. (singeGetTicks() // 1000) .. " seconds") + + if (singeGetTicks() // 500) % 2 == 0 then + spriteDraw(hintImage, centred(hintImage), HINT_Y) + end + + return OVERLAY_UPDATED +end + + +function onShutdown() + spriteUnload(titleImage) + spriteUnload(hintImage) + if scoreImage ~= nil then + spriteUnload(scoreImage) + end + fontUnload(titleFont) + fontUnload(scoreFont) + fontUnload(smallFont) +end +---- + +Run it and hold the fire button. The title sits centred and never re-renders, +the score climbs and re-renders only when it changes, the hint blinks, and the +clock at the bottom is the one thing honestly drawn with `fontPrint` sixty +times a second. + +=== What Just Happened + +[source,lua] +---- +titleFont = fontLoad("Singe/FreeSansBold.ttf", 48) +scoreFont = fontLoad("Singe/FreeSansBold.ttf", 36) +smallFont = fontLoad("Singe/FreeSansBold.ttf", 18) +---- + +One file, three sizes, three handles. Each one costs memory for its own set of +rendered glyphs, so load the sizes you use and no more. Three is normal. A +dozen is a smell. + +After these three lines the selected font is `smallFont`, because loading +selects. That is why the code calls `fontSelect(titleFont)` before rendering +the title: the last thing loaded is not the thing you want first. + +[source,lua] +---- +local function centred(image) + return (overlayGetWidth() - spriteGetWidth(image)) / 2 +end +---- + +Written once as a function because it is used twice, and because the next time +you want something centred you will want it again. Give the arithmetic a name +and you never have to read it again. + +[source,lua] +---- + if (singeGetTicks() // 500) % 2 == 0 then + spriteDraw(hintImage, centred(hintImage), HINT_Y) + end +---- + +`singeGetTicks()` is the number of milliseconds since the engine started. `//` +is division that throws away the remainder, so `singeGetTicks() // 500` counts +half-seconds, and `% 2` is the remainder after dividing by two, which is `0`, +`1`, `0`, `1`... So the hint is drawn for half a second and skipped for half a +second. Drawing nothing is how you make something blink. + +[source,lua] +---- + fontSelect(smallFont) + colorForeground(160, 160, 160) + fontPrint(MARGIN, LABEL_Y, "SCORE") +---- + +Both settings are needed here, and it is worth being clear about why. Drawing +a sprite does not change the selected font or the colour, so the two +`spriteDraw` calls above leave both exactly as they found them. But the setup +code selected three different fonts in turn, and `renderScore` selects another +one whenever the score moves, so the selection when this line runs is whichever +one happened to be chosen last. Do not try to keep track of it. Select the font +and set the colour immediately before you print, every time. It is two lines +and it removes a whole category of puzzled evening. + +=== Try It + +. *Change the quality.* Put `FONT_QUALITY_SOLID` in the `fontQuality` line and + look at the curve of the `S` in `ASTEROID`. Then try `FONT_QUALITY_SHADED` + and watch what happens behind every string. +. *Make the hint yellow.* Move the `colorForeground(140, 200, 255)` line from + above `fontToSprite` to just above the `spriteDraw` that draws `hintImage`, + and change the numbers. Work out why nothing happens. +. *Right align the clock.* The clock is drawn with `fontPrint`, which cannot be + measured. Get it against the right margin anyway. There is only one way, and + finding it is the point. +. *Take out the `scoreShown` check*, so `renderScore()` runs every frame + instead. It will look identical. Leave it running for a minute and watch the + memory your game is using. +. *Use your own font.* Find a `.ttf` on your machine, copy it beside your + script, and load it with `DIR .. "thatfont.ttf"`. Some fonts look terrible at + 18 point and fine at 48. That is the font's fault, not yours. + +=== Break It on Purpose + +Add a line reading `fontPrint(24, 60, "SCORE")` directly under +`overlaySetResolution(discGetWidth(), discGetHeight())`, so that it runs before +any `fontLoad` has. Singe starts and dies at once: + +---- +5:fontPrint: No font selected. +---- + +Read it the same way as any other error. `5` is the line. `fontPrint` is which +engine call complained -- this shape of message comes from inside the engine +rather than from Lua, so it names the function instead of the file. The rest is +the complaint, and here it is exact: you asked it to print and there was no +font to print with. + +You will meet this one for real in a less obvious way. Unload the selected font +while the game is running and the next `fontPrint` says precisely the same +thing, several hundred lines away from the `fontUnload` that caused it. + +=== What You Learned + +* `fontLoad` opens a TrueType file at one fixed point size and returns a + handle. One size, one handle. +* Loading a font selects it; `fontSelect` chooses between loaded fonts. +* `fontPrint` counts in pixels and `overlayPrint` counts in character cells. +* Colour is not an argument. `colorForeground` is a setting; set it, then draw. +* `fontQuality` picks how letters are rasterized, and `FONT_QUALITY_BLENDED` + is the usual answer. +* `fontPrint` re-renders the string on every single call. +* `fontToSprite` renders once into a sprite, which `spriteDraw` then copies + cheaply and `spriteGetWidth` measures. +* Measuring is how you centre or right align text, because nothing else can + tell you how wide a string will be. +* A sprite's colour is fixed when it is made, not when it is drawn. +* `fontUnload` frees a font; the sprites it made survive it. + +=== Next Time + +Text that looks good is a long way from a menu that works. A page with a +title, a list you move a cursor through, a slider, and a button is a hundred +lines of drawing and cursor arithmetic if you build it out of the calls in this +lesson. Lesson twenty does not build it out of those calls at all. diff --git a/docs/lessons/20-gui.adoc b/docs/lessons/20-gui.adoc new file mode 100644 index 000000000..4e7149f47 --- /dev/null +++ b/docs/lessons/20-gui.adoc @@ -0,0 +1,579 @@ +== Lesson 20: Menus and Screens + +image::learn/20-gui.png[The finished lesson, 480] + +You can build a menu out of what you already know. A box, some text, a cursor +that moves when the stick moves, an `if` for every choice, and arithmetic to +keep it all lined up when you add a fourth option. It works. It is also about a +hundred lines for a page with three buttons on it, and every one of those lines +is yours to maintain. + +Singe has a whole document engine inside it -- RmlUi -- and a menu written for +it is a page, like a web page, with a style sheet. It wraps its own text. It +lays itself out when you add an option. It knows what a button is, what a +slider is, and which control the player is pointing at. This lesson builds a +pause menu with it, over a game that keeps running behind. + +=== Two Files + +A GUI page is two things: a *document* that says what is on the page, and +*Lua* that says what the page does. They live in separate files, and this is +the whole point of the exercise -- the layout stops being code. + +Start with a script called `menu.singe` and a game so small it fits in a +paragraph: a ship crossing the screen, and a score that goes up each time it +gets across. You have written this kind of thing since lesson seven. + +[source,lua] +---- +dofile("Singe/Framework.singe") + +overlaySetResolution(discGetWidth(), discGetHeight()) + +local ship = spriteLoad(DIR .. "art/ship.png") +local shipX = 0 +local speed = 2 +local score = 0 + + +function onOverlayUpdate() + overlayClear() + shipX = shipX + speed + if shipX > overlayGetWidth() then + shipX = -spriteGetWidth(ship) + score = score + 10 + end + spriteDraw(ship, shipX, 300) + return OVERLAY_UPDATED +end +---- + +That runs. Now the document. Beside your script, make a file called +`pause.rml`: + +[source,html] +---- + + + Pause + + + + +
+

Paused

+

Score 000000

+
+
+ + + +
+

Arrow keys move, Return chooses.

+
+ +
+---- + +If you have ever seen a web page's source, that is familiar. If you have not, +here is everything you need to read it. + +A document is made of *elements*. An element starts with a name in angle +brackets, `

`, ends with the same name and a slash, `

`, and whatever is +between them is inside it. Some elements have nothing inside and close +themselves with a slash at the end, like the `` above. Elements +nest, and the indentation shows the nesting the same way it shows it in Lua. + +The words inside the opening tag are *attributes*, written `name="value"`. +Two of them matter to you more than the rest. `id` gives an element a name your +script can find it by, which is how Lua and the document talk to each other. +`class` puts the element in a group the style sheet can aim at; `panel`, +`muted`, and `list` are groups the engine's own style sheet already knows about. + +The markup language is called RML and the style language is called RCSS. +They are RmlUi's, not Singe's. The manual's GUI chapter points at RmlUi's own +documentation for the full list of what you can write, and you will want it +eventually. Everything in this lesson works without it. + +=== Show It + +Back in the script. Above `onOverlayUpdate`, add: + +[source,lua] +---- +local gui = guiNew(overlayGetWidth(), overlayGetHeight()) +local page = guiLoad(gui, DIR .. "pause.rml") +---- + +`guiNew` makes a *GUI*: a rectangle of a fixed size that documents are laid out +and drawn into. It returns a handle, the way `fontLoad` and `spriteLoad` do. +Making it the size of the overlay means one pixel of the document is one pixel +of your overlay, which keeps the arithmetic in your head simple. + +`guiLoad` reads a document into that GUI and returns a second handle, for the +document itself. Nearly every other call takes both: the GUI and the document +in it. One GUI can hold up to thirty-two documents -- a title page, an options +page, and a game over page can share one -- and up to sixteen GUIs can exist at +once. + +Nothing is on screen yet, because loading a document does not draw it. Add one +line to `onOverlayUpdate`, just before the `return`: + +[source,lua] +---- + guiDraw(gui) +---- + +Run it. The panel is there, over the ship, with its heading and its three +buttons and its slider, and you have written no drawing code at all. + +`guiDraw` composites the GUI over the overlay *for this frame only*, like every +other drawing call, so it belongs in `onOverlayUpdate` and has to be called +again next frame. Where you put it decides what is on top: everything you drew +before it is underneath, and everything you draw after it goes over it. A +crosshair drawn after `guiDraw` is never hidden by the menu. + +=== Let the Player Use It + +Click a button. Nothing happens, and the button does not even light up. + +A new GUI is a picture, not a control panel. That is deliberate: a HUD or a +sign should not swallow the fire button. To make a page take input: + +[source,lua] +---- +guiSetInput(gui, true) +---- + +Now the mouse works, the buttons light up under the pointer, and the arrow keys +move between the controls, because the document asked for that with +`nav: auto`. On a pad or a stick the four directions arrive +as the arrow keys, the first action button arrives as Return, and the second +arrives as Escape. A cabinet with a joystick and two buttons drives this page +without knowing it is a document. + +Clicking still does nothing, though, because nothing is listening. + +=== Talk to It + +Three calls join the document to your script, and between them they do almost +everything. + +[source,lua] +---- +guiSetHandler(gui, page, "resume", "click", function() + closeMenu() +end) +---- + +`guiSetHandler` says: when the element with this `id` does this thing, call +this function. The `id` is the one in the document. The event name is RmlUi's: +`"click"` for buttons, `"change"` for anything the player adjusts, and a long +list of others you can look up when you need them. One function is kept per +element and per event, so setting another replaces it, and passing `nil` +instead of a function removes it. + +Your function is called with five arguments -- the GUI, the document, the id, +the event, and the element's current value as a string. Take as many as you +want and ignore the rest, the way you already ignore arguments in Lua. + +[source,lua] +---- +guiSetHandler(gui, page, "speed", "change", function(g, d, id, event, value) + speed = math.floor(tonumber(value)) +end) +---- + +The slider hands its value over as a string, and RmlUi formats numbers its own +way, so `4` arrives as `"4.000000"`. `tonumber` turns the string into a number +and `math.floor` throws away the fraction. Skipping that step and using the +string as a number is a mistake you will make once. + +[source,lua] +---- +guiSetValue(gui, page, "score", string.format("%06d", score)) +---- + +`guiSetValue` writes into an element. For a form control it sets the control's +value; for anything else -- a paragraph, a `div`, the `` in +your document -- it replaces what is inside the element, and the page lays +itself out again on the next frame. `guiGetValue` is the same call backwards, +and it answers `nil` for an id nothing has, so you can ask about an element +that might not be there without dying. + +That is the whole everyday API: `guiSetHandler`, `guiSetValue`, `guiGetValue`. +There is far more available through RmlUi's own Lua objects, reachable from the +global `rmlui`, and the manual's GUI chapter shows how. You will get a long way +before you need it. + +=== Hide It Again + +A pause menu is not up all the time. `guiHide` takes a document off screen and +`guiShow` puts it back, keeping everything the player typed or chose. + +The pause itself is `singeSetPauseFlag(true)`, which stops the disc, the +videos, and the sounds but *keeps calling your callbacks*, so you can still +draw. The engine has its own pause key, which freezes the script completely, +and the two would fight; `singeSetPauseKeyEnabled(false)` takes that key away +from the engine so `SWITCH_PAUSE` arrives at `onInputPressed` like any other +switch. On a keyboard that key is *P*. + +[source,lua] +---- +local function closeMenu() + paused = false + singeSetPauseFlag(false) + guiHide(gui, page) + guiSetInput(gui, false) +end + + +local function openMenu() + paused = true + singeSetPauseFlag(true) + guiSetValue(gui, page, "score", string.format("%06d", score)) + guiShow(gui, page) + guiSetInput(gui, true) +end +---- + +Turning input off with the page is not decoration. A hidden page that still +takes input eats the player's arrow keys for the rest of the game. + +=== What the Page Does Not Use, You Still Get + +This is the rule that makes a GUI safe to leave up during play, and it is worth +saying plainly: *an event an element used never reaches your callbacks.* A +Return that pressed the focused button does not arrive as `onInputPressed`. An +arrow that moved the focus does not arrive as `SWITCH_LEFT`. A click on a +button is not a mouse switch. + +Everything the page did not use falls straight through as usual. That is why +`P` closes this menu: no element in the document uses `P`, so the key arrives +at `onInputPressed` exactly as it does when the menu is down. It is also why a +HUD that takes input still lets the fire button through. + +One key does not follow the rule. RmlUi uses Tab to move focus and never gives +it back, so Singe only hands Tab to a GUI while a text field is actually being +typed in. A game that binds Tab to something keeps it. + +=== Style It + +Look back at the document's ``. One line does most of the work: + +[source,html] +---- + +---- + +That is the theme the engine ships, and it is why your panel has a border, a +gradient, and a gold heading without you choosing any of it. Without a style +sheet RmlUi draws almost nothing, because every element starts out inline and +sized to its contents, so linking this first and overriding what you dislike is +the fastest road to a page that looks like something. + +The theme gives you `.panel` (a bordered, rounded box), `.list` (a scrolling +box whose child `div` rows highlight and take focus), `.muted` (dimmer text for +hints), headings, and every form control with its hover, focus, and pressed +states. The manual's GUI chapter lists them. + +Everything after the link is yours. The `