singe/docs/lessons/30-cabinet.adoc
2026-09-22 21:57:42 -05:00

717 lines
25 KiB
Text

== Lesson 30: In a Cabinet
image::learn/30-cabinet.png[The finished lesson, 480]
There is a difference between a game and an arcade machine, and it is not the
wood. A machine switches itself on in the morning, plays to an empty room, takes
a coin from somebody who has never read a word about it, and is still running
at midnight with nobody having touched a keyboard, because there is no
keyboard. Everything in this lesson comes from that.
You do not need a cabinet to do any of it. A stick and two buttons on a desk,
or a gamepad, is enough to find every mistake in this lesson, and most of what
you learn here makes a game better on a desktop too.
=== There Is No Keyboard
Start with what is missing, because it is the thing that breaks games.
A cabinet has a stick, some buttons, a coin door, a start button, and somewhere
inside it a small switch an operator can reach. That is all. No Escape, no
letters, no mouse, no way to type. So:
* Every menu in your game has to be reachable with up, down, and a button. If
any screen in your game says "press Y to continue", it does not work in a
cabinet.
* Entering initials is three buttons and a letter that scrolls, not a text
field. That is why arcade games have three letter names.
* A prompt that names a key by its letter is wrong. Say "button 1", or draw the
button.
* There is no way out. A player cannot quit, and should not be able to.
Go through your game once, screen by screen, and ask of each: could somebody
get through this with a stick and one button? That pass on your own game is
worth more than the rest of this lesson.
=== controls.cfg
Since lesson three you have been reading input as `SWITCH_UP`,
`SWITCH_BUTTON1`, `SWITCH_COIN1`, and the rest, and you have never once said
which key those are. That is the point of them. Somewhere between the
microswitch in the cabinet and your `onInputPressed` is a file that decides,
and your game is not in that conversation.
The file is `controls.cfg`. It is Lua, like your script, and it is a list of
tables -- one per switch -- naming everything that can produce it:
[source,lua]
----
INPUT_1P_START = { SCANCODE.MAIN_1, GAMEPAD_0.BUTTON_START }
INPUT_1P_COIN = { SCANCODE.MAIN_5, GAMEPAD_0.BUTTON_LEFT_BUMPER }
INPUT_ACTION_1 = { SCANCODE.LCTRL, GAMEPAD_0.BUTTON_A }
INPUT_UP = { SCANCODE.UP, GAMEPAD_0.DPAD_UP, GAMEPAD_0.AXIS_LEFT_Y_U }
----
Each `INPUT_` name in the file becomes the matching `SWITCH_` value in your
game: `INPUT_ACTION_1` is the `SWITCH_BUTTON1` you have been testing for, and
`INPUT_1P_COIN` is `SWITCH_COIN1`. Every entry in a table produces the same
switch, so a game does not care whether the player pressed the key, the pad
button, or pushed the stick.
You do not have to write the whole file. Singe starts with its own defaults and
reads `controls.cfg` from four places in turn -- the folder Singe was started
in, the folder above the game's data directory, the game's data directory, and
the game's script folder -- and each one it finds overrides what came before,
key by key. So a cabinet has one file at the top for the whole machine, and a
game that needs one extra button has a four line file of its own. There is a
`controls.cfg.example` in the `Singe` folder to copy from.
`DEAD_ZONE` is in there too: how far a stick has to move before it counts as
pressed, in the raw units the hardware reports, where full deflection is
`32767`. The default is `15000`. A worn stick that creeps needs a bigger
number, and `DEAD_ZONES` sets one axis at a time for the one stick that is
tired. The manual's section on customizing the controls covers all of it.
=== Every Device Is a Gamepad
An arcade cabinet's buttons are wired to an encoder board, which the computer
sees as some kind of joystick, and it is nothing like an Xbox pad. Singe writes
a mapping for a device like that itself and then opens it as `GAMEPAD_0`, so it
reaches `controls.cfg` and your game exactly as a recognised pad does. There is
one family of device and no second vocabulary to learn.
The mapping it invents is a straight one: the board's buttons in order become
`BUTTON_A`, `BUTTON_B`, `BUTTON_X`, `BUTTON_Y` and so on. If your cabinet's
buttons are not wired in that order, they will come out in the wrong places,
and the fix is not in your game: `--program` traces the mapping Singe wrote in
a form you can edit and put in a `gamecontrollerdb.txt` beside the game.
When a button "does nothing", the menu's *Input Test* tool answers the question
in five seconds. It shows every device the engine can see, and the last switch
a game would have been given. Either the switch arrives and your game is
ignoring it, or it never arrives and the problem is the mapping or the wire.
=== Four Switches Are Not Yours
Most switches go straight to your game. Four do not: the engine acts on pause,
quit, screenshot, and the mouse grab toggle itself.
Pause is genuinely useful for free. While the engine owns it, the key mapped to
`INPUT_PAUSE` freezes everything -- the disc, every video, every sound, and
your script -- draws its own indicator, and thaws again on the next press.
Anything your game thought was held down is released properly and pressed again
afterwards, so a stick held through a pause never sticks.
Quit is the one to take away, which the next section does.
=== Coins, Credits, and Start
An arcade machine has a shape older than any of us, and players know it without
being told:
. *Attract.* Nobody is there. The machine shows what it is, blinks `INSERT
COIN`, and asks for nothing.
. *Credit.* A coin arrives as `SWITCH_COIN1`. Coins become credits at whatever
rate the operator set -- not always one for one.
. *Play.* `SWITCH_START1` spends a credit and starts.
. *Game over.* The score, briefly, and back to attract on its own. Nobody
presses anything to leave; the player has walked away.
Two details people get wrong. Coins are counted, not spent: at two coins per
credit, one coin has to sit there waiting for its partner, and a player who put
one in and walked off has left it for the next person. And the machine must
find its own way back to attract, because the last thing a player does is stop
playing.
=== Behind the Service Switch
Inside the coin door there is a button that the player never sees and the
operator uses constantly. It is `SWITCH_SERVICE`, and the engine hands it
straight to you.
An operator opening your service menu expects to find, roughly in this order:
* *the price* -- coins per credit, and a free play setting for a machine at
home or at a show;
* *the difficulty*, because a machine in a bar and a machine in an arcade want
different answers;
* *a way to clear the high score table*, which is the single most requested
thing in this list;
* *what the machine is* -- the engine version, the screen, where the data
lives -- so a fault report has facts in it;
* *a way out*, without turning anything off.
Everything an operator changes has to survive the power being cut, which in an
arcade means cut in the middle of a play, without warning, by somebody pulling
a plug. `saveSet` writes at the end of the frame, which is soon enough for a
score and not soon enough for a setting somebody just adjusted. `saveFlush()`
writes immediately, and a service menu is exactly what it is for.
The bundled menu's own service tools, behind the same switch, are worth
knowing since they are on every machine your game will run on: the input test,
the sound and light gun tests, the display geometry, the audio delay, the
online account, and a page that deletes any one game's saved data. Yours does
not have to duplicate them.
=== A Machine That Starts Itself and Never Quits
The cabinet is switched on at the wall. Nobody logs in. A game must be on the
screen in a minute or so and stay there.
Getting Singe to run at power on is your operating system's job rather than
Singe's -- the machine starts the `Menu.sh` or `Menu.bat` that the engine
writes beside your games, or your game directly. What is your job is what
happens for the rest of the day.
Take the quit key away:
[source,lua]
----
singeSetQuitKeyEnabled(false)
----
The engine then ignores the switch mapped to `INPUT_QUIT` and delivers it to
you as `SWITCH_QUIT` like any other, so you can ask first, save first, or do
nothing at all. The window's close button always quits, whatever you set, which
matters in a minute.
Then a handful of options belong to the machine rather than to your script, and
go in a `settings.cfg` beside the games -- the same names as the command line
options, with the dashes removed:
[source,lua]
----
fullscreen = true
screen = 2
idleexit = 900
startsilent = true
----
`idleexit` quits after that many seconds with no input of any kind, for a
cabinet that should hand the screen back to a menu or a front end.
`startsilent` starts muted until somebody touches something, which is how a
room full of machines is bearable. There are more; the manual's settings and
command line sections list every one.
=== Artwork Round the Picture
A real cabinet has a printed bezel around the screen: artwork with a hole in it
that the game shows through. Singe draws one for you.
----
Singe --bezel cabinet.png MyGame.game
----
The file is looked for in a `bezels` folder inside the game -- a packed game
can carry its own -- and then in a `bezels` folder in the data directory, so a
player can put their own artwork on a game without touching it. Beside the
image, named after it, a small Lua file says where the picture goes, in the
artwork's own pixels:
[source,lua]
----
-- bezels/cabinet.cfg
CUTOUT = { x = 200, y = 120, width = 800, height = 500 }
----
Because those numbers are the artwork's own, they stay right at any window
size: the image and the hole scale together. With no such file the picture
keeps the whole window as it always has.
By default the artwork is behind everything and your game covers the hole.
`--bezelflip` puts it in front instead, so only its transparency lets the game
through, which is what you want for soft or shaped edges -- and then your
overlay is underneath it too, unless you say otherwise:
[source,lua]
----
if mainBezelLoaded() then
setOverlayOnTop(true)
end
----
`mainBezelLoaded()` also tells you whether there is artwork at all, which is
how a game decides to draw its own frame when there is none.
=== A Monitor on Its Side
Plenty of arcade monitors are mounted vertically. Turn the whole presentation:
----
Singe --rotate=90 MyGame.game
----
The picture, the overlay, any GUI, a 3D scene, and the particles all turn
together, and mouse and light gun positions are turned back, so a click still
lands where the player sees it. A quarter turn also swaps the shape of the
area, so a tall window is filled rather than letterboxed into a strip.
Only `0`, `90`, `180`, and `270` are accepted. A script can change it while
running with `vldpSetRotate`, which answers `false` for anything else rather
than ending your game, and `vldpGetRotate()` reads it back -- which is worth
putting on a service screen, since "the picture is sideways" and "the monitor
is sideways" are different faults.
What rotation does not do is rewrite your game. A game drawn for a wide screen,
turned ninety degrees, is a wide game on its side. Designing for a tall screen
means designing for a tall screen.
=== The Script
The whole shape: attract, coin, credit, play, game over, service, and a quit
that asks. The game in the middle is one button and a fifteen second timer,
because the game is not the point this time -- the machine around it is.
[source,lua]
----
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
----
Run it and press `5` for a coin, `1` for start, and `9` for the service
switch -- those are the defaults in the shipped `controls.cfg`. Then press
Escape, and read what it says before you press it again.
=== What Just Happened
[source,lua]
----
local coinsPerCredit = saveGet("coinsPerCredit", 1)
local difficulty = saveGet("difficulty", 2)
local freePlay = saveGet("freePlay", false)
----
The operator's settings are read once, at the top, with a sensible value for a
machine that has never been set up. `saveGet` with a second argument hands back
that value when nothing was saved, which is how a first run is told from a
later one.
[source,lua]
----
local function insertCoin()
coins = coins + 1
while coins >= coinsPerCredit do
coins = coins - coinsPerCredit
credits = credits + 1
end
end
----
Coins in, credits out, at whatever rate the operator set. The `while` rather
than an `if` is not caution about impossible input -- it is the operator
raising the price from four coins to one while six coins are sitting in there.
[source,lua]
----
if what == SWITCH_QUIT then
if state == "confirm" then
singeQuit()
else
goBackTo = state
state = "confirm"
end
return
end
----
The quit switch reaches your game only because of the
`singeSetQuitKeyEnabled(false)` at the very bottom of the script. Pressing it
once asks; pressing it again, from the asking screen, calls `singeQuit()`
yourself. `goBackTo` remembers where the player was, so saying no puts them
back in their game rather than on the attract screen.
This block is first in the function and it `return`s, so nothing else can
swallow the switch. Every other state in the game is handled below it.
[source,lua]
----
if state == "service" then
serviceInput(what)
return
end
----
The same shape again. While the service menu is open, every switch belongs to
it -- the stick does not steer the game underneath, a coin does not land in
the middle of an adjustment. One `if`, early, with a `return`. Any screen that
sits on top of another one needs this, and a game that grows three of them and
does not have it produces bugs that are almost impossible to describe.
[source,lua]
----
saveSet("coinsPerCredit", coinsPerCredit)
...
saveFlush()
----
Set, then flush. Without the flush, the write happens at the end of the frame,
which is fast but not immediate. An operator adjusts a setting and pulls the
plug out ten seconds later, and this is the one place in a game where that
actually happens.
[source,lua]
----
elseif (singeGetTicks() // 400) % 2 == 0 then
overlayPrint(2, 4, "INSERT COIN")
end
----
The blink. `singeGetTicks()` is milliseconds since the engine started, `//`
divides and throws away the remainder, so the number changes every four
hundred milliseconds, and `% 2` makes it alternate between zero and one. No
variable, no timer, nothing to reset: the clock was already counting.
[source,lua]
----
overlayPrint(2, 10, "Singe " .. SINGE_VERSION_STRING .. " rotation " .. vldpGetRotate())
----
The operator's half of the service page. A version, a rotation, whether there
is artwork, and where the data lives. None of it is for the player, and all of
it is what you will wish you had when somebody telephones you about a machine
three hundred miles away.
[source,lua]
----
singeSetQuitKeyEnabled(false)
hasBezel = mainBezelLoaded()
----
At the bottom, outside every function, so it runs once when the script loads.
Neither is a thing to do every frame, and `mainBezelLoaded` cannot change while
the game runs.
=== Try It
. *Change the price.* Open the service menu, set coins per credit to two, and
put one coin in. Watch the credit not appear, and watch the coin counter on
the bottom line. Then quit, start it again, and see that the price stuck.
. *Play free.* Turn free play on and check that start works with no coins and
that credits do not go negative.
. *Add a page.* Put a second page of service information behind button 3 --
the fields of `singeGetSystemInfo()` are a good start, and the manual's entry
lists all of them.
. *Turn the screen.* Run it with `--rotate=90` and look at your layout. Then
make the service page readable at that rotation, which is harder than it
sounds and is exactly the work a vertical cabinet asks for.
. *Take the keyboard away.* Unplug it, or promise yourself not to touch it, and
play through every screen with a pad. Anything you cannot reach is a bug.
=== Break It on Purpose
Delete the `SWITCH_QUIT` block from `onInputPressed`, leaving
`singeSetQuitKeyEnabled(false)` where it is, and run it.
Now nothing quits. Escape does nothing, `Q` does nothing, and your game has no
way out, which is precisely what you asked for. This is not a crash and there
is no message; the engine did exactly as it was told.
Close the window with its close button. That always works, whatever a script
says, and it is the door you left yourself. In full screen, `Alt-Enter` puts
you back into a window first.
It is worth feeling this one. Turning off the quit key is the only thing in
this book that can leave you with a program you cannot get out of, and the
habit that prevents it is simple: write the way out in the same sitting you
turn the quit key off, never later.
=== What You Learned
* A cabinet has no keyboard, so every screen in the game has to work with a
stick and a button.
* `controls.cfg` maps keys, pad buttons, and stick directions onto the
`SWITCH_` names your game already uses, and your game never learns which was
pressed.
* Four files of mappings are read in turn, each overriding the last, so a
machine-wide file and a per-game file can both exist.
* Every device Singe opens is a gamepad, including an arcade encoder board, and
the Input Test tool says what is arriving.
* The engine keeps pause, quit, screenshot, and mouse grab for itself; the
rest are yours.
* Coins are counted into credits at the operator's rate, and a machine returns
to attract by itself.
* `SWITCH_SERVICE` is the operator's switch, and what is behind it is the
price, the difficulty, a way to clear the scores, and what the machine is.
* `saveFlush()` writes a setting now, because a cabinet loses power without
warning.
* `singeSetQuitKeyEnabled(false)` hands the quit switch to your game. Write the
way out at the same moment.
* A bezel is artwork with a hole in it, and `--rotate` turns the whole
presentation for a vertical monitor.
=== The End
That is thirty lessons, and you can write a game.
Not a perfect one, and not a large one yet, but the gap between here and
either of those is time and practice rather than another book. You started
with five lines that put a word on a black screen. You now know what a
variable is, what a function is for, how to draw, how to make a noise, how to
tell when two things touched, how to keep a score somewhere it survives, how
to run film as a world, how to put a model in a room with a light on it, and
how to hand the whole thing to a stranger.
What you need now is the _Singe Manual_, not this book. It is the reference to
every function, every argument, and every return value, and it is the thing you
will actually have open while you work. It ships with the engine as
`Singe/Manual.pdf`. Read the entry before you use the call; there is a note in
most of them about the mistake people make with it.
Read the scripts that come with Singe, too. `Singe/Menu.singe`,
`Singe/Tools.singe`, and `Singe/Framework.singe` are not demonstrations written
to be read -- they are the menu and the service tools you have been using all
along, doing real work with the same functions you have. Working out how
somebody else's program does something is a skill of its own, and those are a
kind place to practise it.
And there is a second book, _Forge_, for describing a game rather than writing
one. It is a different road. You do not need it, but it is there.
The next game is yours. Make the small one first, finish it, and put it in
front of somebody. Finishing is the part nobody tells you is the hard part, and
it is the only part that turns a folder full of scripts into a game that
somebody played.
Go and build it.