Manuals are starting to come together.

This commit is contained in:
Scott Duensing 2026-09-22 21:57:42 -05:00
parent 34fc72ae5d
commit 721a0d120a
108 changed files with 20573 additions and 298 deletions

545
CHANGELOG
View file

@ -4,13 +4,22 @@ SINGE 3.00
Unreleased Unreleased
API Changes 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 - onInputPressed and onInputReleased receive a second argument when the
switch came from a mouse or a light gun: the device's index, as 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 Hypseus passes it, so a two-gun game can tell its players apart. A
switch from the keyboard or a pad passes nothing there. switch from the keyboard or a pad passes nothing there.
- singeScreenshot takes an optional file name, so a script that makes - singeScreenshot takes an optional file name, so a script that makes
pictures on purpose can name them. 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 - 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. 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 - A sprite scaled to nothing (a bullet shrinking to zero) draws as a
pixel instead of ending the engine with "Unable to transform sprite". pixel instead of ending the engine with "Unable to transform sprite".
- --deterministic stepped the disc one video frame per engine frame, - --deterministic stepped the disc one video frame per engine frame,
whatever the step, and the overlay throttle's strict comparison ran whatever the step, and the overlay throttle's strict comparison ran
the script every other iteration of the virtual clock, so a game that 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 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) the wall clock when a game starts (the one-player Space Pirates)
repeats; os.time with a date table is Lua's own. repeats; os.time with a date table is Lua's own.
- fontSelect given no font (nil, or an index that was never loaded) - fontSelect given no font (nil, or an index that was never loaded)
keeps the current font and warns instead of ending the script, as 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 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 TAB lost it. It goes to the GUI only while a text field is being typed
in. 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 - 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 every draw of it, its frames included, so art that faces one way is
enough for a character that walks both ways. 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 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. 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 name now expands to "SINGE Is Not a Game Emulator", in the banner,
the manual, INSTALL, and the Windows file description. the manual, INSTALL, and the Windows file description.
@ -769,17 +660,6 @@ API Changes
singeGetAudioDelay(), the games.dat key AUDIO_DELAY, and singeGetAudioDelay(), the games.dat key AUDIO_DELAY, and
--audiodelay=MS; positive values mean the audio is heard late. --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 - Inside a packed game a relative name with a ".." component resolves
nowhere: io.open returns nil and "NAME reaches outside the game", 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 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 submissions, and flags outliers for a person rather than pretending to
verify them. 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 - One menu, two renderers. Singe/MenuClassic.singe is gone: it was the
whole menu written a second time, and the two copies duplicated their whole menu written a second time, and the two copies duplicated their
game list, their selection, their menu.dat handling and their games.dat 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 layout globals, so a service screen can walk a pad without knowing the
numbers. 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. - 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 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 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 the overlay it is drawn on, so a larger recording gets a larger tagline
rather than the menu's scaled up. 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 - 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 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 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 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. 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 - 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 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 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 and tablets reporting absolute positions through ManyMouse are mapped
into the picture like the mouse. 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 - controllerGetButton() never worked; it rejected every framework button
code. code.
@ -1536,6 +1645,7 @@ Fixes
64-bit Raspberry Pi OS (bullseye or newer) binary and a macOS 13 or 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 newer universal (Apple silicon and Intel) binary. The toolchains repository is no longer
needed. See INSTALL. needed. See INSTALL.
- On a plain video disc, discSearch, discSkipToFrame, discSkipForward, - On a plain video disc, discSearch, discSkipToFrame, discSkipForward,
discSkipBackward, discStepForward and discStepBackward clamp at the discSkipBackward, discStepForward and discStepBackward clamp at the
first and last frame instead of wrapping around the disc. 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 or scriptExecute is reported like any other bad API argument -- the
script line and the call named -- rather than as a bare exit message. 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 SINGE 2.10

View file

@ -1,14 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build the Singe manual from docs/Manual.adoc and the Forge manual from docs/Forge.adoc. # Build the Singe manual from docs/Manual.adoc, the Forge manual from docs/Forge.adoc, and the
# Produces .builddir/Manual.html, Manual.pdf, Forge.html and Forge.pdf. The version comes from # beginner's course from docs/Learn.adoc. Produces .builddir/Manual.html, Manual.pdf, Forge.html,
# CMakeLists.txt. # Forge.pdf, Learn.html and Learn.pdf. The version comes from CMakeLists.txt.
set -euo pipefail set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd) here=$(cd "$(dirname "$0")" && pwd)
src=$here/docs/Manual.adoc src=$here/docs/Manual.adoc
forge=$here/docs/Forge.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 if [[ ! -f $doc ]]; then
echo "error: $doc not found" >&2 echo "error: $doc not found" >&2
exit 1 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-pdf -a revnumber="$version" "$src" -o "$out/Manual.pdf"
asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html" asciidoctor -a revnumber="$version" "$forge" -o "$out/Forge.html"
asciidoctor-pdf -a revnumber="$version" "$forge" -o "$out/Forge.pdf" 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:" 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"

View file

@ -7,6 +7,7 @@ Scott Duensing <scott@kangaroopunch.com>
:toclevels: 3 :toclevels: 3
:sectnums: :sectnums:
:sectnumlevels: 3 :sectnumlevels: 3
:title-logo-image: image:singeLogo.png[Singe, pdfwidth=2.6in, align=center]
:source-highlighter: rouge :source-highlighter: rouge
:icons: font :icons: font
:experimental: :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 thread can open the finished game and compare. The pictures come from those
scenes too. scenes too.
=== Tutorial 1: Ten minutes === Tutorial 1: Ten Minutes
image::tutorials/01-playing.png[The starter game, playing, 600] 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[] 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] 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[] 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] 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[] 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] 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[] 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] 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[] 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] 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[] 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] image::tutorials/07-playing.png[The yard, 600]
@ -802,7 +803,7 @@ viewport in <<_the_3d_viewport>>.
include::../assets/Forge/tutorials/08-scene.forge[] 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] 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[] 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] 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 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. 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, *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; 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 sprite, tile, MIDI, water, and soft-body pieces -- and
`testScripts/scene52.singe` onward compile and play them. `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, 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 }`), 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" } } } { 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 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 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, 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. 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 A point-and-click adventure -- Sierra's or LucasArts' -- is rooms that
remember themselves, a character that walks where the player clicks, hotspots 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 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. 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, `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, 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 waves on a lane, turrets placed by clicks, gold and lives) are the worked
examples; scenes 66 and 67 play them. 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, 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 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 worked example and scene 68 plays it: one window answered, three missed, the
lives spent, the score sent, a coin, and a continue. 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 `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 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; `testScripts/author/tail.forge` and `pool.forge` are the worked examples;
scenes 70 and 71 play them. 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 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 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[] include::ForgeVocabulary.adoc[]
[[own-vocabulary]] [[own-vocabulary]]
=== Your own vocabulary === Your Own Vocabulary
A game can bring words of its own. A description may name a file of Lua -- 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 arrow keys, so an entity that lives underneath it is never permanently out of
reach. reach.
=== The four panels === The Four Panels
`Tab` cycles the panel through the room's *entities*, the *types*, the `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 *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. colour -- so a type can be told from its neighbours without opening it.
`testScripts/scene82.singe` drives both. `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 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 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 again compiles to the same game, byte for byte. The editor depends on that
and `testScripts/scene54.singe` asserts it. and `testScripts/scene54.singe` asserts it.
=== Releasing a game === Releasing a Game
`X` in the editor, or `forgeExport(folder, name)` from a script, writes `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 everything a finished game needs into a directory of its own: the compiled

230
docs/Learn.adoc Normal file
View file

@ -0,0 +1,230 @@
= Learn to Program with Singe
Scott Duensing <scott@kangaroopunch.com>
: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[]

View file

@ -7,6 +7,7 @@ Scott Duensing <scott@kangaroopunch.com>
:toclevels: 3 :toclevels: 3
:sectnums: :sectnums:
:sectnumlevels: 3 :sectnumlevels: 3
:title-logo-image: image:images/singeLogo.png[Singe, pdfwidth=2.6in, align=center]
:source-highlighter: rouge :source-highlighter: rouge
:icons: font :icons: font
:experimental: :experimental:
@ -101,7 +102,7 @@ is not sat through twice, and `MENU_OVERLAY` chooses a renderer (see
<<menurenderers,The two renderers>>). <<menurenderers,The two renderers>>).
[#menurenderers] [#menurenderers]
==== The two renderers ==== The Two Renderers
A GUI document is drawn through the GPU device the 3D scene uses, so on a 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 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. tested, and how a cabinet builder who prefers the old look gets it.
[#onlineservice] [#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 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 `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. `controls.cfg`; `DEAD_ZONES` wins over both, being the most specific.
[[joymouse]] [[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 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 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` the same: `deterministic = true` uses the default step, `deterministic = 20`
names one, and `deterministic = false` leaves the option off. names one, and `deterministic = false` leaves the option off.
==== What wins ==== What Wins
. The built in default. . The built in default.
. The settings file. . 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 it stretches with the window exactly as the picture does. Give it the aspect
ratio you expect to play at. ratio you expect to play at.
==== The cutout ==== The Cutout
Beside the image, and named after it, a sidecar file says where the picture 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 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 `--bezelflip` puts the artwork in front and then only its transparency lets the
game through. game through.
==== Draw order ==== Draw Order
Without `--bezelflip` the artwork is the first thing in the frame: the picture, 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 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 Screen space GUIs and the score panel always draw above the artwork, since the
point of them is to sit on it. 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 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, 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 border around the picture, and a cabinet with no artwork at all may want it at
the screen's edge. the screen's edge.
==== The score panel ==== The Score Panel
Sixteen of the Hypseus Singe games can drive an arcade scoreboard: credits, and 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 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 different but equally reproducible run. It may be set in a settings file as
`deterministic` like any other option. `deterministic` like any other option.
==== Reference screenshots ==== Reference Screenshots
The scenes in `testScripts` each take a screenshot or two at a fixed frame number 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 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) nodeSetRotation(monitor, 90, 0, 0)
---- ----
==== Reloading, Performance and Limits ==== Reloading, Performance, and Limits
A GUI belongs to the script that made it. Quitting, `scriptPush`, A GUI belongs to the script that made it. Quitting, `scriptPush`,
`scriptExecute` and a reload (see <<reloading,Reloading While You Work>>) `scriptExecute` and a reload (see <<reloading,Reloading While You Work>>)
@ -1953,7 +1954,7 @@ if srtLoad("subs/english.srt") then
end end
---- ----
==== The file format ==== The File Format
SubRip (`.srt`) is a plain text file of numbered cues: 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. `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. 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 over its life; `emitterSetSize` does the same for its size, with a third
value for how fast it spins. 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 `emitterSetTrail(emitter, length, width)` keeps the last few positions of
every particle and draws a ribbon through them, fading toward the tail: 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. of audio arriving early.
[#menucalibration] [#menucalibration]
==== Calibrating from the menu ==== Calibrating from the Menu
The bundled menu has a calibration screen for that downstream delay. Press the 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 key mapped to `INPUT_SERVICE` (the `9` key by default) on the game list and
@ -11948,6 +11949,54 @@ function onOverlayUpdate()
end 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:* <<overlaygetfontwidth,overlayGetFontWidth>>, <<overlayprint,overlayPrint>>, <<overlaygetheight,overlayGetHeight>>
.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:* <<overlaygetfontheight,overlayGetFontHeight>>, <<overlayprint,overlayPrint>>, <<overlaygetwidth,overlayGetWidth>>
.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]
==== 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. * `column`, `row` -- character cell, `0` at the top left.
* `text` -- the string to print. * `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 *Since:* 1.x
*See also:* <<fontprint,fontPrint>>, <<overlayclear,overlayClear>>, <<colorbackground,colorBackground>> *See also:* <<fontprint,fontPrint>>, <<overlayclear,overlayClear>>, <<colorbackground,colorBackground>>
@ -16596,7 +16645,7 @@ srtPosition(70)
---- ----
[#onlinescores] [#onlinescores]
=== Online high scores === Online High Scores
A game posts a score in one line: A game posts a score in one line:

BIN
docs/images/learn/01-hello.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/02-numbers.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/03-decisions.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/04-repeating.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/05-functions.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/06-tables.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/07-a-game.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/08-when-it-goes-wrong.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/09-pictures.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/10-sound.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/11-hitting-things.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/12-score.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/13-game-folder.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/14-video.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/15-threaded.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/16-branching.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/17-light-guns.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/18-qte.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/19-text.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/20-gui.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/21-subtitles.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/22-3d.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/23-models.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/24-physics.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/25-particles.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/26-navigation.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/27-midi.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/28-online.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/29-shipping.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/learn/30-cabinet.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/images/singeLogo.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

134
docs/learn/06-tables.singe Normal file
View file

@ -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()

150
docs/learn/07-a-game.singe Normal file
View file

@ -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

View file

@ -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

View file

@ -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()

266
docs/learn/10-sound.singe Normal file
View file

@ -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()

View file

@ -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

249
docs/learn/12-score.singe Normal file
View file

@ -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

View file

@ -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

69
docs/learn/14-video.singe Normal file
View file

@ -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()

View file

@ -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")

View file

@ -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")

View file

@ -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()

159
docs/learn/18-qte.singe Normal file
View file

@ -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()

95
docs/learn/19-text.singe Normal file
View file

@ -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

121
docs/learn/20-gui.singe Normal file
View file

@ -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

View file

@ -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

76
docs/learn/22-3d.singe Normal file
View file

@ -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

View file

@ -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

162
docs/learn/24-physics.singe Normal file
View file

@ -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

View file

@ -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

View file

@ -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

115
docs/learn/27-midi.singe Normal file
View file

@ -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()

129
docs/learn/28-online.singe Normal file
View file

@ -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

View file

@ -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 <your name here>",
"",
"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

246
docs/learn/30-cabinet.singe Normal file
View file

@ -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

22
docs/learn/art/README.txt Normal file
View file

@ -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.

BIN
docs/learn/art/boom.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/rock.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/ship.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/shoot.wav (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/shot.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/star.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
docs/learn/art/walk.png (Stored with Git LFS) Normal file

Binary file not shown.

28
docs/learn/pause.rml Normal file
View file

@ -0,0 +1,28 @@
<rml>
<head>
<title>Pause</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { width: 100%; height: 100%; }
#frame { width: 380dp; margin: 80dp auto; }
.row { display: block; margin: 8dp 0; }
label { display: inline-block; width: 100dp; }
button { nav: auto; }
input.range { nav: auto; width: 200dp; }
#status { min-height: 28dp; }
</style>
</head>
<body id="pause">
<div id="frame" class="panel">
<h1>Paused</h1>
<p class="row">Score <span id="score">000000</span></p>
<div class="row"><label>Speed</label><input type="range" id="speed" min="1" max="8" step="1" value="2"/></div>
<div class="row">
<button id="resume">Resume</button>
<button id="restart">Restart</button>
<button id="quit">Quit</button>
</div>
<p id="status" class="muted">Arrow keys move, Return chooses.</p>
</div>
</body>
</rml>

12
docs/learn/subtitles.srt Normal file
View file

@ -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.

273
docs/lessons/01-hello.adoc Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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()`.

View file

@ -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.

737
docs/lessons/06-tables.adoc Normal file
View file

@ -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.

840
docs/lessons/07-a-game.adoc Normal file
View file

@ -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.

View file

@ -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 <eof>
----
Line 148 is the last line of the file. There is nothing wrong with line 148.
Lua read to the very end -- `<eof>` 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.

View file

@ -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.

589
docs/lessons/10-sound.adoc Normal file
View file

@ -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.

View file

@ -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.

480
docs/lessons/12-score.adoc Normal file
View file

@ -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.

View file

@ -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.

482
docs/lessons/14-video.adoc Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

589
docs/lessons/18-qte.adoc Normal file
View file

@ -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.

491
docs/lessons/19-text.adoc Normal file
View file

@ -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.

579
docs/lessons/20-gui.adoc Normal file
View file

@ -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]
----
<rml>
<head>
<title>Pause</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { width: 100%; height: 100%; }
#frame { width: 380dp; margin: 80dp auto; }
.row { display: block; margin: 8dp 0; }
label { display: inline-block; width: 100dp; }
button { nav: auto; }
input.range { nav: auto; width: 200dp; }
#status { min-height: 28dp; }
</style>
</head>
<body id="pause">
<div id="frame" class="panel">
<h1>Paused</h1>
<p class="row">Score <span id="score">000000</span></p>
<div class="row"><label>Speed</label><input type="range" id="speed" min="1" max="8" step="1" value="2"/></div>
<div class="row">
<button id="resume">Resume</button>
<button id="restart">Restart</button>
<button id="quit">Quit</button>
</div>
<p id="status" class="muted">Arrow keys move, Return chooses.</p>
</div>
</body>
</rml>
----
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, `<h1>`, ends with the same name and a slash, `</h1>`, and whatever is
between them is inside it. Some elements have nothing inside and close
themselves with a slash at the end, like the `<input .../>` 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 `<span id="score">` 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 `<head>`. One line does most of the work:
[source,html]
----
<link type="text/rcss" href="Singe/gui.rcss"/>
----
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 `<style>` block in `pause.rml`
overrides the theme for this page, later rules winning over earlier ones, and
a rule aimed at an `id` beats one aimed at a `class`. For a real game put your
own rules in a `.rcss` file beside the document and link it second, so your
look is one file for every page.
The unit `dp` is a pixel of the GUI's own size -- your GUI is the size of the
overlay, so `380dp` is 380 overlay pixels. `margin: 80dp auto` is the trick
that centres a block of a known width: `auto` splits whatever is left over
equally between the two sides. There is no measuring and no arithmetic, which
after lesson nineteen you may find slightly annoying.
To use your own font in a document, load it with `guiLoadFont` before the
documents that use it, and name it in RCSS by the family name inside the font
file, not by its file name.
=== When Not to Use Any of This
A GUI is a page. It is the right answer for a menu, an options screen, a high
score table, a game over screen, or a HUD with a real layout.
It is the wrong answer for a score in the corner. That is two lines of
`fontPrint` from lesson nineteen, and wrapping it in a document buys you
nothing and costs you a texture. The same goes for a crosshair, a health bar
you drew with `overlayBox`, and a line of debug text.
Three facts decide it for you.
A GUI needs a GPU. On a machine without one -- an old box, a small Pi, a
machine with no Vulkan, Direct3D 12, or Metal driver at all -- there are no
GUIs, and `guiNew` does not politely answer `false`, it ends your game. The 3D
scene of lesson twenty-two goes the same way, so this is not the last time you
will ask. Ask `singeHasGpu()` first if
your game has to run in both places, and have a plain overlay version to fall
back on. The engine's own menu does exactly that.
A GUI costs a texture and a layout pass *every frame, whether or not you draw
it*. A document you are finished with should be deleted with `guiDelete`, not
merely hidden. Hiding is for a page you will show again in a minute.
And a GUI is torn down by a reload. `F5`, `-R`, and anything that restarts the
script take every GUI with them, and the script makes them again when it runs.
That is fine, as long as you build them in your script's body and not in some
corner that only runs once.
=== The Whole Script
[source,lua]
----
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
----
=== What Just Happened
[source,lua]
----
if singeHasGpu() then
gui = guiNew(overlayGetWidth(), overlayGetHeight())
page = guiLoad(gui, DIR .. "pause.rml")
----
The whole GUI is built inside that `if`, and `gui` stays `nil` on a machine
without one. Every call that touches the GUI is guarded with `if gui ~= nil`,
and the pause still works without a menu. It is four extra lines and it is the
difference between "does not have a menu on that machine" and "does not start
on that machine".
[source,lua]
----
guiHide(gui, page)
guiSetInput(gui, false)
----
`guiLoad` shows the document it loaded, so a page that should start hidden has
to be hidden immediately. Input is off already for a new GUI, but turning it
off here means `openMenu` and `closeMenu` are exact mirrors of each other, and
mirrored pairs are much harder to get wrong.
[source,lua]
----
guiSetHandler(gui, page, "restart", "click", function()
score = 0
shipX = 0
closeMenu()
end)
----
The handler is a function with no name, written where it is used. Lua lets you
write `function(...) ... end` anywhere a value is wanted, and since
`guiSetHandler` wants a function, you can hand it one on the spot instead of
naming it first and passing the name. It reaches `score`, `shipX`, and
`closeMenu` because they are
declared above it in the same file, which is the whole reason the handlers are
set up below the things they touch.
[source,lua]
----
if not paused then
shipX = shipX + speed
----
The pause flag stops the disc and the sounds, not your arithmetic.
`onOverlayUpdate` keeps being called -- that is the point, since otherwise the
menu could not be drawn -- so anything of yours that should stop while paused,
you stop yourself.
=== Try It
. *Add a fourth button.* Copy the `<button id="quit">` line, give it a new id
and new text, and add a handler for it. Notice that nothing had to be
re-measured or moved along.
. *Restyle it.* In the `<style>` block, change `#frame` to
`width: 260dp; margin: 20dp;` and see the panel move and the text rewrap by
itself.
. *Take out the `guiSetInput(gui, true)` in `openMenu`.* The page still
appears. Work out from the screen alone what you have lost.
. *Leave the page shown all the time.* Delete the `guiHide` calls and draw the
GUI every frame instead of only while paused. Then try to fly the ship.
This is why a HUD is `guiSetInput(gui, false)`.
. *Put the score on the page instead of the HUD.* Call `guiSetValue` for
`score` every frame rather than only in `openMenu`, and delete the
`fontPrint` line. Then decide which of the two you actually prefer, and why.
=== Break It on Purpose
Change one id in `pause.rml`, from `id="resume"` to `id="continue"`, and leave
the script alone. Singe starts and dies:
----
53:guiSetHandler: No element "resume" in document 0 of GUI 0.
----
Read it as you read any engine error. The line, the call that complained, then
the complaint -- and this complaint is precise. It is not saying the document
is broken; the document loaded perfectly. It is saying you asked for an element
by a name nothing in it has.
`guiSetHandler` and `guiSetValue` both die on a missing id, because a handler
you meant to attach and did not is a bug you want to hear about at once.
`guiGetValue` is the exception: it answers `nil` instead, so you can ask
whether something is there.
The other error worth meeting is the one you get when the document itself does
not load. Rename `pause.rml` and run. This time Singe names the file and quotes
RmlUi's own reason, and RmlUi's grumbles about your markup and your styles go
to the console whether or not the load succeeds. Read that console the first
time a page comes out blank; it will usually have said why.
=== What You Learned
* A GUI is a fixed size rectangle that documents are laid out and drawn into;
`guiNew` makes one and `guiDelete` frees it.
* `guiLoad` puts an RML document in a GUI and shows it; `guiHide`, `guiShow`,
and `guiClose` manage it afterwards.
* `guiDraw` composites the GUI over the overlay for one frame, so it goes in
`onOverlayUpdate` like every other drawing call.
* A new GUI only displays; `guiSetInput` is what makes it usable.
* `guiSetHandler` runs your function when an element is clicked or changed,
`guiSetValue` writes into an element, and `guiGetValue` reads one.
* An event an element used never reaches your own callbacks; everything else
falls through as normal.
* `Singe/gui.rcss` is the shipped theme; link it first and override it in a
`<style>` block or a sheet of your own.
* A GUI needs a GPU, so ask `singeHasGpu()` if your game must run without one.
* Every GUI renders every frame whether you draw it or not, so delete the ones
you have finished with.
* A score in the corner is two lines of `fontPrint`, not a document.
=== Next Time
There is one more kind of text on screen, and it has its own system for a
reason that has nothing to do with how it looks. Lesson twenty-one is about
subtitles, and about the players who cannot play your game without them.

View file

@ -0,0 +1,478 @@
== Lesson 21: Subtitles
image::learn/21-subtitles.png[The finished lesson, 480]
Somebody who cannot hear is going to play your game.
If your game has speech in it and no subtitles, they cannot. Not "it is harder
for them" -- they will reach the first scene where a character says the thing
that tells the player what to do, and they will stop. That is the whole of it,
and it is the reason this lesson exists rather than being a paragraph in lesson
nineteen.
The other reason is practical. Retrofitting subtitles into a finished game is
miserable. You have to find every line of speech, work out which frame it is
on, and discover that half your timings are wrong because your video was
re-encoded in the middle of the project. Done while you are writing the game,
it is an afternoon. Singe gives you a system that does the hard part, and this
lesson is how to use it.
=== It Is Not Ordinary Text Drawing
You could do subtitles yourself. Keep a list of lines with start and end times,
check the clock in `onOverlayUpdate`, and draw the current one with
`fontToSprite`. People have. Here is why you should not.
Subtitles are timed against *the disc*, not against a clock. When `srtLoad`
reads your file it converts every timestamp into a disc frame number, there and
then, using the frame rate of the disc that is loaded. From that moment the
engine is not watching a clock at all; every drawn frame it asks which cue
covers the frame the disc is on.
That one decision buys you the thing a hand rolled system never gets right:
*a subtitle survives a seek*. Send the disc anywhere -- `discSearch`,
`discSkipForward`, a step, a branch into another segment -- and the right line
is up on the next drawn frame, forwards or backwards, with nothing reloaded and
no clock reset. In a branching game from lesson sixteen, where the player's
choices jump the disc around constantly, that is the difference between working
and almost working.
It also draws in the right place. The subtitle document sits over the picture,
above your overlay, below any screen space GUI, and it follows the picture
wherever the player's settings put it -- a shrunken picture, a shifted one, a
rotated cabinet, a hole in a bezel. It takes no mouse input, so a light gun
shot passes straight through it. Getting all of that right yourself is a week
you did not plan for.
=== Write the File
Subtitles go in a SubRip file, which ends in `.srt` and is plain text you can
type in any editor. It is one of the simplest formats there is. Make a file
called `subtitles.srt` beside your script:
----
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.
----
A cue is four things. A number on a line of its own. A line with two
timestamps and an arrow made of two hyphens and a greater-than sign between
them. One or more lines of text. A blank line to finish.
A timestamp is `HH:MM:SS,mmm` -- hours, minutes, and seconds separated by
colons, then a comma, then milliseconds. The comma is not a typo and a full
stop will not do. `00:00:04,200` is four and a fifth seconds in.
The cue numbers are read and thrown away, so they do not have to be in order or
even correct. Number them anyway; you will be looking for cue forty-one at some
point.
Cue two shows what a two line cue looks like. The lines are shown as you wrote
them, so *you* decide where a long line breaks, which matters more than you
would think and is covered further down.
The parser is deliberately forgiving about the things that go wrong in a hand
edited file. Windows line endings load unchanged, and leading and trailing
spaces are stripped from every line. A cue whose timestamps do not parse is
skipped and the rest of the file goes on loading, which is friendly right up
until you wonder where line twelve went. A few things are not supported: the
`<i>` and `<b>` tags some tools write are shown as text rather than obeyed, and
the positioning coordinates some tools append to the time line are ignored.
The manual's Subtitles section is the full list.
=== Load It and Switch It On
Three calls, and the order matters.
[source,lua]
----
if srtLoad(DIR .. "subtitles.srt") then
srtPosition(80)
srtEnable(true)
end
----
`srtLoad` reads the file and answers `true` or `false`. It answers `false`,
having loaded nothing, in three cases: the file cannot be read, nothing in it
parses, or *there is no disc*. In all three it prints a warning naming the file
to the console. That is a good design and you should use it: a missing
subtitle file is not worth killing a game over, but silently shipping a game
with no subtitles is worth noticing.
The "no disc" case is the one that will catch you. The timestamps have to
become frame numbers at the disc's frame rate, and until a disc is open there
is no frame rate to use. So load subtitles *after* the disc, which in an
ordinary script means from the body of the script, where everything else you
have written at the top level runs. It follows that a `.srt` cut for a video at
one frame rate lands in the wrong place on a video at another. Time your file
against the video you are actually going to ship.
`srtEnable(true)` switches the loaded cues on. A script starts with subtitles
off, always, so a game that loads them must switch them on. Switching them off
again takes down whatever is showing, and switching them back on puts up
whatever covers the frame the disc is on right now -- no hunting, no state to
keep.
Keeping subtitles on is a setting, not a decision you make for the player. Put
it in your options page from lesson twenty, save it with `saveSet` from lesson
twelve, and hand `srtEnable` whatever the player last chose.
=== Where They Sit
[source,lua]
----
srtPosition(70)
----
`srtPosition` is a percentage down the picture, from `1` to `95`, and the
default is `80`. It takes effect on the cue that is up right now and on every
cue after it. A number outside the range is ignored rather than clamped, which
means a typo leaves the position exactly where it was and says nothing, so
check yours.
Eighty per cent is low, and it is low on purpose: the bottom of the picture is
usually the least interesting part of the shot. Move it up when the bottom is
*not* boring -- when your game draws a scoreboard along the bottom edge, or
when the player's light gun crosshair lives down there.
=== Taking One Down
[source,lua]
----
srtClear()
----
`srtClear` takes whatever is on screen off at once, and leaves the loaded cues
and the enabled state alone. The line that was showing stays off until the disc
reaches the next cue. Use it when a scene ends early and you do not want the
last line of dialogue hanging over the next shot.
There is one more call that shares the subtitle machinery, and you may have
used it already:
[source,lua]
----
overlayBanner("Extra life!", 20)
----
`overlayBanner` shows a short message over the picture for about thirty drawn
frames and then removes it. It is drawn by the same document as the subtitles,
so a banner looks like a subtitle, and while one is up it takes the place of
any subtitle, which comes back when the banner times out. Its own height
argument is separate from `srtPosition` and defaults to `47`, the middle of the
picture. Messages longer than sixty characters are ignored entirely, so a long
one appears not to work at all.
=== Subtitles Already Inside the Video
Some video files carry their own subtitle tracks. If yours does, you do not
need a `.srt` at all:
[source,lua]
----
if discGetSubtitleTracks() > 0 then
srtLoadTrack(0)
else
srtLoad(DIR .. "subtitles.srt")
end
srtEnable(true)
----
`srtLoadTrack` takes a track number from `0` to `discGetSubtitleTracks() - 1`
and everything after loading is identical. `discGetSubtitleLanguage(track)`
tells you what language a track is labelled with, as a three letter code like
`eng` or `fra`, so a game can offer the player whatever its video happens to
carry.
Two warnings. Reading a track walks the whole video file once, decoding no
pictures, so a feature length disc takes a moment: do it while something else
is on screen, not between two frames of gameplay. And subtitle tracks that hold
*pictures* of the words rather than the words -- the kinds DVDs and Blu-rays
use -- cannot be read. They are counted, so the numbering matches what other
players show, but `srtLoadTrack` answers `false` for them.
=== The Whole Script
This one needs a video. You have one: `Singe/menuBackground.mkv`, which the
engine unpacked into your work folder the first time you ran anything. Tell Singe
to use it as the disc with `--framefile`, which also turns the disc on:
----
Singe --framefile=Singe/menuBackground.mkv subtitles
----
[source,lua]
----
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
----
Press fire to toggle, up and down to move the lines, and left to rewind to the
start. The rewind is the one to watch: the disc jumps back to frame zero and
the correct cue is up on the very next frame, without you doing anything about
it.
=== What Just Happened
[source,lua]
----
if discGetSubtitleTracks() > 0 then
loaded = srtLoadTrack(0)
end
if not loaded then
loaded = srtLoad(DIR .. "subtitles.srt")
end
----
Take the video's own subtitles when it has them, fall back to the file beside
the script when it does not, and end up knowing which -- if either -- worked.
Both calls answer a boolean, so the fallback is two `if` statements and no
special cases. `menuBackground.mkv` carries no subtitle track, so what you will
actually get is the file you typed.
[source,lua]
----
function onInputPressed(what)
if not loaded then
return
end
----
`return` with nothing after it leaves the function immediately. With no
subtitles loaded there is nothing for any of these keys to do, and leaving
early is clearer than wrapping the whole body in an `if`. Calling `srtEnable`
with nothing loaded is harmless, but writing code that only makes sense when it
does nothing is a habit worth not forming.
[source,lua]
----
elseif what == SWITCH_LEFT then
srtClear()
discSearch(0)
discPlay()
----
The `srtClear` is not necessary. The engine will put up the right cue for frame
zero on the next drawn frame regardless. It is there so that you can delete it
and watch what happens: the old line stays up for a fraction of a second while
the disc gets where it is going. On a slow seek that flash of the wrong line is
visible, and clearing first is how you avoid it.
=== Making Text Readable Over a Moving Picture
The system puts the words on screen. Whether anybody can read them is your
problem, and it applies to every line of text you draw over video -- a prompt
from lesson eighteen, a score, a branch choice -- not only to subtitles.
*Stay out of the edges.* Television sets have been cutting the edges off
pictures for seventy years, and arcade monitors are worse, not better. Work on
the assumption that the outer five per cent of the picture on each side may
not be there at all on somebody's screen. Keep anything a player must read
inside that. This is why `srtPosition` stops at `95` and not `100`.
*Contrast is not a colour choice, it is a guarantee.* White text over a picture
is white text over whatever the picture happens to be doing, and in two seconds
that may be a snowfield. The shipped subtitle style solves it the way film
subtitles do: a dark panel that hugs the text, plus a one pixel shadow. If you
draw your own text over video, do one or the other -- an `overlayBox` behind it
or a dark copy of the text offset by a pixel. Do not rely on the picture.
*Break your own lines.* Two short lines read faster than one long one, and a
line that wraps where you did not choose usually breaks in the wrong place. Aim
for about forty characters a line and no more than two lines at a time. Break
at a natural pause: `They were wrong about / a great many things` reads; `They
were wrong / about a great many things` stumbles.
*Leave them up long enough.* A comfortable reading speed is somewhere around
fifteen characters a second, so a forty character line wants roughly three
seconds. Look at cue one in the file above: thirty-two characters, three
seconds. If a line of dialogue is short and quick, keeping the cue up a beat
past the end of the speech is kinder than snapping it away.
*Put them where the action is not.* If the important thing in the shot is at
the bottom of the frame, move the subtitles up for that scene with
`srtPosition`. You can call it as often as you like, and the player will never
notice that you did.
=== Styling Them
The lines are drawn by a document, the same kind you met in lesson twenty, and
two files in your `Singe` folder decide what they look like:
* `Singe/subtitle.rml` -- the document. It holds one element, `#slot`, which
the engine writes the current cue or banner into.
* `Singe/subtitle.rcss` -- the style. `.cue` is the block that carries the
height `srtPosition` asked for, and `.cue .text` is the bar of text inside
it: white, bold, on a dark rounded panel that hugs the text, with a shadow.
Open `subtitle.rcss` and read it -- it is about forty lines. Change the
`font-size`, the `color`, the `background-color`, the `border-radius`, or the
`font-effect`, save, and run again. The engine only ever sets `#slot`'s
contents and the `.cue`'s height, so everything else in that file is yours and
will survive an upgrade of the engine.
Because `overlayBanner` uses the same document, restyling subtitles restyles
your banners too, which is usually what you want and is worth knowing before
you wonder why.
One limit: the subtitle document is a document, and documents need a GPU. On a
machine without one, `srtLoad` still reads your file and still answers
truthfully, and nothing is drawn. A warning says so once.
=== Try It
. *Add a fourth cue* to `subtitles.srt`, between eleven and thirteen seconds.
Save and run. Then put it out of order, as cue `99`, first in the file, and
confirm it still works.
. *Give a cue a bad timestamp* -- write `00:00:05.500` with a full stop instead
of a comma -- and run. Watch which line disappears, and notice that nothing
at all is said about it. A file that loads is not a file that is right.
. *Restyle them.* In `Singe/subtitle.rcss`, change `.cue .text` to yellow text
on a fully transparent background, then add a heavier shadow. Then play it
over the video and decide honestly whether it is still readable.
. *Break the timing on purpose.* Change every timestamp in the file so it is
two seconds late, and watch a correctly working system deliver useless
subtitles. This is what a re-encoded video does to you.
. *Put the toggle in a menu.* Take the pause page from lesson twenty, add a
checkbox, and wire it to `srtEnable`. Then save the choice with `saveSet` so
it is still on next time. This is the version your players actually want.
=== Break It on Purpose
Run the script without the `--framefile` option:
----
Singe subtitles
----
The window opens, black, with your fallback message on it, and the console
says:
----
Warning: No subtitles were loaded from subtitles.srt.
----
No crash and no error dialog, because `srtLoad` answered `false` and your
script handled it.
That is the most valuable failure in this lesson, because it is the one that
happens without anybody noticing. There is no disc, so there is no frame rate,
so there is nothing to turn the timestamps into, so nothing loads. If you had
written `srtLoad(...)` on its own and thrown the answer away, your game would
have run perfectly and shown no subtitles, and you would have found out when
somebody told you.
Use the answer. Every time.
=== What You Learned
* A game with speech and no subtitles is a game some people cannot play at all.
* Building subtitles in as you go is an afternoon; retrofitting them is not.
* Subtitles are a `.srt` file: a number, two timestamps with `-->` between
them, the text, and a blank line.
* `srtLoad` turns the timestamps into disc frame numbers at load time, which is
why a cue survives any seek.
* It must be loaded after the disc, and it answers `false` when it cannot load,
including when there is no disc. Check the answer.
* `srtEnable` switches them on, and a script always starts with them off.
* `srtPosition` places them as a percentage down the picture, `1` to `95`.
* `srtClear` takes down whatever is showing, and `overlayBanner` shares the
same document.
* `srtLoadTrack` reads subtitles out of the video file itself when it has them.
* The look lives in `Singe/subtitle.rcss` and is yours to replace.
* Text over a moving picture needs a safe margin, its own contrast, lines you
broke yourself, and time to be read.
=== Next Time
That is the last of the text. Part four goes on without you having to read it
in order, and the next lesson opens a door that has been shut for twenty
lessons: `overlayGetWidth` and `overlayGetHeight` are two numbers, and a scene
has three. Lesson twenty-two is the third one.

426
docs/lessons/22-3d.adoc Normal file
View file

@ -0,0 +1,426 @@
== Lesson 22: Into 3D
image::learn/22-3d.png[The finished lesson, 480]
Everything you have drawn so far has been flat. Sprites, text, boxes, and
lines all live on the overlay, which is a sheet of glass at the front of the
window with the same coordinates it had in lesson one. This lesson puts a
world behind that glass: a place with depth, where a thing can be nearer or
further away, and where a camera decides what you see of it.
By the end you will have a box floating in space that you can turn with the
arrow keys, and the numbers for its angles printed in the corner by the same
`overlayPrint` you used on your very first day. That last part matters more
than it sounds, and there is a section about it.
=== A Scene, a Box, and a Light
Make a folder, put a file called `spin.singe` in it, and type this in.
[source,lua]
----
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)
function onOverlayUpdate()
nodeRotate(box, 0, 0.5, 0)
return OVERLAY_UPDATED
end
----
Run it with `Singe -R spin`. An orange box turns slowly on a dark blue
background, lit from above and to the right, with one face bright and the
others falling away into shadow.
There is no camera in that script. Singe put you at `(0, 0, 5)` looking at
the middle of the world, because a scene with no camera has to be seen from
somewhere. You will replace that with your own camera in a moment.
If the window stays black, read the console. On a machine whose graphics
cannot draw a 3D scene, `sceneEnable` stops the game with a message saying
so, and there is nothing to do about it but run the lesson elsewhere. 2D
games keep working on such a machine; 3D does not.
=== What Just Happened
The three calls at the top set up the world as a whole.
[source,lua]
----
sceneEnable(true)
sceneSetBackground(18, 20, 34)
sceneSetAmbient(35, 35, 45)
----
`sceneEnable(true)` turns the 3D layer on. It is the one call that every 3D
game makes, and until you make it nothing you build in the scene is drawn.
`sceneSetBackground` is the color the scene is wiped to each frame, red,
green, and blue from `0` to `255`, the same way colors have worked since
lesson nine. There is a fourth number you can give it for transparency,
which is how a game that plays video puts a 3D object in front of the film.
Leave it off and the background is solid.
`sceneSetAmbient` is a little light coming from every direction at once. Its
job is to keep the sides that no lamp reaches from being pure black. Turn it
down to `0, 0, 0` later and see how hard the picture becomes.
[source,lua]
----
local paint = materialNew()
materialSetColor(paint, 215, 95, 60)
materialSetRoughness(paint, 0.6)
----
A *material* is how a surface looks: its color, how shiny it is, whether it
has a picture on it. `materialNew` makes one and hands back a number that
stands for it, the same kind of handle `spriteLoad` gave you in lesson nine.
Keep it in a variable, because every later call needs it.
`materialSetRoughness` runs from `0`, a mirror with a tiny hard highlight, to
`1`, a chalky matte surface with no highlight at all. `0.6` is paint on
wood. There are a dozen more `materialSet` calls, and the next lesson uses
some of them; the manual's Material section lists them all.
[source,lua]
----
local box = nodeNew()
nodeSetMesh(box, meshBox(2, 2, 2), paint)
----
This is the important one. A *node* is a place in the world. It has a
position, a rotation, and a scale, and on its own it draws nothing at all: it
is a spot that things can be attached to. Everything in a 3D scene is a node,
including the lights and the camera.
`nodeNew` makes an empty one at the middle of the world. `nodeSetMesh` hangs
two things on it: a *mesh*, which is the shape, and the material, which is
how that shape looks. `meshBox(2, 2, 2)` builds a box two units wide, two
high, and two deep, and hands back a handle to it the way `materialNew` did.
Meshes and materials are worth sharing. One `meshBox` handle can be used by a
hundred nodes, and that is how you draw a hundred crates cheaply. Making a
hundred separate boxes that happen to be the same size is the beginner's way
to make a scene slow.
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 4, 6, 5)
nodeLookAt(sun, 0, 0, 0)
----
`lightNew` also makes a node -- one carrying a light -- and hands back its
node handle, so you place and aim it with the same calls you use for
everything else. `LIGHT_DIRECTIONAL` is a sun: it is so far away that only
the direction it shines in counts, which is why the position here matters
only in that `nodeLookAt` uses it to work out the direction.
Take those three lines out and run it again. The box goes nearly black,
because the only light left is the dim ambient. Light is the whole of lesson
twenty-three, and this is the first taste of the most common disappointment
in 3D: the model loaded fine, and the scene is black because nothing is
shining on it.
[source,lua]
----
nodeRotate(box, 0, 0.5, 0)
----
`nodeRotate` turns a node by so many degrees about each of its own three
axes, on top of whatever rotation it already had. Half a degree a frame, at
about sixty frames a second, is a turn every twelve seconds. This is the
same trick as lesson two: a small change, applied every frame, reads as
movement.
=== Which Way Is Which
Three numbers describe a place in the scene, and you have to know what each
one means before you can put anything anywhere.
* *X* runs to the right. Larger X is further right.
* *Y* runs up. Larger Y is higher. The floor of a scene is usually `y = 0`.
* *Z* runs toward you, out of the screen. Larger Z is nearer the default
camera, and *negative Z is the direction the camera looks*.
That last one catches everybody. Things you want in front of the camera go at
negative Z. It is not a quirk of Singe: glTF and Blender use the same
arrangement, so a model exported from Blender arrives facing the way Singe
expects.
Do not take my word for any of it. Add this line after the `nodeSetMesh`
line, save, and look:
[source,lua]
----
nodeSetPosition(box, 3, 1, 0)
----
The box jumps to the right and up. `nodeSetPosition` puts a node at an exact
place, and it replaces whatever position the node had. There is a matching
`nodeMove(node, dx, dy, dz)` that shifts a node by an amount instead, along
its own axes, so `nodeMove(ship, 0, 0, -0.1)` drives a ship forward whichever
way it happens to be pointing.
Try `nodeSetPosition(box, 0, 0, -20)` next. The box shrinks into the
distance, which is negative Z doing its job. Then try `(0, 0, 20)`: the box
vanishes, because you have put it behind the camera.
Two more calls finish the set.
[source,lua]
----
nodeSetRotation(box, 0, 45, 0)
nodeSetScale(box, 0.5)
----
`nodeSetRotation` sets the rotation outright in degrees, where `nodeRotate`
adds to it. The three numbers are about X, about Y, and about Z: turning
about Y is what a person standing on the floor does when they turn to face a
different way, and it is the one you will use most. `nodeSetScale` makes the
node bigger or smaller, either with one number for all three axes or with
three for one each.
Take both of those experiments back out before you go on. The rest of the
lesson starts from the script as you first typed it.
=== Your Own Camera
The default view is a courtesy, not a feature. Put a camera in.
[source,lua]
----
local camera = nodeNew()
nodeSetPosition(camera, 0, 2, 7)
nodeLookAt(camera, 0, 0, 0)
cameraSet(camera)
cameraSetPerspective(60, 0.1, 100)
----
Put that under the light, before your `onOverlayUpdate`, and run it. You are
now looking slightly down at the box from two units up and seven back.
A camera is a node like any other. `cameraSet` tells the engine which node to
look out of, and the scene is drawn from that node's position looking down
the node's own negative Z. Aiming a camera by hand with `nodeSetRotation`
would be miserable, so `nodeLookAt` does it for you: give it a point in the
world and it turns the node to face it.
`cameraSetPerspective` is the lens. The first number is the field of view in
degrees, up and down: how much of the world is squeezed into the height of
the window. A small number like `35` is a telephoto lens that flattens
everything; a large one like `90` is a wide angle that makes the room look
enormous and the corners bulge. `60` is a comfortable default. The other two
numbers are the nearest and furthest distances that get drawn, and things
outside that range are not drawn at all.
Because the camera is an ordinary node, everything you learned about nodes
works on it. Later you will hang a camera underneath a moving car and get a
chase camera for nothing.
=== Turning It with the Arrow Keys
Reading the coordinate system off a page is a poor way to learn it. Moving
something around in it is a good one. Replace your `onOverlayUpdate` with
this, and add the two new callbacks and the four variables above it.
[source,lua]
----
local turnLeft = false
local turnRight = false
local tiltUp = false
local tiltDown = false
----
Those go at the very top of the file, above everything else.
[source,lua]
----
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
return OVERLAY_UPDATED
end
----
`onInputPressed` and `onInputReleased` are callbacks, like `onOverlayUpdate`:
you write them, and Singe calls them when a control goes down or comes back
up. A press arrives once, not over and over while the key is held, which is
why each one sets a variable and the drawing callback does the work. That
pattern -- press sets a flag, the frame reads the flag -- is how you get
"while the key is held" out of an engine that only tells you about changes.
The `SWITCH_` names come from the engine itself, so nothing has to be loaded
to use them. They are not keys, either. They are
what a control *means*, and `controls.cfg` decides which key, button, or
stick direction produces each one. Your arrow keys send `SWITCH_LEFT` and
friends today; a gamepad's stick sends the same thing, and your script never
knows the difference.
The signs are chosen so that each arrow moves the face you are looking at in
the direction you pressed. Turning about Y by a positive angle swings that
near face to the right, so the left arrow asks for a negative one. If you
find that backwards, swap the signs; they are your controls, not the
engine's.
=== The Overlay Is Still on Top
Add these lines to `onOverlayUpdate`, above the `return`.
[source,lua]
----
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))
----
Nothing there is new except what it is printed over. `overlayPrint` is the
call from lesson one, counting in character cells, needing no font loaded,
and it is drawing on top of a 3D scene without being told anything about it.
That is the arrangement, and it is worth saying plainly: Singe draws the
video first, then the 3D scene over it, then the overlay over that. Your
score, your lives, your timer, your crosshair, and your subtitles are all
overlay work, exactly as they were in part two. A 3D game keeps its score in
the corner with the same five lines a 2D game uses.
`nodeGetRotation` hands back three numbers, the angles about X, Y, and Z, and
this line keeps the first two in `pitch` and `yaw`. Those are the usual names
for them: pitch is the nose going up and down, yaw is turning left and right.
`math.floor` throws away the fraction so the numbers stop flickering.
Watch the yaw as you hold the left arrow. It counts down past `-180` and
comes back round from `180`, because the angles are reported as the smallest
turn that gets you there rather than as a running total. If your game needs
to know how many times the player has spun, count that yourself.
=== Try It
. *Move the camera.* Change `nodeSetPosition(camera, 0, 2, 7)` to
`(0, 8, 7)`, then `(7, 2, 7)`, then `(0, 0.2, 7)`. Each time the
`nodeLookAt` keeps the box centered, so you get a different angle on the
same scene for one number.
. *Change the lens.* Try `cameraSetPerspective(30, 0.1, 100)`, then `(90,
0.1, 100)`, without moving the camera. A game feels fast with a wide field
of view and stately with a narrow one, and this is the cheapest mood knob
there is.
. *Put a second box in.* Copy the three lines that made the box, rename the
variable to `mark`, and give it `nodeSetPosition(mark, 3, 0, 0)` and
`nodeSetScale(mark, 0.3)`. Now you have a landmark to judge the first box
against. Move it to `(0, 3, 0)` and `(0, 0, -3)` in turn and say out loud
which axis is which.
. *Share the mesh.* Instead of calling `meshBox` twice, keep the first one in
a variable and hand the same handle to both nodes. Nothing looks different.
That is the point: one shape, two places.
. *Hang the camera on the box.* Keep the second box from the last step, or
there will be nothing left to move against. Then look up `nodeSetParent` in
the manual and hang the camera under the first box. Hold an arrow key: the
camera rides the box's rotation, so the box sits still and the rest of the
scene swings past. That is one line away from a first-person view, and it
is also how you find out that a chase camera wants a parent rather than
arithmetic.
=== Break It on Purpose
Mistype the material's name. Change the `nodeSetMesh` line to use `pain`
instead of `paint`:
[source,lua]
----
nodeSetMesh(box, meshBox(2, 2, 2), pain)
----
Singe stops before the window opens and prints something like:
----
17:nodeSetMesh: Argument 3 must be a number.
----
This is a different shape of error from the one in lesson one, and it comes
from the engine rather than from Lua. The number at the front is the line.
The name after it is the call that complained. The complaint itself is that
the third thing you handed it was not a number.
The reason is worth following, because it explains a whole family of errors.
`pain` is a variable that was never given a value, and in Lua an empty
variable is `nil` -- the value that means "nothing here". Handing `nil` to a
call that wants a material handle gives exactly this. When you see "must be a
number" from an engine call, look for a misspelled variable name on that
line, or for a handle you forgot to keep.
=== What You Learned
* `sceneEnable(true)` turns the 3D layer on, and nothing is drawn in 3D until
you call it.
* A node is a place in the world with a position, a rotation, and a scale.
Meshes, lights, and cameras all hang on nodes.
* A mesh is the shape and a material is the look, and one of each can be
shared by any number of nodes.
* X is right, Y is up, and negative Z is the way the camera looks.
* `nodeSetPosition` and `nodeSetRotation` set a value outright; `nodeMove` and
`nodeRotate` change it by an amount.
* Any node can be the camera. `nodeLookAt` aims it and `cameraSet` chooses it.
* The field of view in `cameraSetPerspective` changes how the scene feels
without moving anything.
* A scene with no light is black, which is the first thing to check when
nothing appears.
* The overlay is still there, still on top, and still where your score goes.
=== Next Time
A box is a poor dragon. Next lesson loads a real model -- one that the engine
already unpacked into your work folder, so there is nothing to download and
nothing to draw -- places it on the floor, and lights it properly. That is
lesson twenty-three.

431
docs/lessons/23-models.adoc Normal file
View file

@ -0,0 +1,431 @@
== Lesson 23: Models and Light
image::learn/23-models.png[The finished lesson, 480]
Boxes and spheres will take you a long way, but sooner or later the game
wants a dragon. A *model* is a shape somebody else built in a modeling
program: hundreds of triangles, its own colors and surfaces, sometimes a
skeleton and movements to go with it, all in one file. Loading one is two
lines. Getting it to look like anything is light, and light is most of this
lesson.
You need no artwork and no downloads. The first time you ever ran Singe it
unpacked a folder called `Singe` into your work folder, and two models are
sitting in it: `DragonModel.glb`, the dragon from the engine's own title
screen, and `SingeText.glb`, the lettering underneath it. This lesson uses
the dragon.
=== The Scene Around It
A model needs somewhere to stand. Make a folder, make `dragon.singe` in it,
and start with a floor, a light, and a camera. If you have done lesson
twenty-two none of this is new; if you have not, it is a scene turned on, a
flat slab to stand on, one sun, and a point to look from.
[source,lua]
----
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 camera = nodeNew()
nodeSetPosition(camera, 0, 2.6, 9)
nodeLookAt(camera, 0, 1.7, 0)
cameraSet(camera)
cameraSetPerspective(55, 0.1, 200)
function onOverlayUpdate()
return OVERLAY_UPDATED
end
----
Run it with `Singe -R dragon`. A gray slab, seen from slightly above,
on a dark blue background. The slab is twenty-four units across and a little
under half a unit thick, and sitting it at `y = -0.2` puts its top surface
exactly at `y = 0`, which makes everything you place later easy to reason
about: the floor is zero.
=== Loading the Dragon
Two lines.
[source,lua]
----
local dragonModel = modelLoad("Singe/DragonModel.glb")
local dragon = modelInstance(dragonModel)
----
Put them after the floor and save. A dragon appears, and it is far too big
for the picture -- you are looking at its middle.
=== What Just Happened
The two lines do different jobs, and the difference matters.
`modelLoad` reads the file, hands the shapes and their surfaces to the
graphics card, and keeps the whole thing ready. It is the slow call. Do it
once, when the game starts, and never in the middle of play.
`modelInstance` places a copy in the scene and hands you back a node -- an
ordinary node, the same kind `nodeNew` gives you, which you position, rotate,
scale, and delete with the calls you already know. Instancing is the cheap
call. Load one tree and instance it forty times and you have a wood, with one
copy of the shapes on the graphics card and forty places they are drawn.
What comes with the model is everything the file had in it: the shapes, the
surfaces and their colors, the way the parts are arranged, any skeleton, and
any movements. You do not build materials for a loaded model; it brought its
own.
=== Scale, and the First Thing That Goes Wrong
The dragon is about seven and a half units tall. Your camera is nine units
back. That is why it fills the window.
[source,lua]
----
nodeSetScale(dragon, 0.5)
----
Add that under the `modelInstance` line. Now it is under four units tall,
stands on the floor, and fits the shot.
This is not a detail; it is the commonest first problem with a model, and it
has two faces. A model built in centimeters arrives a hundred times too big
and you see one enormous surface filling the screen, which usually reads as
"my scene is broken" rather than "I am inside the dragon's neck". A model
built in some other unit arrives so small it is a dot, or invisible against
the background, and you assume the file failed to load.
The fix is always the same: a scale on the instance's node. Not on the parts,
not in the modeling program if you can help it, but one `nodeSetScale` on
the node `modelInstance` handed you, chosen by trying a number and looking.
`0.01` and `100` are both perfectly ordinary answers.
When you cannot tell how big something is, give yourself a ruler. Put a
one-unit box beside it:
[source,lua]
----
local ruler = nodeNew()
nodeSetMesh(ruler, meshBox(1, 1, 1), stone)
nodeSetPosition(ruler, 3, 0.5, 0)
----
A crate-sized object next to the model tells you in one glance whether the
model is a dragon or a mountain. Take the ruler out when you are done.
While you are placing it, turn it to face you:
[source,lua]
----
nodeSetRotation(dragon, 0, -60, 0)
----
The dragon was modeled facing along positive X, which is to the right, so
it starts in profile. Turning it about Y brings it round toward the camera.
A model's "front" is whatever direction the person who built it chose, and
finding out means turning it and looking.
=== Light
Run what you have now. The dragon is there, and it is dreary: a flat, dim
shape with no highlights and no shadow. That is because the only light in
your scene is the ambient, the faint glow from everywhere that
`sceneSetAmbient` sets.
This is the single most common disappointment in 3D, and it happens to
everyone: the model loaded perfectly and the scene is black, or nearly so,
because nothing is shining on it. Before you suspect the file, put a light
in.
There are three kinds, and each is a node, so you place and aim them with
`nodeSetPosition` and `nodeLookAt` like anything else.
[source,lua]
----
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)
----
`LIGHT_DIRECTIONAL` is a sun. It is treated as being infinitely far away, so
only the direction it shines in counts and everything in the scene gets the
same amount of it. Its position is there for `nodeLookAt` to aim it with.
This is the light that gives a scene its main shape, and most scenes want
exactly one.
`lightSetIntensity` is brightness. For a directional light, `1` lights a
white surface square-on to it up to white, so `1.8` is a bright day.
`lightSetColor` tints it: a slightly warm white for sun, a cold blue for
moonlight, and the manual's Light section for the rest.
`lightSetShadow(sun, true)` is what makes the dragon sit on the floor rather
than hover above it. Shadows are off by default because each shadow-casting
light costs another pass over the whole scene every frame. Turn it on for the
one light that matters and leave it off for the others; a small machine will
thank you.
[source,lua]
----
local torch = lightNew(LIGHT_POINT)
nodeSetPosition(torch, 3.5, 1.2, 3.0)
lightSetColor(torch, 255, 150, 70)
lightSetIntensity(torch, 14)
lightSetRange(torch, 12)
----
`LIGHT_POINT` is a bulb. It sits at a place, shines in every direction, and
fades with distance. That fading is why the number is `14` and not `1`: the
intensity of a point light is its brightness at the light itself, and by the
time it has crossed a few units there is not much left. A bulb lighting a
room usually wants something in the tens. If a point light seems to do
nothing, that is almost always the reason.
`lightSetRange` says how far it reaches before it stops entirely, which keeps
a lamp in one room from faintly lighting the next one and costs nothing to
set.
The third kind is `LIGHT_SPOT`, a cone pointing down the node's own negative
Z, with `lightSetCone` for the angle of the bright middle and the soft edge:
a torch, a headlight, a searchlight. Up to eight lights shine at once, which
is more than most scenes need. If you want more, you want fewer.
Run it now. A near-white sun from the left, a warm orange glow from the
right, a shadow on the slab, and a dragon with a front and a back.
=== Materials, at the Level You Need Today
The dragon brought its own materials, and there are three of them: a dark
metal, a paler champagne metal, and a pale glass you can see a little way
into. Most of its parts are cut from more than one. You do not have to touch
any of it, and most of the time you should not.
When you do want to change how a part of a model looks, you make a material
the way lesson twenty-two did and put it on the part:
[source,lua]
----
local red = materialNew()
materialSetColor(red, 220, 50, 40)
materialSetRoughness(red, 0.4)
nodeSetMaterial(nodeFind("crest", dragon), red)
----
`nodeFind(name, root)` searches a model instance for a part by the name it
had in the file and hands back its node, or `nil` when there is no such name.
That is how you reach inside a model: a turret, a wheel, a hand to hang a
torch on, or the dragon's crest.
The crest is one piece of one material, so its node carries the shape itself
and the line above repaints it. A part built from several materials is kept
as a node with one child per piece, and putting a material on the parent
changes nothing you can see. When a `nodeSetMaterial` appears to do nothing,
that is why, and `nodeGetChildren` will show you what is underneath.
Four numbers describe most surfaces. The color, from `materialSetColor`.
`materialSetRoughness`, from `0` for a mirror to `1` for chalk.
`materialSetMetallic`, which is `0` for nearly everything -- paint, wood,
plastic, skin -- and `1` for actual metal, with little use for the values
between. And `materialSetEmissive`, which makes a surface glow with its own
color for screens, lamps, and hot iron, without lighting anything around it.
One thing to know about metal: a metal surface shows you its reflections
rather than a color of its own, so in a scene with nothing to reflect it
looks dark and dull. That is why the dragon's dark metal needs the sun to
come alive. The proper fix is a sky (`sceneSetSky`), which wraps the scene in
a panorama that both shows behind everything and lights it; the manual's
"The Look of the Frame" covers it.
=== Movement the Model Brings with It
Many models carry animations: a walk, a run, an idle, a door opening. They
are stored in the file, and playing one is a single call on the instance's
root node.
[source,lua]
----
animationPlay(dragon, "Walk", true)
----
The second argument is the clip's name as the file stores it, or its number.
`true` means loop it forever. Two more optional numbers set the speed and the
crossfade: `animationPlay(hero, "Run", true, 1.2, 0.4)` plays the run a fifth
faster and blends into it over four tenths of a second, so a walk becomes a
run without a snap. `animationStop`, `animationPause`, and
`animationIsPlaying` do what their names say.
You cannot run that line yet, and here is the honest reason: the dragon in
your `Singe` folder has no animations in it. It is a model, not a performance.
Asking for a clip that is not there stops the game with an error, so never
assume -- ask:
[source,lua]
----
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
----
`modelGetAnimations` hands back a table of the clip names in the file, in
order, so `clips[1]` is the first one and `#clips` is how many there are --
the table calls from lesson six, on a table the engine filled in. Run it and
watch the console: the message appears, because that count is zero.
Get into the habit of that check with any model you did not make yourself. It
costs three lines and it turns "the game died on startup" into a sentence in
the log.
=== Moving the Parts Yourself
A model with no animations is not a statue. Its parts are nodes, and nodes
turn.
[source,lua]
----
local wingLeft = nodeFind("wingL", dragon)
local wingRight = nodeFind("wingR", dragon)
----
[source,lua]
----
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
----
Save it. The wings beat, the dragon turns slowly on the spot, and the shadow
on the floor beats with them.
`singeGetTicks` is the number of milliseconds since the engine started, so it
climbs steadily forever. `math.sin` takes that climbing number and gives back
a value that slides smoothly from `-1` to `1` and back, a full beat about
every second and a half at this divisor. Multiplying by `22` turns it into an
angle of twenty-two degrees each way. Dividing by a bigger number slows the
beat; multiplying by a bigger number widens it. That one line is the cheapest animation in
existence and it is worth keeping in your pocket.
The wings turn about their own X axis, and they turn around the shoulder
rather than the middle of the animal, because whoever built the file put each
part's node where the joint belongs. A model that was not built that way will
swing its parts around the model's center instead, and the fix is to move the
part's node, which the manual's Node section covers.
Notice that the whole dragon turning does not interfere with the wings
turning. Parts hang under the instance root, so rotating the root carries
everything with it, and each wing's own rotation is on top of that. That
arrangement -- a thing inside a thing inside a thing, each moving in its
parent's world -- is what a scene tree is for.
=== Your Own Models
When you make your own, export from Blender -- or Maya, or 3ds Max, or
anything else modern -- as *glTF 2.0 binary*, which is a `.glb` file. That is
Blender's default choice and it is the only model format Singe reads.
The one rule is that the file must be self-contained: the shapes, the
pictures on them, and the movements all inside the single `.glb`. A file that
refers to a `.bin` or a `.png` sitting next to it is refused, with a message
naming what it wanted. Blender's exporter does the right thing on its default
settings.
Not everything you can build survives the trip. The manual's "Models and
Animation" section, in the 3D Scenes chapter, is the list: what is read, what
is quietly ignored, and the limits (a skeleton may have up to a hundred and
twenty-eight joints, for instance). Read it once before you spend a weekend
on a model, not after.
=== Try It
. *Kill the lights.* Comment out the whole `sun` block by putting `--` at the
start of each line, and run it. Then do the same to `sceneSetAmbient`.
Remember what that looks like, because one day it will happen when you did
not mean it.
. *Move the sun.* Change `nodeLookAt(sun, 0, 1.5, 0)` to
`nodeLookAt(sun, 0, 20, 0)` so the sun aims over the dragon's head. Watch
the shadow and the highlights go. Aiming a light is not decoration.
. *Warm the torch up.* Raise the point light's intensity from `14` to `60`
and drop its range to `4`. Then move it to `(0, 6, 0)`, above the dragon.
A point light is a bulb, and you are choosing where the bulb hangs.
. *Instance it twice.* Call `modelInstance(dragonModel)` a second time, keep
it in another variable, scale it, and put it at `(-4, 0, -3)`. One load, two
dragons. Then try loading the model twice instead and understand why you
should not.
. *Find another part.* The dragon's parts are named `head`, `neck`, `tail`,
`body`, `crest`, `hornL`, `hornR`, `wingL`, `wingR`, `foreLegL`, `foreLegR`,
`hindLegL`, and `hindLegR`. Make the tail sway with its own `math.sin` at a
different speed from the wings. Then try `nodeFind("wings", dragon)` and see
what a wrong name does when you hand the `nil` straight to
`nodeSetRotation`.
=== Break It on Purpose
Change the file name to `Singe/Dragonmodel.glb` -- one capital letter --
and run it. Singe stops before the window opens:
----
15:modelLoad: Unable to read Singe/Dragonmodel.glb.
----
The line, the call, and the complaint, in that order. "Unable to read" means
the engine looked and found nothing, which is nearly always one of three
things: the name is misspelled, the capitalization is wrong, or the file is
not where you said. On Windows the capitalization would have been forgiven
and your game would then fail for everyone on Linux and macOS, which is a
worse bug than this one. Type names exactly.
The same message with a real file name means the file is not beside your
script. The `Singe` folder is created in the folder you run the engine
*from*, so if you started Singe from somewhere else, `Singe/DragonModel.glb`
points somewhere else too.
=== What You Learned
* `modelLoad` reads a `.glb` once; `modelInstance` places copies of it as
often as you like.
* An instance's root is an ordinary node: position, rotate, scale, and delete
it like any other.
* A model brings its own shapes and materials with it.
* A model arrives at the size its author used, which is why one
`nodeSetScale` on the instance is part of placing it.
* A scene with no light is black or nearly so, and that is the first thing to
check when a model looks wrong.
* A directional light is a sun, a point light is a bulb that fades with
distance and needs a much larger intensity, and a spot light is a cone.
* Shadows are off until `lightSetShadow` turns them on, and each one costs.
* `nodeFind` reaches a part of a model by its name from the file.
* `modelGetAnimations` says what a model can play, and asking for a clip that
is not there is an error.
* A model with no animations can still be moved a part at a time.
=== Next Time
Everything in this scene moves because you told it to, frame by frame. Next
lesson hands that job to a physics engine: crates that fall, roll, stack, and
knock each other over without you writing a line of arithmetic -- and the
harder question of when you should let it. That is lesson twenty-four.

View file

@ -0,0 +1,524 @@
== Lesson 24: Physics
image::learn/24-physics.png[The finished lesson, 480]
Up to now every moving thing in your games has moved because you did the
arithmetic. A number went up by two each frame; if it went past an edge you
turned it round. That is the honest way to make a game move, and most games
are made of it.
This lesson hands the arithmetic to somebody else. A *physics engine*
simulates the world -- gravity, falling, sliding, stacking, bouncing,
rolling, and things hitting each other -- and moves your nodes for you. By
the end you will have a tray of crates and a cannon that knocks them over.
The last section is about when not to do any of this, and it is the part to
read twice.
=== What It Does, and What It Does Not
Give a node a *body* and the simulation takes charge of it. From then on it
falls under gravity, lands on whatever is beneath it, tips when it is hit off
center, rests in a stack, slides to a stop, and the node follows wherever the
simulation puts it. You stop setting its position. That is the deal: you
hand over the position and rotation of that node, and in exchange you get
behavior you did not have to write.
What it does not do is anything about your game. It does not know what a
score is, or that the red crate is the one worth points, or that falling off
the edge means a life. It does not animate a character walking, it does not
know how a jump should feel, and it will not make an object move in a way you
can predict exactly. Physics is a machine for producing plausible mess.
Units are meters, kilograms, and seconds. Gravity is `0, -9.81, 0` unless you
say otherwise, which is Earth's, downward, in meters per second squared. A
crate that is one unit wide is one meter wide, and it falls like one. If your
scene is built at some other scale, everything will look like it is falling
through syrup or through a cartoon, and the fix is to build at meters.
=== A Floor and One Crate
Make a folder, make `crates.singe`, and type this in. It is a scene, a slab,
a crate above the slab, and three lines of physics.
[source,lua]
----
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 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 crate = nodeNew()
nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), wood)
nodeSetPosition(crate, 0.3, 4, 0)
nodeSetRotation(crate, 20, 0, 15)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8)
bodySetMass(crate, 4)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -5, 9, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.7)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 4, 11)
nodeLookAt(camera, 0, 1, 0)
cameraSet(camera)
function onOverlayUpdate()
return OVERLAY_UPDATED
end
----
Run it with `Singe -R crates`. The crate falls from four meters up,
lands on a corner because you tilted it, rocks over onto a face, and stops.
Press *F5* to run it again and watch it once more.
Nothing in `onOverlayUpdate` moves anything. The two `bodyNew` calls did all
of it.
=== What Just Happened
[source,lua]
----
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.4, 16)
----
`bodyNew` gives a node a body. It takes the node, what kind of body it is,
what shape the simulation should use for it, and the sizes of that shape.
There are three kinds, and choosing the right one is most of the skill.
* `BODY_STATIC` never moves. It is placed where the node is when you make the
body, and there it stays forever. Floors, walls, the ground, the cabinet,
the level. Moving its node afterwards does not move the body, which is a
trap worth remembering: static means static.
* `BODY_DYNAMIC` is moved by the simulation. It falls, bounces, slides, gets
pushed, and drags its node along with it. This is a crate, a barrel, a ball,
a piece of debris.
* `BODY_KINEMATIC` goes where *you* put its node, and shoves dynamic bodies
out of its way without ever being pushed itself. A moving platform, a
paddle, a door on a track, a character's swinging fist. It is the bridge
between your arithmetic and the simulation: you drive it, it drives them.
The shape is not the mesh. It is a rough stand-in the simulation uses because
it can answer questions about a box far faster than about nine hundred
triangles. `SHAPE_BOX` takes a width, a height, and a depth; `SHAPE_SPHERE`
takes a radius; `SHAPE_CAPSULE` and `SHAPE_CYLINDER` take a radius and a
height. There is `SHAPE_HULL`, which shrink-wraps whatever mesh is under the
node -- the usual answer for a model -- and `SHAPE_MESH`, the exact triangles,
which static scenery may use and a falling object may not.
The sizes are yours to get right. Nothing checks that the body you described
matches the mesh you drew, and a crate drawn at `0.8` with a body made at `2`
will float above the floor and knock things over from a distance. When
something behaves as if it were a different size, it is.
[source,lua]
----
bodySetMass(crate, 4)
----
A dynamic body's mass comes from its shape at the density of water, which
makes things much heavier than you expect: a box the size of a door weighs
about a hundred and forty kilograms, and nothing you can throw at it will
move it. Set the mass of anything meant to feel light. Four kilograms is a
crate you could pick up.
Mass matters because forces are shared out by it. The same shove moves a
light thing further, and an impulse of mass times speed is what it takes to
set something moving at that speed. Tune the mass first and the forces
afterwards, or you will chase your own tail.
=== Making Things Move
Three calls push a dynamic body around, and they are not interchangeable.
`bodySetVelocity(node, x, y, z)` says "you are now traveling at this speed",
replacing whatever it was doing. This is a serve, a launch, a spawn with some
speed already on it.
`bodyApplyImpulse(node, ix, iy, iz)` is a hit: a kick, an explosion, a bat
meeting a ball. It adds to what the body was already doing. Give it a point
in the world as three more numbers and it hits *there* instead of at the
middle, so a crate struck near the top tips over rather than sliding.
`bodyApplyForce(node, fx, fy, fz)` is a push that lasts: a rocket, a fan, a
conveyor. A force applied for one frame does almost nothing, which surprises
people who expected an impulse.
Gravity itself is `physicsSetGravity(x, y, z)` and it is world-wide. The moon
is `physicsSetGravity(0, -1.62, 0)`, space is all zeroes, and a game with
gravity sideways is one line away.
Two more settings finish the feel of an object. `bodySetBounce(node, amount)`
runs from `0`, which stops dead, to `1`, which comes back with everything it
arrived with; the default is a barely-there `0.1`. `bodySetFriction(node,
amount)` is `0` for ice and `0.5` by default. Both combine between the two
things touching, so an icy floor needs the low number on the floor, not on
everything that walks across it.
=== Knowing When Things Touch
[source,lua]
----
function onCollision(nodeA, nodeB, x, y, z, speed)
if speed > 4 then
knocks = knocks + 1
end
end
----
`knocks` there is a variable of your own, waiting at the top of the file with
`local knocks = 0`, and the toy below prints it.
`onCollision` is a callback, like `onOverlayUpdate`: you write it, the engine
calls it. It arrives once for each new contact -- two bodies that stay
touching do not report again -- with both nodes, the point in the world where
they met, and how fast they met. The speed is what lets a bump and a crash
sound different, and it is why the test above ignores the gentle settling of a
stack.
The other half of the pair is `onTrigger`. Make a body a trigger with
`bodySetTrigger(node, true)` and it stops pushing things: it becomes a region
that reports what enters and leaves it through `onTrigger(trigger, other,
entered)`. A finish line, a doorway, a pit, a pickup. It is the 3D
descendant of the collision checks from lesson eleven, and for anything
shaped like "did the player reach here" it is the call you want rather than a
solid body.
=== The Toy
Now the whole thing. Start from the script above and change it into this: a
tray with four walls, six crates in a pyramid, and a steel ball you can aim
and fire. The pieces are all ones you have met.
Everything the new code remembers goes at the top of the file, above the
scene:
[source,lua]
----
local crates = {}
local balls = {}
local aimX = 0
local aimLeft = false
local aimRight = false
local knocks = 0
local showShapes = false
----
Two more shared handles go with the materials: a polished metal for the ball
and the aiming marker, and one crate mesh for all six crates.
[source,lua]
----
local steel = materialNew()
materialSetColor(steel, 200, 205, 215)
materialSetMetallic(steel, 1)
materialSetRoughness(steel, 0.25)
local crateMesh = meshBox(0.8, 0.8, 0.8)
----
The tray's walls are four static boxes, made in a loop from a table of
placements, which is lesson six doing honest work:
[source,lua]
----
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
----
Each entry is an X, a Z, and a turn about Y. Two walls lie across the back
and front; two are turned ninety degrees to make the sides. One mesh does for
all four, and the body is made after the node is placed and turned, because a
static body takes the node's position and rotation at the moment it is made.
The crates come from a function, so that six of them is six lines:
[source,lua]
----
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
----
It keeps every crate it makes in the `crates` table, which is how the drawing
callback can ask all of them a question later. Six calls build the pyramid,
three on the floor, two on those, and one on top:
[source,lua]
----
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)
----
The upper rows sit a finger's width above the ones below and drop into place
the moment the game starts. Leaving a small gap is easier than stacking
exactly, and it is kinder to the simulation than handing it a pile that is
already pressed together.
Firing makes a new ball each time:
[source,lua]
----
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
----
Look at the order. The node is made, given a shape to draw, and *placed*, and
only then given a body. A dynamic body starts where its node is, and after
that the traffic runs the other way: the body moves the node. Setting the
position of a dynamic node yourself afterwards fights the simulation and the
simulation wins. When something must be moved, give it a velocity, hit it
with an impulse, or do what this does -- make a new one and let the old one
go.
Letting the old one go is `nodeDelete`, which frees the node, everything
under it, and the body that was riding on it. Without those three lines every
shot would stay in the world forever, and a few hundred balls later the
simulation would be doing a great deal of work on things nobody can see.
Cleaning up is part of using physics.
The controls and the display are the rest:
[source,lua]
----
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
----
Space fires, shift turns the physics view on and off, and the arrows hold
down to slide the launch point. A flat metal plate with no body on it, moved
each frame to `aimX`, shows where the next ball will come from. The full
script, with the release callback and the rest of the setup, is
`24-physics.singe` in the `learn` folder.
`physicsSetDebug` is the most useful call in this lesson. It draws what the
simulation actually holds -- the real shapes in wireframe, and a red cross
wherever two bodies touched this step -- over the top of your scene. The
first time a crate hovers a hand's width above the floor, or falls through it,
or knocks something over from two meters away, turn this on and the answer
will be on the screen. Add `DEBUG_STATIC` to see the floor and walls as well.
It costs nothing while it is off.
Finally, the counting:
[source,lua]
----
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
----
A crate is counted as knocked over when it is leaning more than thirty
degrees, which is a game rule written in one line of arithmetic on top of
whatever the simulation did. `nodeGetRotation` gives three angles and this
wants the first and the third, so the middle one is caught in a variable
called `_`. That underscore is an ordinary name with nothing special about
it, used by convention to say "I am ignoring this one".
`bodyIsResting` says whether the simulation has put a body to sleep because
it stopped moving. That is how you know a shot is over: not a timer, not a
guess, but every crate asleep. A bowling game scores its frame exactly there.
=== When Not to Use It
Physics is the most tempting tool in any engine and the one that wastes the
most weekends. Three things are true about it, and none of them are the
engine's fault.
*It is expensive.* Every dynamic body is work every step, sixty times a
second, whether anybody is looking at it or not. Forty crates is fine. Four
thousand is not, and on a small machine four hundred is not.
*It is unpredictable.* Run your toy twice and the crates land differently.
That is what makes it look alive, and it is also why a game cannot rely on it
for anything that must come out the same each time. A door that must open, a
platform that must arrive, a jump the player must be able to make: those are
arithmetic, or a tween, or a kinematic body on a path you wrote. Never leave
a thing the player must do to a simulation that can drop it behind a crate.
*It is hard to tune.* The mass, the friction, the bounce, the shape, and the
size all pull on each other, and "it feels wrong" rarely has one cause. Every
hour you spend making a physics crate feel good is an hour you did not spend
on the game.
So reach for it when the mess *is* the point: things toppling, debris flying,
a pile settling, a ball rattling round a room, a vehicle rolling over rough
ground, a ragdoll flopping down a staircase. Nobody can write that by hand
and nobody can tell you exactly how it should look.
Do not reach for it when you know what should happen. A bullet that flies
straight is two lines from lesson two. A platform that goes up and down is
`math.sin`. A player that walks and jumps the way players expect wants a
character controller (`playerNew`, in the manual), because a person is not a
box and never behaves like one. An enemy that patrols wants a path. A menu
that slides in wants a tween.
The rule that serves best: use physics for the things the player watches, and
arithmetic for the things the player does.
=== Try It
. *Change the world.* Add `physicsSetGravity(0, -1.62, 0)` near the top and
fire again. Then try `0, -30, 0`. The same crates, a different planet, one
line.
. *Change the ball.* Make the ball ten times heavier with `bodySetMass(ball,
30)`, then ten times lighter with `0.3`, keeping the speed the same. Then
put the mass back and change the `-14` instead. Two ways to hit harder, and
they do not feel the same.
. *Make the crates ice.* Add `bodySetFriction(crate, 0.02)` in `crateAt`, and
add a line giving the floor a low friction too. Watch the pile refuse to
stand up in the first place.
. *Wrong the shape on purpose.* In `crateAt`, change the body's sizes to
`2, 2, 2` while the mesh stays `0.8`. Run it, see the crates hover and
shove each other from nowhere, then press shift and watch the debug view
explain it in one picture.
. *Score it properly.* Use `bodyIsResting` on every crate to notice when the
shot has finished, and only then count how many are over and add that to a
score. That is the scoring half of a bowling game.
=== Break It on Purpose
Add a line under the floor's `bodyNew` making the floor heavy, which is a
reasonable thing to think you want:
[source,lua]
----
bodySetMass(floor, 200)
----
Singe stops the game with something like:
----
32:bodySetMass: Node 1 is not a dynamic body, or the mass is not positive.
----
The first number is the line in your own file, so yours will differ.
The number after `Node` is the node's handle, the number `nodeNew` handed
back. The complaint is exact: mass belongs to things the simulation moves,
and a static body is not moved by anything, so it has no mass to set. Asking
is a sign that the body is the wrong type for what you had in mind -- and if
you truly want a heavy floor that can be shifted, what you want is a dynamic
body with a large mass, or a kinematic one you move yourself.
The same message appears for a mass of `0`, which people write meaning
"weightless". Weightless is not a mass. It is a world with no gravity, or
`playerSetGravityScale` on a character controller.
=== What You Learned
* A body attaches to a node, and from then on the node and the body are one
thing.
* Static never moves, dynamic is moved by the simulation, and kinematic is
moved by you and pushes everything else aside.
* The collision shape is a stand-in for the mesh, and you are responsible for
its sizes matching what you drew.
* Mass defaults to the shape's volume at the density of water, so set it on
anything meant to feel light.
* `bodySetVelocity` replaces a body's motion, `bodyApplyImpulse` is a hit, and
`bodyApplyForce` is a push that lasts.
* Do not set the position of a dynamic node; delete it and make a new one
when you must start it over.
* `onCollision` reports each new contact with its point and its speed, and a
trigger body reports arrivals and departures through `onTrigger`.
* `bodyIsResting` tells you when everything has stopped, which is usually when
a turn is over.
* `physicsSetDebug` draws the simulation's own view, and it answers most
physics questions faster than reading code does.
* Physics is expensive, unrepeatable, and fiddly. Use it for the mess, not for
the things the game depends on.
=== Next Time
Crates falling is one kind of spectacle. The other kind is made of a thousand
tiny things that live for half a second: sparks, smoke, fire, rain, and dust.
Next lesson is emitters, and it ends with an explosion. That is lesson
twenty-five.

Some files were not shown because too many files have changed in this diff Show more