More work on Forge.

This commit is contained in:
Scott Duensing 2026-09-14 14:20:17 -05:00
parent 3f69551719
commit 2819449810
70 changed files with 12700 additions and 1067 deletions

View file

@ -7,6 +7,22 @@ SINGE 3.00
API Changes API Changes
----------- -----------
- spriteFlip mirrors a sprite left to right, top to bottom, or both, for
every draw of it, its frames included, so art that faces one way is
enough for a character that walks both ways.
- soundPlay takes a volume as its third argument, and
soundSetChannelVolume changes a playing channel's own volume, both under
the master from soundSetVolume: a quiet footstep and a loud shot can
share one master. The master used to be applied through the mixer's
tag gain, which overwrote the distance fade of positioned channels
whenever it changed; it is applied per track now.
- materialSetOccluder makes a material write depth and no colour: an
invisible shape that hides what walks behind it while what was drawn
behind it -- a painted room, the disc -- shows through, which is how a
3D character walks behind a pillar that is only in the picture.
- --gamedir names the directory holding the games, the .game files and - --gamedir names the directory holding the games, the .game files and
the Singe support folder, so the executable can live anywhere. It the Singe support folder, so the executable can live anywhere. It
becomes the working directory; relative names count from it. The becomes the working directory; relative names count from it. The
@ -1510,6 +1526,13 @@ Fixes
read as pixels. The menu's backdrop stops rewriting its grid colours read as pixels. The menu's backdrop stops rewriting its grid colours
every frame once the grid is lit. every frame once the grid is lit.
- navAgentNew placed an agent from the node's world position as of the
last drawn frame, so an agent made before the first frame -- or in a
game that never draws the scene, which is what a 2D room walking a
navigation mesh is -- started at the origin, off the mesh, and never
moved. The transforms are refreshed first now, as navAddNode already
did.
Net.singe verified a connection against Linux's CA list by path, so Net.singe verified a connection against Linux's CA list by path, so
without a pin every HTTPS request failed on Windows, macOS and the without a pin every HTTPS request failed on Windows, macOS and the
handhelds. The engine now extracts Mozilla's CA list as handhelds. The engine now extracts Mozilla's CA list as
@ -1521,10 +1544,79 @@ 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 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. Forge.pdf beside the Singe manual; the engine's manual keeps a pointer.
The build packs Forge.game itself, with the manual inside, and Forge's The build packs Forge.game itself, with the manual and four samples
first run copies Forge.pdf out to its data directory and says where. 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. R redoes what U undid, forty steps deep.

View file

@ -298,11 +298,22 @@ singeEmbedLua(thirdparty/bump.lua/bump.lua "")
# Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into # Forge's manual, rendered the same way. It is not embedded: the superbuild packs it into
# Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge. # Forge.game (cmake/Superbuild.cmake), which is the one thing that ships Forge.
# Its vocabulary tables are generated from the manifest in Author.singe, so the manual cannot
# drift from it; the generated file is kept in the tree for a host without a Lua interpreter.
find_program(LUA_INTERPRETER NAMES lua5.4 lua)
if(LUA_INTERPRETER)
add_custom_command(
OUTPUT ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc
COMMAND ${CMAKE_COMMAND} -E chdir ${CMAKE_SOURCE_DIR} ${LUA_INTERPRETER} util/forgeVocabulary.lua > ${CMAKE_SOURCE_DIR}/docs/ForgeVocabulary.adoc
DEPENDS assets/Forge/Author.singe util/forgeVocabulary.lua
COMMENT "Writing the Forge vocabulary"
)
endif()
add_custom_command( add_custom_command(
OUTPUT ${MANUAL_DIR}/Forge.pdf OUTPUT ${MANUAL_DIR}/Forge.pdf
COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR} COMMAND ${CMAKE_COMMAND} -E make_directory ${MANUAL_DIR}
COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf COMMAND ${ASCIIDOCTOR_PDF} -a revnumber=${PROJECT_VERSION} ${CMAKE_SOURCE_DIR}/docs/Forge.adoc -o ${MANUAL_DIR}/Forge.pdf
DEPENDS docs/Forge.adoc DEPENDS docs/Forge.adoc docs/ForgeVocabulary.adoc
COMMENT "Rendering Forge.pdf" COMMENT "Rendering Forge.pdf"
VERBATIM VERBATIM
) )

File diff suppressed because it is too large Load diff

View file

@ -23,19 +23,19 @@
-- Turns a game description into an ordinary Singe game (PLAN section 58). -- The compiler: a game description to a Singe game (FORGE.md).
-- --
-- The output is Lua a person can read, keep, and edit by hand; the description is kept beside it -- A description is data -- kinds, rooms, and rules -- and what comes out is a Lua script that
-- so the editor can open it again. Compiling rather than interpreting costs nothing at build time -- calls the runtime in Author.singe. The rules become real functions: a condition is a Lua
-- and saves walking a rule table every frame on a Pi or a handheld -- and it means there is no wall -- expression, an action a statement, and a rule with an action that takes time a coroutine. The
-- between what the tools make and what someone writes themselves. -- description travels with the game so it can be opened again; nothing is lost in either
-- direction.
-- --
-- This is written in Lua rather than in util/ as a Python script because the editor is itself a -- Written in Lua rather than as a util/ script because the editor is itself a Singe game and has to
-- Singe game: it has to be able to compile what it is editing without leaving the engine. It -- compile what it is editing without leaving the engine. It lives with Forge rather than in Singe/
-- lives with Forge rather than in Singe/ because only building needs it -- a finished game is -- because only building needs it; Author.singe, the runtime a built game loads, travels with the
-- already Lua. Author.singe, the runtime that game loads, travels with the game: a copy is -- game -- a copy is written beside it when it is built -- so a game handed to someone who has never
-- written beside it when it is built, so a game handed to someone who has never installed Forge -- installed Forge still runs.
-- still runs. The compiler loads it too, for the vocabulary it emits against.
-- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy. -- Where the runtime is taken from when a game is built. Forge carries it; a game gets a copy.
AUTHOR_RUNTIME = "Forge/Author.singe" AUTHOR_RUNTIME = "Forge/Author.singe"
@ -46,209 +46,477 @@ dofile(AUTHOR_RUNTIME)
local INDENT = "\t" local INDENT = "\t"
-- A Lua literal for a value that came out of a description. Numbers and booleans go through as -- ===== Expressions ============================================================================
-- they are; everything else is quoted, because an author typing a name must not be able to end --
-- the string and start writing code. AUTHOR.actions.lua is the one deliberate way in. -- A small, safe language where a number goes: numbers, strings, arithmetic, comparison, and, or,
local function literal(value) -- not, self.var, other.var, event.field, an entity's field by name, the game's vars by name, and a
if type(value) == "number" or type(value) == "boolean" then -- few functions. Anything else is a compile error naming the expression, and the lua action is
-- the way past it.
local FUNCTIONS = {
count = "authorCount",
random = "authorRandom",
distance = "authorDistance",
has = "authorHas",
abs = "math.abs",
min = "math.min",
max = "math.max",
floor = "math.floor",
time = "authorTime",
frame = "discGetFrame"
}
local NAMES = {
self = "self",
other = "other",
event = "event",
time = "authorTime()",
frame = "discGetFrame()"
}
local OPERATORS = {
["=="] = "==", ["~="] = "~=", ["!="] = "~=", ["<="] = "<=", [">="] = ">=", ["<"] = "<", [">"] = ">",
["+"] = "+", ["-"] = "-", ["*"] = "*", ["/"] = "/", ["%"] = "%", ["^"] = "^", [".."] = ".."
}
local function tokenize(text)
local tokens = {}
local at = 1
while at <= #text do
local c = text:sub(at, at)
if c:match("%s") then
at = at + 1
elseif c:match("[%d%.]") and text:sub(at):match("^%d") or text:sub(at):match("^%.%d") then
local number = text:sub(at):match("^%d*%.?%d+[eE][-+]?%d+") or text:sub(at):match("^%d*%.?%d+") or text:sub(at):match("^%d+")
tokens[#tokens + 1] = { kind = "number", value = number }
at = at + #number
elseif c:match("[%a_]") then
local name = text:sub(at):match("^[%a_][%w_]*")
tokens[#tokens + 1] = { kind = "name", value = name }
at = at + #name
elseif (c == '"') or (c == "'") then
-- A backslash escapes the next character, so a line can hold its own quote marks.
local value = {}
local scan = at + 1
while (scan <= #text) and (text:sub(scan, scan) ~= c) do
local ch = text:sub(scan, scan)
if ch == "\\" then
local escaped = text:sub(scan + 1, scan + 1)
value[#value + 1] = (escaped == "n") and "\n" or escaped
scan = scan + 2
else
value[#value + 1] = ch
scan = scan + 1
end
end
if scan > #text then
error("unterminated string in: " .. text)
end
tokens[#tokens + 1] = { kind = "string", value = table.concat(value) }
at = scan + 1
else
local two = text:sub(at, at + 1)
if OPERATORS[two] then
tokens[#tokens + 1] = { kind = "op", value = two }
at = at + 2
elseif OPERATORS[c] or c == "(" or c == ")" or c == "," or c == "." then
tokens[#tokens + 1] = { kind = "op", value = c }
at = at + 1
else
error("cannot read '" .. c .. "' in: " .. text)
end
end
end
return tokens
end
-- Recursive descent, lowest precedence first. Every level returns Lua source.
local function parser(text)
local tokens = tokenize(text)
local at = 1
local p = {}
local function peek(kind, value)
local token = tokens[at]
return token and (token.kind == kind) and ((value == nil) or (token.value == value))
end
local function take()
at = at + 1
return tokens[at - 1]
end
local function expect(kind, value)
if not peek(kind, value) then
error("expected " .. (value or kind) .. " in: " .. text)
end
return take()
end
function p.primary()
local token = take()
if token == nil then
error("expression ends early: " .. text)
end
if token.kind == "number" then
return token.value
elseif token.kind == "string" then
return string.format("%q", token.value)
elseif token.kind == "op" and token.value == "(" then
local inner = p.expression()
expect("op", ")")
return "(" .. inner .. ")"
elseif token.kind == "op" and token.value == "-" then
return "(-" .. p.unary() .. ")"
elseif token.kind == "name" then
local name = token.value
if name == "true" or name == "false" or name == "nil" then
return name
end
if name == "not" then
return "(not " .. p.unary() .. ")"
end
if peek("op", "(") then
local target = FUNCTIONS[name]
local args = {}
if target == nil then
error("no function called " .. name .. " in: " .. text)
end
take()
if not peek("op", ")") then
args[#args + 1] = p.expression()
while peek("op", ",") do
take()
args[#args + 1] = p.expression()
end
end
expect("op", ")")
return target .. "(" .. table.concat(args, ", ") .. ")"
end
if peek("op", ".") then
take()
local field = expect("name").value
if name == "event" then
return "event[" .. string.format("%q", field) .. "]"
elseif NAMES[name] and (name == "self" or name == "other") then
return "authorField(" .. name .. ", " .. string.format("%q", field) .. ")"
end
return "authorField(authorEntity(" .. string.format("%q", name) .. "), " .. string.format("%q", field) .. ")"
end
if NAMES[name] then
return NAMES[name]
end
return "AUTHOR_VARS[" .. string.format("%q", name) .. "]"
end
error("unexpected '" .. tostring(token.value) .. "' in: " .. text)
end
function p.unary()
return p.primary()
end
function p.power()
local left = p.unary()
while peek("op", "^") do
take()
left = "(" .. left .. " ^ " .. p.unary() .. ")"
end
return left
end
function p.product()
local left = p.power()
while peek("op", "*") or peek("op", "/") or peek("op", "%") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.power() .. ")"
end
return left
end
function p.sum()
local left = p.product()
while peek("op", "+") or peek("op", "-") or peek("op", "..") do
local op = take().value
left = "(" .. left .. " " .. op .. " " .. p.product() .. ")"
end
return left
end
function p.comparison()
local left = p.sum()
while peek("op") and OPERATORS[tokens[at].value] and tokens[at].value:match("[=<>~!]") do
local op = OPERATORS[take().value]
left = "(" .. left .. " " .. op .. " " .. p.sum() .. ")"
end
return left
end
function p.conjunction()
local left = p.comparison()
while peek("name", "and") do
take()
left = "(" .. left .. " and " .. p.comparison() .. ")"
end
return left
end
function p.expression()
local left = p.conjunction()
while peek("name", "or") do
take()
left = "(" .. left .. " or " .. p.conjunction() .. ")"
end
return left
end
local out = p.expression()
if at <= #tokens then
error("unexpected '" .. tostring(tokens[at].value) .. "' in: " .. text)
end
return out
end
-- An expression as Lua. A number or a boolean given as a value is itself.
function authorExpression(value)
if type(value) == "number" then
return tostring(value) return tostring(value)
elseif type(value) == "boolean" then
return tostring(value)
elseif value == nil then
return "nil"
end
return parser(tostring(value))
end
-- ===== Parameters =============================================================================
--
-- A parameter's value as a Lua fragment, by the type the manifest declares for it.
local function fragment(kind, value)
if value == nil then
return nil
end
if kind == "number" or kind == "expression" then
return authorExpression(value)
elseif kind == "boolean" then
return value and "true" or "false"
elseif kind == "entity" then
if value == "self" or value == "other" then
return value
end
return "authorEntity(" .. string.format("%q", value) .. ")"
elseif kind == "scancode" then
return "SCANCODE." .. value .. ".value"
elseif kind == "switch" then
return value
elseif kind == "lua" then
return value
end end
return string.format("%q", tostring(value)) return string.format("%q", tostring(value))
end end
-- The table a description carries for an entity's look or behaviour, written back out as source. local function fragments(entry, item)
local function tableSource(t) local out = {}
local parts = {}
local keys = {}
for key in pairs(t) do for key, kind in pairs(entry.params or {}) do
keys[#keys + 1] = key out[key] = fragment(kind, item[key])
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
parts[#parts + 1] = string.format("%s = %s", key, literal(t[key]))
end end
return "{ " .. table.concat(parts, ", ") .. " }" return out
end end
-- One condition or action, through the manifest entry that owns it. An unknown name is a fault in -- ===== Rules ==================================================================================
-- the description rather than in the generated game, so it is reported here and not emitted.
local function emitOne(set, item, what) local function emitOne(set, item, what)
local entry = set[item[1]] local name = item[1]
local entry = set[name]
if entry == nil then if entry == nil then
debugPrint(string.format("Author: no %s called '%s'", what, tostring(item[1]))) debugPrint("Author: no " .. what .. " called '" .. tostring(name) .. "'; skipped")
return nil return nil
end end
return entry.emit(item) return entry.emit(fragments(entry, item))
end end
local function emitConditions(rule) -- A condition list is an AND; an entry named any is an OR of what it holds, and an entry named
local tests = {} -- all an AND, nesting as deep as the author likes.
local function emitConditions(list)
local parts = {}
for _, item in ipairs(rule.when or {}) do for _, item in ipairs(list or {}) do
local test = emitOne(AUTHOR.conditions, item, "condition") local test = emitOne(AUTHOR.conditions, item, "condition")
if test ~= nil then if test ~= nil then
tests[#tests + 1] = "(" .. test .. ")" parts[#parts + 1] = test
end end
end end
if #tests == 0 then if list and list.any then
local inner = {}
for _, item in ipairs(list.any) do
if item[1] then
local test = emitOne(AUTHOR.conditions, item, "condition")
if test then
inner[#inner + 1] = test
end
else
inner[#inner + 1] = emitConditions(item)
end
end
if #inner > 0 then
parts[#parts + 1] = "(" .. table.concat(inner, " or ") .. ")"
end
end
if list and list.all then
parts[#parts + 1] = emitConditions(list.all)
end
if #parts == 0 then
return "true" return "true"
end end
-- Every condition on a rule has to hold. A rule needing "either" is two rules, which reads return table.concat(parts, " and ")
-- better in a list than a nested group does. end
return table.concat(tests, " and ")
local function ruleWaits(rule)
for _, item in ipairs(rule.act or {}) do
local entry = AUTHOR.actions[item[1]]
if entry and entry.waits then
return true
end
end
return false
end end
local function emitRule(out, rule, index) local function emitRule(out, rule, index)
out[#out + 1] = string.format("%s-- %s", INDENT, rule.note or ("rule " .. index)) local event = AUTHOR.events[rule.on or "frame"]
out[#out + 1] = string.format("%sif %s then", INDENT, emitConditions(rule)) local filter = {}
if event == nil then
debugPrint("Author: no event called '" .. tostring(rule.on) .. "'; rule " .. index .. " skipped")
return
end
for key, kind in pairs(event.filter) do
if rule[key] ~= nil then
filter[#filter + 1] = key .. " = " .. fragment(kind, rule[key])
end
end
out[#out + 1] = INDENT .. "{"
out[#out + 1] = INDENT .. INDENT .. string.format("note = %q, on = %q,", rule.note or ("rule " .. index), rule.on or "frame")
out[#out + 1] = INDENT .. INDENT .. "filter = { " .. table.concat(filter, ", ") .. " },"
out[#out + 1] = INDENT .. INDENT .. string.format("each = %s, room = %s, waits = %s, controls = %s, interrupt = %s, guarded = %s,",
rule.each and string.format("%q", rule.each) or "nil",
rule.room and string.format("%q", rule.room) or "nil",
tostring(ruleWaits(rule)),
tostring(rule.controls ~= false),
tostring(rule.interrupt == true),
tostring((rule.when ~= nil) and ((#rule.when > 0) or (rule.when.any ~= nil) or (rule.when.all ~= nil))))
out[#out + 1] = INDENT .. INDENT .. "run = function(self, other, event)"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "if " .. emitConditions(rule.when) .. " then"
for _, item in ipairs(rule.act or {}) do for _, item in ipairs(rule.act or {}) do
local statement = emitOne(AUTHOR.actions, item, "action") local statement = emitOne(AUTHOR.actions, item, "action")
if statement ~= nil then if statement ~= nil then
out[#out + 1] = INDENT .. INDENT .. statement out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. statement
end end
end end
out[#out + 1] = INDENT .. "end" out[#out + 1] = INDENT .. INDENT .. INDENT .. INDENT .. "return true"
out[#out + 1] = "" out[#out + 1] = INDENT .. INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. INDENT .. INDENT .. "return false"
out[#out + 1] = INDENT .. INDENT .. "end"
out[#out + 1] = INDENT .. "},"
end end
-- The whole game, as source. Returns a string; the caller decides where it goes. -- A dialogue: its start and its nodes; a choice's when is an expression and its act a list of
-- -- actions, compiled as a rule's are, with self and other absent.
-- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing local function emitDialogue(out, name, dialogue)
-- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is out[#out + 1] = INDENT .. string.format("[%q] = { start = %q, nodes = {", name, dialogue.start or "start")
-- the engine's, as it is for every game ever written for Singe. for nodeName, node in pairs(dialogue.nodes or {}) do
function authorCompile(game) out[#out + 1] = INDENT .. INDENT .. string.format("[%q] = { name = %q, who = %s, text = %s, seconds = %s, next = %s, choices = {",
local out = {} nodeName, nodeName,
node.who and string.format("%q", node.who) or "nil",
node.text and string.format("%q", node.text) or "nil",
node.seconds and tostring(node.seconds) or "nil",
node.next and string.format("%q", node.next) or "nil")
for _, choice in ipairs(node.choices or {}) do
local parts = { string.format("text = %q", choice.text or "..."), "next = " .. (choice.next and string.format("%q", choice.next) or "nil"), "once = " .. tostring(choice.once == true) }
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description") if choice.when then
out[#out + 1] = "-- " .. (game.title or "Untitled") parts[#parts + 1] = "when = function() local self, other, event = nil, nil, {} return " .. emitConditions(type(choice.when) == "table" and choice.when or { { "test", expr = choice.when } }) .. " end"
out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then"
out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other."
out[#out + 1] = ""
out[#out + 1] = 'dofile("Singe/Framework.singe")'
-- The game finds its own directory rather than trusting DIR. Framework sets DIR from the
-- script the *engine was launched with*, which is this file only when the game is played
-- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would
-- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the
-- chunk actually running, either way.
out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")'
out[#out + 1] = 'dofile(here .. "Author.singe")'
out[#out + 1] = ""
out[#out + 1] = "authorBegin({"
for _, layer in ipairs(game.layers or {}) do
out[#out + 1] = INDENT .. tableSource(layer) .. ","
end end
out[#out + 1] = "})" if choice.act then
out[#out + 1] = "" local body = {}
for _, entity in ipairs(game.entities or {}) do for _, item in ipairs(choice.act) do
local parts = {} local statement = emitOne(AUTHOR.actions, item, "action")
parts[#parts + 1] = string.format("id = %q", entity.id) if statement then
parts[#parts + 1] = string.format("x = %s, y = %s", entity.x or 0, entity.y or 0) body[#body + 1] = statement
parts[#parts + 1] = "look = " .. tableSource(entity.look)
if entity.behaviours ~= nil and #entity.behaviours > 0 then
local made = {}
for _, b in ipairs(entity.behaviours) do
made[#made + 1] = tableSource(b)
end end
parts[#parts + 1] = "behaviours = { " .. table.concat(made, ", ") .. " }"
end end
out[#out + 1] = "authorMake({ " .. table.concat(parts, ", ") .. " })" parts[#parts + 1] = "run = function() local self, other, event = nil, nil, {} " .. table.concat(body, " ") .. " end"
end end
out[#out + 1] = "" out[#out + 1] = INDENT .. INDENT .. INDENT .. "{ " .. table.concat(parts, ", ") .. " },"
out[#out + 1] = "-- The rules, in the order they were written. Every one is tested every frame."
out[#out + 1] = "local function rules()"
for index, rule in ipairs(game.rules or {}) do
emitRule(out, rule, index)
end end
out[#out + 1] = "end" out[#out + 1] = INDENT .. INDENT .. "} },"
out[#out + 1] = ""
out[#out + 1] = "onKeyPressed = authorKeyDown"
out[#out + 1] = "onKeyReleased = authorKeyUp"
out[#out + 1] = "onInputPressed = authorSwitchDown"
out[#out + 1] = "onInputReleased = authorSwitchUp"
out[#out + 1] = ""
out[#out + 1] = "function onOverlayUpdate()"
out[#out + 1] = INDENT .. "authorFrame(rules)"
out[#out + 1] = ""
out[#out + 1] = INDENT .. "return OVERLAY_UPDATED"
out[#out + 1] = "end"
out[#out + 1] = ""
return table.concat(out, "\n")
end
-- Compiles a description file to a game beside it. Returns the path written.
-- Copies a file through the VFS, so the source may be inside a database and the destination is an
-- ordinary file on disc. Used to put the runtime beside a released game.
function authorCopy(fromPath, toPath)
local input = io.open(fromPath, "rb")
local output
if input == nil then
debugPrint("Author: cannot read " .. tostring(fromPath))
return false
end end
output = io.open(toPath, "wb") out[#out + 1] = INDENT .. "} },"
if output == nil then
input:close()
debugPrint("Author: cannot write " .. tostring(toPath))
return false
end
output:write(input:read("a"))
input:close()
output:close()
return true
end
-- The directory part of a path, with its separator, or "" for a bare name.
local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "")
end
-- Compiles a description and puts the runtime beside the result, because that is what the result
-- loads. Building without copying it would produce a game that cannot start.
function authorBuild(descriptionFile, outputFile)
local chunk = assert(loadfile(descriptionFile))
local game = chunk()
local file
game.source = descriptionFile
file = assert(io.open(outputFile, "w"))
file:write(authorCompile(game))
file:close()
authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe")
return outputFile
end end
-- ===== Reading and writing a description ====================================================== -- ===== Reading and writing a description ======================================================
-- --
-- The editor produces descriptions, so the format has to survive a round trip: load, change -- The editor produces descriptions, so the format has to survive a round trip: load, change
-- nothing, save, and the result must compile to the same game. scene54 asserts exactly that. -- nothing, save, and the result must compile to the same game.
local function literal(value)
if type(value) == "string" then
return string.format("%q", value)
end
return tostring(value)
end
local function indentOf(depth) local function indentOf(depth)
@ -256,10 +524,10 @@ local function indentOf(depth)
end end
-- Whether a table holds only leaves, in which case it is written on one line. A rule reads far -- Whether a table holds only leaves, in which case it is written on one line. A condition reads
-- better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed. -- far better as { "keyHeld", key = "LEFT" } than as six lines, and it is what an author typed.
local function isLeaf(t) local function isLeaf(t)
for key, value in pairs(t) do for _, value in pairs(t) do
if type(value) == "table" then if type(value) == "table" then
return false return false
end end
@ -277,9 +545,6 @@ local function valueSource(value, depth)
local parts = {} local parts = {}
local keys = {} local keys = {}
local count = 0 local count = 0
local sep
local open
local close
if type(value) ~= "table" then if type(value) ~= "table" then
return literal(value) return literal(value)
@ -296,7 +561,12 @@ local function valueSource(value, depth)
end end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do for _, key in ipairs(keys) do
parts[#parts + 1] = string.format("%s = %s", key, valueSource(value[key], depth + 1)) local name = tostring(key)
if not name:match("^[%a_][%w_]*$") then
name = "[" .. literal(key) .. "]"
end
parts[#parts + 1] = string.format("%s = %s", name, valueSource(value[key], depth + 1))
end end
if #parts == 0 then if #parts == 0 then
return "{}" return "{}"
@ -304,11 +574,8 @@ local function valueSource(value, depth)
if isLeaf(value) then if isLeaf(value) then
return "{ " .. table.concat(parts, ", ") .. " }" return "{ " .. table.concat(parts, ", ") .. " }"
end end
sep = ",\n" .. indentOf(depth + 1)
open = "{\n" .. indentOf(depth + 1)
close = "\n" .. indentOf(depth) .. "}"
return open .. table.concat(parts, sep) .. close return "{\n" .. indentOf(depth + 1) .. table.concat(parts, ",\n" .. indentOf(depth + 1)) .. "\n" .. indentOf(depth) .. "}"
end end
@ -341,3 +608,215 @@ function authorSave(game, path)
return path return path
end end
function authorCopy(fromPath, toPath)
local input = io.open(fromPath, "rb")
local output
if input == nil then
return false
end
output = io.open(toPath, "wb")
if output == nil then
input:close()
return false
end
output:write(input:read("a"))
input:close()
output:close()
return true
end
-- ===== Checking ===============================================================================
--
-- What the compiler can tell an author before the game runs: a kind that is not declared, a
-- condition the manifest does not have, an expression that does not parse. Returns a list of
-- messages, empty when the description is sound.
function authorCheck(game)
local problems = {}
local kinds = game.kinds or {}
local function problem(text)
problems[#problems + 1] = text
end
local function checkItems(set, list, what, where)
for _, item in ipairs(list or {}) do
local entry = set[item[1]]
if entry == nil then
problem(where .. ": no " .. what .. " called '" .. tostring(item[1]) .. "'")
else
for key, kind in pairs(entry.params) do
if (kind == "number" or kind == "expression") and (type(item[key]) == "string") then
local ok, err = pcall(parser, item[key])
if not ok then
problem(where .. ": " .. tostring(err))
end
elseif (kind == "kind") and (item[key] ~= nil) and (kinds[item[key]] == nil) then
problem(where .. ": no kind called '" .. tostring(item[key]) .. "'")
end
end
end
end
end
-- A field the manifest does not declare is a misspelling nine times in ten, and it is
-- otherwise silently ignored.
local function checkFields(t, declared, skip, where)
for key in pairs(t or {}) do
if (type(key) == "string") and (declared[key] == nil) and not skip[key] then
problem(where .. ": '" .. key .. "' is not something it takes")
end
end
end
for name, kind in pairs(kinds) do
local look = kind.look and AUTHOR.looks[kind.look.kind] or nil
if kind.look and (look == nil) then
problem("kind " .. name .. ": no look called '" .. tostring(kind.look.kind) .. "'")
elseif look then
checkFields(kind.look, look.params, { kind = true }, "kind " .. name .. "'s look")
end
for _, b in ipairs(kind.behaviours or {}) do
local behaviour = AUTHOR.behaviours[b.kind]
if behaviour == nil then
problem("kind " .. name .. ": no behaviour called '" .. tostring(b.kind) .. "'")
else
checkFields(b, behaviour.params, { kind = true }, "kind " .. name .. "'s " .. b.kind)
end
end
checkFields(kind, { look = true, vars = true, behaviours = true }, {}, "kind " .. name)
end
if #(game.rooms or {}) == 0 then
problem("the game has no rooms")
end
for _, room in ipairs(game.rooms or {}) do
for _, entry in ipairs(room.entities or {}) do
if kinds[entry.kind] == nil then
problem("room " .. tostring(room.name) .. ": no kind called '" .. tostring(entry.kind) .. "'")
end
end
end
for index, rule in ipairs(game.rules or {}) do
local where = "rule " .. index .. " (" .. tostring(rule.note or "") .. ")"
if rule.on and (AUTHOR.events[rule.on] == nil) then
problem(where .. ": no event called '" .. tostring(rule.on) .. "'")
elseif AUTHOR.events[rule.on or "frame"] then
checkFields(rule, AUTHOR.events[rule.on or "frame"].filter, { note = true, on = true, each = true, room = true, when = true, act = true, controls = true, interrupt = true }, where)
end
if rule.each and (kinds[rule.each] == nil) then
problem(where .. ": no kind called '" .. tostring(rule.each) .. "'")
end
checkItems(AUTHOR.conditions, rule.when, "condition", where)
checkItems(AUTHOR.conditions, rule.when and rule.when.any, "condition", where)
checkItems(AUTHOR.actions, rule.act, "action", where)
end
return problems
end
-- ===== The whole game =========================================================================
-- The whole game, as source. Returns a string; the caller decides where it goes.
--
-- Every built game loads the runtime sitting beside it. There is no other copy to load: nothing
-- of Forge ships inside Singe, so Author.singe travels with the game that needs it. Framework is
-- the engine's, as it is for every game ever written for Singe.
function authorCompile(game)
local out = {}
local data = {}
for key, value in pairs(game) do
if (key ~= "rules") and (key ~= "source") and (key ~= "dialogues") then
data[key] = value
end
end
out[#out + 1] = "-- Generated by Forge/AuthorCompile.singe from " .. (game.source or "a game description")
out[#out + 1] = "-- " .. (game.title or "Untitled")
out[#out + 1] = "--"
out[#out + 1] = "-- This is an ordinary Singe game and can be edited by hand. Doing so and then"
out[#out + 1] = "-- recompiling the description will overwrite it, so keep one or the other."
out[#out + 1] = ""
out[#out + 1] = 'dofile("Singe/Framework.singe")'
-- The game finds its own directory rather than trusting DIR. Framework sets DIR from the
-- script the *engine was launched with*, which is this file only when the game is played
-- directly; a game reached by dofile -- a test, a launcher, a menu that previews it -- would
-- otherwise look for its runtime beside the caller and not find it. debug.getinfo names the
-- chunk actually running, either way.
out[#out + 1] = 'local here = ((debug.getinfo(1, "S").source:gsub("^@", "")):match("^(.*[/\\\\])") or "")'
out[#out + 1] = 'dofile(here .. "Author.singe")'
out[#out + 1] = ""
out[#out + 1] = "-- The description, as the runtime wants it: everything but the rules, which follow as code."
out[#out + 1] = "authorBegin(" .. valueSource(data, 0) .. ")"
out[#out + 1] = ""
out[#out + 1] = "-- The rules. Each is a trigger, its filter, and a function that tests the conditions and"
out[#out + 1] = "-- does the actions; one with an action that waits runs as a coroutine."
out[#out + 1] = "authorRules({"
for index, rule in ipairs(game.rules or {}) do
emitRule(out, rule, index)
end
out[#out + 1] = "})"
out[#out + 1] = ""
if game.dialogues then
out[#out + 1] = "-- The dialogues: nodes of a line and choices, each choice's condition and actions as code."
out[#out + 1] = "authorDialogues({"
for name, dialogue in pairs(game.dialogues) do
emitDialogue(out, name, dialogue)
end
out[#out + 1] = "})"
out[#out + 1] = ""
end
out[#out + 1] = "onKeyPressed = authorKeyDown"
out[#out + 1] = "onKeyReleased = authorKeyUp"
out[#out + 1] = "onInputPressed = authorSwitchDown"
out[#out + 1] = "onInputReleased = authorSwitchUp"
out[#out + 1] = "onMouseMoved = authorMouseMoved"
out[#out + 1] = "onCollision = authorCollision"
out[#out + 1] = "onTrigger = authorTrigger"
out[#out + 1] = "onNavArrived = authorNavArrived"
out[#out + 1] = "onMidiMessage = authorMidi"
out[#out + 1] = ""
out[#out + 1] = "function onOverlayUpdate()"
out[#out + 1] = INDENT .. "authorFrame()"
out[#out + 1] = ""
out[#out + 1] = INDENT .. "return OVERLAY_UPDATED"
out[#out + 1] = "end"
out[#out + 1] = ""
return table.concat(out, "\n")
end
local function directoryOf(path)
return (string.match(path, "^(.*[/\\])") or "")
end
-- Compiles a description file to a game beside it, with the runtime beside that, because that is
-- what the result loads. Returns the path written.
function authorBuild(descriptionFile, outputFile)
local chunk = assert(loadfile(descriptionFile))
local game = chunk()
local file
game.source = descriptionFile
for _, problem in ipairs(authorCheck(game)) do
debugPrint("Author: " .. problem)
end
file = assert(io.open(outputFile, "w"))
file:write(authorCompile(game))
file:close()
authorCopy(AUTHOR_RUNTIME, directoryOf(outputFile) .. "Author.singe")
return outputFile
end

View file

@ -20,6 +20,9 @@
.row { margin: 2px 8px; padding: 4px 6px; background-color: #242433ff; } .row { margin: 2px 8px; padding: 4px 6px; background-color: #242433ff; }
.row:hover { background-color: #32324aff; } .row:hover { background-color: #32324aff; }
.row.selected { background-color: #c03c96ff; color: #ffffff; } .row.selected { background-color: #c03c96ff; color: #ffffff; }
.thumb { display: inline-block; vertical-align: middle; margin-right: 6px; width: 24px; text-align: center; color: #ffcf4a; }
.thumb.dim { color: #6a6a80; }
.thumb.swatch { height: 16px; width: 16px; margin-left: 4px; margin-right: 10px; }
.part { margin: 1px 8px 1px 18px; padding: 2px 5px; background-color: #1b1b28ff; color: #aeb4c8; font-size: 12px; } .part { margin: 1px 8px 1px 18px; padding: 2px 5px; background-color: #1b1b28ff; color: #aeb4c8; font-size: 12px; }
.part.here { background-color: #4a3a6aff; color: #ffffff; } .part.here { background-color: #4a3a6aff; color: #ffffff; }
#detail { margin: 14px 8px; padding: 6px; background-color: #10101aff; color: #b9bfd4; } #detail { margin: 14px 8px; padding: 6px; background-color: #10101aff; color: #b9bfd4; }

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ for doc in "$src" "$forge"; do
exit 1 exit 1
fi fi
done done
for tool in asciidoctor asciidoctor-pdf; do for tool in asciidoctor asciidoctor-pdf lua5.4; do
if ! command -v "$tool" > /dev/null; then if ! command -v "$tool" > /dev/null; then
echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2 echo "error: $tool not found (gem install asciidoctor asciidoctor-pdf rouge)" >&2
exit 1 exit 1
@ -25,6 +25,8 @@ version=$(sed -n 's/^project(singe2 VERSION \([0-9.]*\).*/\1/p' "$here/CMakeList
out=$here/.builddir out=$here/.builddir
mkdir -p "$out" mkdir -p "$out"
# The Forge vocabulary tables come from the manifest itself, so the manual cannot drift from it.
lua5.4 "$here/util/forgeVocabulary.lua" > "$here/docs/ForgeVocabulary.adoc"
asciidoctor -a revnumber="$version" "$src" -o "$out/Manual.html" 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"

View file

@ -471,9 +471,12 @@ if((KANGAROO_OS STREQUAL "linux") AND (CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux"))
COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage} COMMAND ${CMAKE_COMMAND} -E rm -rf ${forgePack} ${forgeStage}
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets/Forge ${forgeStage}/Forge COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/assets/Forge ${forgeStage}/Forge
COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf COMMAND ${CMAKE_COMMAND} -E copy ${forgeManual} ${forgeStage}/Forge/Forge.pdf
# The samples that need no art beyond what the engine ships, so the chooser has something to
# open on a fresh install. They come from the test set, which is the one copy of them.
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game ${forgeStage}/Forge/
# Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory. # Run it somewhere harmless: the engine writes Singe/ and data/ into the working directory.
COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack} COMMAND ${CMAKE_COMMAND} -E chdir ${forgeStage} ${CMAKE_SOURCE_DIR}/.builddir/${singeBinaryName} --pack ${forgeStage}/Forge ${forgePack}
DEPENDS singe ${forgeSources} ${forgeManual} DEPENDS singe ${forgeSources} ${forgeManual} ${CMAKE_SOURCE_DIR}/testScripts/author/platformer.game ${CMAKE_SOURCE_DIR}/testScripts/author/breakout.game ${CMAKE_SOURCE_DIR}/testScripts/author/qte.game ${CMAKE_SOURCE_DIR}/testScripts/author/fmv.game
COMMENT "Packing Forge into .builddir/Forge.game" COMMENT "Packing Forge into .builddir/Forge.game"
VERBATIM VERBATIM
) )

View file

@ -18,44 +18,48 @@ Forge is Singe's authoring tool: a way to make a game by describing it --
placing things, attaching behaviours, writing rules -- and having ordinary placing things, attaching behaviours, writing rules -- and having ordinary
Singe Lua written for you. It is itself a Singe game and is distributed on Singe Lua written for you. It is itself a Singe game and is distributed on
its own, beside the engine rather than inside it. This document covers the its own, beside the engine rather than inside it. This document covers the
description format, the vocabulary, the compiler and the editor. The engine description format, the vocabulary, the compiler, and the editor. The engine
calls the generated code makes (`playerNew`, `collidePointRect`, calls the generated code makes (`playerNew`, `collidePointRect`,
`onKeyPressed`, `scriptPush` and the rest) are documented in the Singe Manual. `onKeyPressed`, `scriptPush`, and the rest) are documented in the Singe Manual.
== Describing a Game Instead of Writing One == Describing a Game Instead of Writing One
A game can be written as a *description* -- a table of layers, entities and A game can be written as a *description* -- a table of kinds, rooms, and rules
rules -- and compiled into an ordinary Singe game. -- and compiled into an ordinary Singe game. `Forge/AuthorCompile.singe` does
`Forge/AuthorCompile.singe` does the compiling and `Author.singe` is the runtime the compiling and `Author.singe` is the runtime the result calls. **No part of
the result calls. **No part of Forge ships with Singe** -- not the editor, not Forge ships with Singe** -- not the editor, not the compiler, not the runtime --
the compiler, not the runtime -- so a game built with it carries its own copy of so a game built with it carries its own copy of that runtime and stands entirely
that runtime and stands entirely on its own. Nothing is interpreted at run time: the rules on its own. Nothing is interpreted at run time: the rules become real Lua
become real Lua `if` statements, so a description costs nothing per frame on a functions, so a description costs nothing per frame on a Raspberry Pi, and the
Raspberry Pi, and the game it produces can be opened, read and edited by hand game it produces can be opened, read, and edited by hand like any other.
like any other.
There is no notion of genre anywhere in it. A game declares which of the 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 three 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), `overlay` (flat drawing over everything) and `disc` drawn into the overlay), `scene3d` (the 3D scene, with a sun, a sky, and fog;
(the video the game is played over). A game lists the ones it wants. positions are world units), `overlay` (flat drawing), `disc` (the video the
game is played over), `hud` (an RmlUi document bound to the game's vars), and
`music`. A game lists the ones it wants.
*Entities* are things on a layer. Each has a position, a `look` (`box`, *Kinds* are what things are: a `look` (`box`, `sprite`, `text`, or `none` in
`sprite` or `text`) and any number of behaviours. Every entity is a node, 2D; `model`, `mesh`, `light`, `billboard`, or `text3d` in the scene), any
whether or not the 3D scene is drawing, which is what lets a 2D game built this number of `behaviours`, and `vars` -- the state every instance starts with.
way run on a machine with no GPU. Everything placed or spawned is an instance of a kind.
*Behaviours* are bundles over engine calls that already exist: `platformer` is *Rooms* are where things are. A room lists its `entities` -- a kind, an id, a
the character controller (`playerNew` and friends), `solid` is a position, and any vars that differ -- and its `tracks`. A room keeps its state
static body, `drift` moves at a constant velocity. Attaching one is a line in when it is left unless it says `reset = true`; vars declared on the game live
the description rather than code. across rooms.
*Rules* are conditions and actions. Every rule is tested every frame, in the *Rules* are a trigger, conditions, and actions. `on` names the event -- every
order written, and all of a rule's conditions must hold for its actions to run. frame, a collision between two kinds, a shot landing, a death, a timer, a disc
frame passing -- and `each` scopes the rule to every instance of a kind, with
`self` bound. All of a rule's conditions must hold for its actions to run; an
`any` group inside them is an OR.
.A description, in full .A description, in full
[source,lua] [source,lua]
@ -63,16 +67,21 @@ order written, and all of a rule's conditions must hold for its actions to run.
return { return {
title = "One rule", title = "One rule",
layers = { { kind = "world2d", gravity = 1500 } }, layers = { { kind = "world2d", gravity = 1500 } },
entities = { vars = { score = 0 },
{ id = "ground", x = 360, y = 440, kinds = {
look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 },
behaviours = { { kind = "solid" } } }, behaviours = { { kind = "solid" } } },
{ id = "hero", x = 120, y = 380, hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } } behaviours = { { kind = "platformer", speed = 210, jump = 620 } } }
}, },
rooms = {
{ name = "start",
entities = { { kind = "ground", id = "ground", x = 360, y = 440 },
{ kind = "hero", id = "hero", x = 120, y = 380 } } }
},
rules = { rules = {
{ note = "Run right", { note = "Run right",
on = "frame",
when = { { "keyHeld", key = "RIGHT" } }, when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } } act = { { "run", entity = "hero", direction = 1 } } }
} }
@ -87,30 +96,331 @@ dofile("Forge/AuthorCompile.singe")
dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe")) dofile(authorBuild("mygame.game", singeGetDataPath() .. "mygame.singe"))
---- ----
`testScripts/author/platformer.game` and `testScripts/author/qte.game` are `testScripts/author/` holds the worked examples -- a platformer, a QTE over
worked examples, and `testScripts/scene52.singe` and `scene53.singe` compile video, a shoot-em-up, a light gun game over video, a rail shooter, a 3D
and play them. platformer, an adventure in 2D and in 3D, a racing game, a tower defence,
branching video, a first-person shooter, breakout, a maze, third-person
action, a space shooter, a rhythm game, and bowling, with two more for the
sprite, tile, MIDI, water, and soft-body pieces -- and
`testScripts/scene52.singe` onward compile and play them.
=== The vocabulary, and adding to it === Instances, vars, and states
Conditions and actions are not built into the compiler. Each is an entry in A kind's `vars` are what each instance starts with: `vars = { health = 3,
the `AUTHOR` table declaring its parameters and the Lua it emits, so a new kind ammo = 6 }`. An entity in a room may override them (`vars = { health = 5 }`),
of game is a set of entries rather than a new release. The conditions today and rules read and change them: `self.health` in an expression, `setVar` and
are `keyHeld`, `switchHeld`, `timeBetween`, `discBetween`, `onGround`, `addVar` as actions. `state` is a var every instance has, `"idle"` to begin
`touching`, `below`, `flagSet` and `once`; the actions are `run`, `jump`, with; `setState` changes it and `inState` tests it.
`moveTo`, `setText`, `show`, `addScore`, `setFlag`, `discTo` and `lua`.
`once` deserves a word. Rules run every frame, so anything that should happen Vars declared on the game -- `vars = { score = 0, lives = 3 }` -- belong to
a single time -- a door opening, a score awarded -- needs it: no instance and are read by name: `score`, `lives`. `setVar` and `addVar`
with no entity change them, and `addScore` is `addVar` on `score`.
Instances are made when a room is entered, by a `spawner` or `shooter`
behaviour, or by the `spawn` action, and go with `destroy`, with a `health`
that reaches zero, or with a `projectile` that leaves the picture. An
instance made at run time has an id of its own (`raider#7`); rules reach it as
`self` or `other`, or through `each`.
=== Events
A rule's `on` says what triggers it:
[cols="1,3"]
|===
| `frame` | every frame, the default
| `pressed`, `released` | a key or a switch, once per press; `key` or `switch` narrows it
| `collision` | an instance of kind `a` began touching one of kind `b`; `self` is `a`, `other` is `b`
| `hit`, `miss` | a gun's shot landed on `self` (`other` is the gun), or landed on nothing; `event.offscreen` says whether a miss left the picture
| `death`, `spawn` | `self`'s health reached zero; `self` was just made
| `timer` | a timer on `self` went off; `event.name` says which
| `frameReached` | the disc passed `frame`
| `enter`, `leave` | an instance of kind `b` entered or left a trigger of kind `a`
| `arrived` | a seeking instance reached its target
| `stopped`, `railEnd` | a rail camera reached a stop (`event.name`), or the end of its track
| `animationDone` | a clip that does not loop ended on `self`
| `roomStart`, `roomEnd`, `gameOver` | the game's own moments
|===
The event's parameters are written on the rule itself (`on = "collision",
a = "shot", b = "raider"`). `each = "raider"` runs a frame rule once per live
raider, and narrows an event rule to that kind; `room = "hall"` keeps a rule
to one room.
=== Expressions
Anywhere a number goes, an expression may go instead, as a string: numbers,
strings, `+ - * / % ^ ..`, comparisons, `and`, `or`, `not`, and the names
below. Nothing else is allowed -- an expression is checked when the game is
built -- and the `lua` action is the way past it.
[cols="1,2"]
|===
| `self.health`, `other.x`, `event.frame` | the instance the rule is about, the other one, the event's own fields
| `hero.x` | a placed entity's field or var, by id
| `score`, `lives` | the game's vars
| `time`, `frame` | seconds since the game began; the disc frame
| `count("raider")` | live instances of a kind
| `random(a, b)`, `distance(a, b)`, `has("key")` | a number in a range; between two instances; whether the inventory holds an item
| `abs`, `min`, `max`, `floor` | arithmetic
|===
A `test` condition takes one: `{ "test", expr = "count('raider') == 0" }`.
=== Sequences
Some actions take time. `say` shows a line and waits for it; `wait` waits.
A rule with one of them runs as a coroutine, its actions in order, each waiting
its turn -- a cut-scene is the same shape as `addScore`. While it runs the
same rule does not start again for the same instance unless the rule says
`interrupt = true`, and `controls = false` on the rule takes the keys away for
its duration.
=== Tracks
A track is keys by disc frame (`key = "frame"`) or by time (`key = "time"`),
and a room may hold any number. A track of `boxes` is a hitbox that moves
with the picture:
[source,lua] [source,lua]
---- ----
{ when = { { "touching", entity = "hero", other = "prize" }, tracks = {
{ "once", tag = "prize" } }, { name = "bandit", key = "frame",
act = { { "addScore", amount = 100 }, boxes = { { at = 120, x = 100, y = 200, w = 60, h = 100 },
{ "show", entity = "prize", visible = false } } } { at = 220, x = 400, y = 200, w = 60, h = 100 } } }
}
---- ----
Between keys the box is interpolated; outside them there is none. A kind with
`{ kind = "hitbox", track = "bandit" }` is hit where the track says, which is
how a light gun game over video is 2,750 rectangles no longer.
A track of `points` is a rail for a camera: positions by time, each looking
at a point (`look = { x, y, z }`) or at an entity by id, and any point may be
a `stop` that holds the camera there until a `pathNext` action sends it on --
which is what House of the Dead does at every doorway.
[source,lua]
----
{ name = "rail", key = "time",
points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.2, -10 } },
{ at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" } } }
----
=== Games in the scene
A game with a `scene3d` layer places its kinds in world units and draws them
through the engine's renderer. A `model` look is a glTF file with a scale and
a starting clip; `mesh` is a box, sphere, cylinder, or plane with a colour;
`light` and `billboard` are what they say. The 2D looks still draw on the
overlay over the scene, which is how a score readout works in both.
The behaviours that live there: `character` is the engine's controller
moving by the `keys` vars relative to the camera; `camera` draws the scene
from the instance -- `fixed`, `follow` (an offset from a target), `orbit`
and `first` (the mouse looks), or `rail`; `seek` walks toward a target and
raises `arrived`; `animator` plays a clip per state and raises
`animationDone`; `target` gives a `gun` something to hit, with `zones` on
named bones so a shot says which part it landed on (`hitPart`); `health`
with `ragdoll` lets a model fall limp on death; `solid`, `body`, and
`trigger` are the physics. A gun in the scene casts a ray through the
pointer, so the same `hit` and `miss` rules serve a light gun over video and
a rail shooter.
`testScripts/author/railshooter.game` is House of the Dead in a table: a
rail with two stops, zombies that spawn there, walk to the camera, and bite
on a timer unless they are killed, headshots through a zone on the head bone,
and the rail moving on when the stop is clear.
=== Adventures: rooms, walking, hotspots, verbs, and talk
A point-and-click adventure -- Sierra's or LucasArts' -- is rooms that
remember themselves, a character that walks where the player clicks, hotspots
a verb is used on, an inventory, dialogue, and cut-scenes. All of it is the
same nouns.
*Walking.* A 2D room declares `walk = { { x, y, x, y, ... }, ... }`, its
walkable floor as polygons in overlay coordinates; a 3D room declares
`navFrom = { "floor", "ledge" }`, the entities whose meshes are its floor.
Either is baked into a navigation mesh when the room is entered, and a kind
with a `walker` behaviour walks it: `walkTo` sends it to a point (and waits),
`walkToPointer` to where the player clicked, `walkToHotspot` to a hotspot's
walk point; it raises `arrived`, and its state is `walk` or `idle` as it
goes. A painted room may say `depthSort = true`, so what is lower on the
picture is drawn over what is higher, and `scaleBy = { { y = 300, scale = 0.6
}, { y = 460, scale = 1 } }`, so a character further up the picture is drawn
smaller; looks with `anchor = "feet"` stand on their position rather than
being centred on it, which is what a walking character wants.
*3D over a painting.* A room in the scene may be a picture on a flat `mesh`
(its `texture`, `unlit`) with 3D characters in front of it. Whatever stands
in the picture -- a pillar, a doorway, a table -- that a character has to walk
behind is a `mesh` look with `occluder = true`: an invisible box that hides
what is behind it while the picture shows through it. The editor's 3D
viewport keeps occluders visible so they can be placed; the game hides them.
`testScripts/author/painted.game` and scene 79 show a character crossing a
painted room behind such a pillar.
*Hotspots.* A kind with `{ kind = "hotspot", name = "door", walkX = 600,
walkY = 330 }` is something a verb can be used on: its look's box, or a
`polygon` of x,y pairs, in 2D; a box the size of its look, met by the ray
through the pointer, in 3D. The name is what the sentence line shows.
*Verbs.* A game with `verbs = { "walk", "look", "take", "use", "open", "talk"
}` builds a sentence line from the current verb, the hotspot under the
pointer, and the item in hand (`sentence`, `hover`, `verb`, and `item` are
game vars, so a text look or a HUD element can show them). A click on a
hotspot raises `verb` with `verb`, `target`, and `item`, and **the most
specific rule wins**: `verb = "use", item = "key", target = "door"` beats
`verb = "use", target = "door"` beats `verb = "use"` beats a rule that names
nothing, which is the "That doesn't work" every adventure needs. A rule with
conditions ranks above one without among the same parameters, so "open the
door while the guard objects" and "open the door" can share their parameters
and differ in a `test`. `setVerb`, `nextVerb`, and `useItem` choose; the
first verb in the list is what a click on the floor means, and the `pressed`
event carries the pointer's `x` and `y` for a `walkToPointer` rule.
*The parser.* A `parser` layer takes typing at a prompt: `words = { look =
{ "look", "examine", "l" }, door = { "door", "gate" } }` says what the game
knows, and a typed line raises `said` with its `verb`, `noun`, and `second`
as the words table knows them -- or `event.unknown`, the first word it did
not -- with the same most-specific-wins rule. A game may offer both a verb
line and a parser; the rules do not care which was used.
*Inventory.* `give` and `take` keep a list in the `inventory` var;
`has("key")` tests it in an expression and `has` as a condition.
*Dialogue.* The game's `dialogues` are trees:
[source,lua]
----
dialogues = {
guard = {
start = "hello",
nodes = {
hello = { who = "Guard", text = "Nobody passes.",
choices = { { text = "Why not?", next = "why" }, { text = "Fine." } } },
why = { who = "Guard", text = "The door is locked.",
choices = { { text = "I have a key.", when = 'has("key")',
act = { { "setVar", name = "allowed", value = true } }, next = "ok" },
{ text = "I see." } } },
ok = { who = "Guard", text = "Then go ahead." }
}
}
}
----
`talk` runs one and waits for it: each node's line is said, its choices
(those whose `when` holds, and not those already chosen once with `once`) are
offered and picked with the number keys or a click, the choice's `act` runs,
and `next` names the node after. With a HUD, the choices go in the element
called `choices`.
*Cut-scenes and rooms.* A rule with `controls = false` takes the keys away
while it runs; `fade` (out, then back with `out = false`) and `goTo` change
the picture and the room, and `goTo` with `at = "arrival"` puts the carried
entity where the entity of that id stands in the new room, so the same rule
serves a painted room and a modelled one. Sequences survive a room change.
*Saving.* `saveGame` keeps the whole game -- the room, every visited room's
instances and their vars, the game's vars, the inventory -- in a numbered
slot, and `loadGame` brings it back. A save taken while a sequence runs
records the world as it stands; the sequence does not resume. `die` is the
Sierra death: a line, a fade, and the game over from the start.
`testScripts/author/adventure2d.game` is the two-room adventure in a painted
room, and `adventure3d.game` is the same game in a modelled room from a fixed
camera -- it takes the 2D description's rules, dialogue, verbs, and parser as
they are and supplies only kinds and rooms. Scenes 64 and 65 play them.
=== Vehicles, crowds, turrets, and physics toys
`vehicle` puts a kind on the engine's vehicle physics -- a car, a motorcycle,
a tank, or a boat -- with wheels hung at the corners its look's size gives,
driven by the `keys` vars (`dy` throttle, up is forward; `dx` steering), and
`self.speed` says how fast. `racer` drives a vehicle round a track of
`points` on its own, steering toward the next and easing off in bends, which
is every opponent in a racing game. `camera` in `follow` mode with a `lag`
is what sits behind the car.
`walker` with `follow = "hero"` keeps an instance after an entity (or the
camera) across the navigation mesh, asking again every so often -- a crowd of
enemies in a shooter, a companion. `patrol` walks a list of points in turn,
looping or stopping, and raises `patrolEnd` at the last: a creep's lane, a
guard's round. `turret` fires at the nearest instance of a kind within range,
no faster than its rate, spawning a projectile aimed at it (or doing the
damage itself), and `spawnAtPointer` makes an instance where the player
clicked -- on the floor the ray meets, in 3D -- which is how turrets are
placed.
`body` is a thing physics moves (mass, bounce, friction); `thrust` pushes a
body along its nose by `dy` and turns it by `dx` (a hovercraft, a ship);
`joint` hangs a body from another's, or from the world, by a hinge, a ball,
or a slider through a point along an axis; and `push` shoves a body, or a
ragdoll's bone.
`testScripts/author/racing.game` (a car, a rival on a track, checkpoint
triggers, a hinged bar, a hovercraft) and `towerdefence.game` (creeps in
waves on a lane, turrets placed by clicks, gold and lives) are the worked
examples; scenes 66 and 67 play them.
=== Arcade plumbing: branching video, lives, credits, the board
A `branches` track is 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
`success` and on `fail`; a kind with `{ kind = "branching", track = "moves" }`
watches it and raises `branchOpen` (with `event.move`), `branchTaken`, and
`branchMissed`. Lives, credits, and continues are vars and rules -- `credit`
on `SWITCH_COIN1`, a `restart` on `SWITCH_START1` when `credits > 0` -- and a
`bezel` layer shows `score`, `lives`, and `credits` (and a second player's
`score2` and `lives2` with `twin`) on the arcade panel around the picture.
`gameOver` ends the rules, keeps the best score across runs, and shows the
results card; `submitScore` sends the score to the master service's board
for the game, queued until it can go. `testScripts/author/fmv.game` is the
worked example and scene 68 plays it: one window answered, three missed, the
lives spent, the score sent, a coin, and a continue.
=== The long tail: sprite sheets, tiles, MIDI, water, soft bodies
`distance(a, b)` takes instances or ids (`distance(self, "hero")`) and
measures through the scene in 3D. A rule with `each` on an event that has no
instance of its own -- a key, a room, the disc -- runs once per instance of
the kind, as a frame rule does, which is how "a swing of the sword hits every
enemy in reach" is one rule. A `solid` with `moving = true` is a body that
follows wherever the instance is moved: a paddle, a lift.
A `sprite` look turns with the var `angle` (degrees, clockwise), which an
entity's `rz` starts and which rules, a `spin` behaviour, or a `projectile`
with `aim` change -- a turning image is a whole one, not a sheet. `spin`
turns a 3D instance about Y the same way. A `particles` look is a steady
stream from the instance -- a fire, smoke, a thruster, rain -- with a colour,
a rate, a size, a speed, and a life, and the `emit` action is a burst from any
instance.
A `sprite` look with `frames` is a sheet of that many columns, and a `frames`
behaviour steps it: `states = "idle=1-1, walk=2-5"` gives each state its run
of frames, played at `fps`, so a walker's walk cycle follows the state the
`walker` (or a rule) sets. Art need only face one way: the look's `faces`
says which (`right` unless said), and while the var `facing` -- which the
`walker` and the `mover` set as they go -- is the other way, the image is
mirrored. A sheet drawn facing both ways names the other way's frames for
the state with `Left` or `Right` on the end (`walkLeft=6-9`), and those are
used instead of the mirror; `faces = "none"` never mirrors, for a top-down
sprite that turns rather than flips. A sheet entity with an `rz` (or a var
`angle`) draws its frame turned. A `grid` look draws a map of tiles from a sheet --
`columns` tiles wide, each `tile` pixels square -- from rows of tile numbers
(`map = "1 2 1; 2 1 2"`, `0` for nothing), which is a maze or a level in a
string. A game that opened a MIDI port hears `midi` events with
`event.pitch` and `event.velocity` for every note struck.
In the scene, a mesh look with `water` is filled with water: its top is the
surface, and a `body` with `buoyancy` floats in it; a `vehicle` of type
`boat` with `thrust` is pushed by its propeller while that sits under the
surface; and `soft` makes a mesh look a cloth or an inflated body.
`testScripts/author/tail.game` and `pool.game` are the worked examples;
scenes 70 and 71 play them.
=== The way out === The way out
The `lua` action takes a line of Lua and emits it as it stands. It is there on The `lua` action takes a line of Lua and emits it as it stands. It is there on
@ -119,24 +429,42 @@ to Lua and the rest of the game is unaffected. A description is a convenience,
not a cage, and the compiled output is a normal game you can stop describing not a cage, and the compiled output is a normal game you can stop describing
and start editing whenever it suits you. and start editing whenever it suits you.
=== The editor == The Vocabulary
Conditions, actions, behaviours, looks, layers, and events are not built into
the compiler. Each is an entry in the `AUTHOR` table in `Author.singe`
declaring its parameters and the Lua it emits, so a new kind of game is a set
of entries rather than a new release. The tables below are generated from
that manifest.
The parameter types: `number` (a number or an expression), `expression`,
`string`, `boolean`, `entity` (`"self"`, `"other"`, or an entity's id),
`kind`, `scancode` (a key name from `SCANCODE`), `switch` (a `SWITCH_*` name),
`file`, `state`, `track`, `room`, and `lua`. A behaviour's parameters are
plain values in the description; key and switch names among them are resolved
when the game runs.
include::ForgeVocabulary.adoc[]
== The Editor
`Forge/Forge.singe` edits a description, and it is itself a Singe game. It has `Forge/Forge.singe` edits a description, and it is itself a Singe game. It has
its own directory beside the games, appears in the menu like one, and packs to its own directory beside the games, appears in the menu like one, and packs to
`Forge.game` with `--pack`; the build does that itself (the `forge` target), `Forge.game`; the build does that itself (the `forge` target), with this manual
with this manual inside. inside. Nothing in it is a preview: the canvas is the same overlay at the same
Nothing in it is a preview: the canvas is the same overlay at the same
coordinates the game will be played in, so what is placed is what is seen. coordinates the game will be played in, so what is placed is what is seen.
Started from the menu it opens on a chooser: the descriptions in its data Started from the menu it opens on a chooser: the descriptions in its data
directory, any dropped into the `Forge` directory itself (those are opened as a directory, the samples inside `Forge.game` and any dropped into the `Forge`
copy, since inside a `.game` they are read only), and *New game*, which writes directory itself (those are opened as a copy, since inside a `.game` they are
read only), and *New game*, which writes
a starter -- ground, a hero that runs and jumps, a score readout -- and opens a starter -- ground, a hero that runs and jumps, a score readout -- and opens
it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into it. The first run copies this manual, `Forge.pdf`, out of `Forge.game` into
that data directory, and the chooser says where it is. `Esc` in the editor closes the description (twice, when it has unsaved that data directory, and the chooser says where it is. `Esc` in the editor
changes) and `Esc` on the chooser leaves Forge. `P` plays: the description is closes the description (twice, when it has unsaved changes) and `Esc` on the
saved, compiled beside a copy of the runtime, and handed to the engine with chooser leaves Forge. `P` plays: the description is saved, compiled beside a
`scriptPush`; when the game ends Forge comes back on the same file. copy of the runtime, and handed to the engine with `scriptPush`; when the game
ends Forge comes back on the same file.
A script can drive the editor instead, which is how the test scenes do it: A script can drive the editor instead, which is how the test scenes do it:
@ -155,81 +483,137 @@ function onOverlayUpdate()
end end
---- ----
The entity list and the details are an RmlUi document; the canvas beside them is The panel is an RmlUi document; the canvas beside it is drawn into the overlay
drawn into the overlay and picked with `collidePointRect`. and picked with `collidePointRect`. The two compose because the engine offers
The two compose because the engine offers a button to the GUI first and passes a button to the GUI first and passes on what it did not use, while pointer
on what it did not use, while pointer motion is never consumed at all -- so motion is never consumed at all -- so point `forgePress`, `forgeDrag`, and
point `forgePress`, `forgeDrag` and `forgeRelease` at the mouse `forgeRelease` at the mouse callbacks and clicks on the panel will not reach
callbacks and clicks on the panels will not reach the canvas. the canvas. The panel slides: drag the tab on its outer edge, or use the
arrow keys, so an entity that lives underneath it is never permanently out of
reach.
`forgeSave()` writes the description back, `forgeBuild(path)` === The four panels
compiles what is on screen (and puts the runtime beside it, so the result
plays), and `forgeMove(index, x, y)` moves an entity without a pointer, which
is how `testScripts/scene54.singe` drives it.
=== Editing the entities `Tab` cycles the panel through the room's *entities*, the *kinds*, the
*rules*, the room's *tracks*, and the *dialogues*. Whatever is selected is edited the same way:
`Enter` walks its fields and each is typed; `Enter` again moves to the next,
`Esc` puts the value back. A typed number comes back a number, because `100`
and `"100"` compile to different source.
`A` adds a box in the middle of the canvas, `D` duplicates the selected entity *Entities*: `A` adds one of the selected kind in the middle of the canvas,
a little to one side, and `Delete` removes it. `Enter` walks the entity's `D` duplicates the selected one, `Delete` removes it, and dragging moves it.
fields the same way it walks a rule's parameters: `id`, `x`, `y`, the look's Its fields are `id`, `kind`, `x`, `y`, `z`, `scale` -- and in a 3D room
`kind`, then whatever that look takes, then `behaviours` -- typed as a list of `rx`, `ry`, `rz`, its turn about each axis in degrees -- and each var its kind
kinds, `platformer, solid` -- and then each behaviour's own parameters as declares, overridable here. A scale scales the look, the body, and how big
`platformer.speed` and so on. The fields come from the `AUTHOR` manifest, so a the instance counts as for touching. Renaming an entity renames it in every rule that talks about
new look or behaviour is editable the moment it is declared. Renaming an it. `PgUp` and `PgDn` move between rooms; `N` adds a room. With nothing
entity renames it in every rule that talks about it. A `sprite` look is drawn selected, `Enter` edits the room itself: its `name`, `reset`, `depthSort`,
with its image once the file name is right, and as a box until then. `walk` areas (polygons of x,y pairs, separated by semicolons, drawn on the
canvas), `navFrom`, and `scaleBy` (as `300:0.6, 460:1`).
From a script, `forgeEntityAdd(x, y)`, `forgeEntityDuplicate()`, In every picker, typing narrows the list to the names that contain what was
`forgeEntityDelete()` and `forgeEntitySet(entity, field, value)` do the same. typed, and `Backspace` widens it again. A field that names a file -- a look's
image or model, a sound, a HUD document -- opens a picker of the files under
the game's directory instead of a prompt; `Esc` on it leaves the field to be
typed.
=== Editing the rules *Kinds*: `A` adds one, `D` duplicates, `Delete` removes one no room still
places. Its fields are `name`, `look` and what that look takes (`look.w`,
`look.file`, ...), `vars` typed as `health=3, ammo=6`, `behaviours` typed as a
list of names (`keys, mover, shooter`), and each behaviour's own parameters as
`mover.speed` and so on. The fields come from the manifest, so a new look or
behaviour is editable the moment it is declared; renaming a kind renames it in
every entity and rule.
The panel shows either the entities or the event sheet; `Tab` swaps them. In In a 2D room, `V` draws a polygon corner by corner on the canvas: with the
the rules, the selected rule opens in place and its conditions and actions are selected entity's kind carrying a `hotspot` behaviour it becomes that hotspot's
listed under it, because a rule only means anything whole -- a `when` without a outline, otherwise it is added to the room's walk areas. `V` or `Enter` closes
`then` tells you nothing. it, `Esc` drops it.
Point `onKeyPressed` at `forgeKey` and the whole editor *Rules*: the selected rule opens in place with its conditions and actions
works without a pointer, which is how the bundled menu has always been driven under it, because a rule only means anything whole. `N` adds a rule, `E` picks
and what a cabinet wants: what it is on (the event's own parameters then appear as fields on the rule),
`C` adds a condition, `O` a condition in the rule's `any` group, `T` an action
-- each picked from the vocabulary with its help beside it -- and `[` and `]`
move the rule up and down the sheet, since rules run in order. `Delete`
removes the selected condition or action, or the rule itself.
*Tracks*: the room's tracks and their keys. The canvas shows the frame of the
disc layer's video under the cursor and every track's box there; `,` and `.`
step the cursor a frame, `[` and `]` leap ten. `N` adds a track, `K` a key at
the cursor -- taking the box interpolated there, or the nearest key's -- and a
key is moved by dragging its box on the canvas or by typing. `Delete` removes
the selected key, or the track. In a 3D room a new track is a rail of points,
drawn through the scene with its stops marked, and `K` puts a point where the
editor camera stands, looking where it looks: stand where the player should
and press it.
*Dialogues*: an outline of every dialogue, the selected one's nodes under it,
the selected node's choices under that, and a choice's actions under that.
`A` adds at the level selected -- a dialogue, a node in it, a choice in the
node -- `T` adds an action to the selected choice from the vocabulary, and
`Delete` removes what is selected. A dialogue's `name` and `start`, a node's
`name`, `who`, `text`, `seconds`, and `next`, and a choice's `text`, `when`,
`next`, and `once` are typed like everything else.
`P` plays from the room on screen: the built game begins in it, with the
other rooms following in order.
On the 2D canvas the selected entity carries the yellow turn handle alone:
an arm the way its `rz` points, past its box, dragged round the entity to
turn it, and a sheet entity shows its first frame turned the same way. In
the kinds panel each kind's picture stands beside its name -- the sprite
itself (one frame of a sheet), `Aa` for a text, or a swatch of the box's
colour -- so a kind can be told from its neighbours without opening it.
`testScripts/scene82.singe` drives both.
=== The 3D viewport
A room in a game with a `scene3d` layer is shown as the scene itself, built
from the kinds' looks exactly as the runtime builds them, under an editor
camera that orbits whatever is selected: `J` and `L` orbit, `Y` and `H` tilt,
`I` and `K` close in and back off. Nothing steps -- no behaviours, no physics
-- so it is the description that is on screen, not a running game. An entity
is picked by where it projects and dragged across the floor it stands on, and
the selected one carries a gizmo: three arms along the world axes, red for
X, green for Y, blue for Z, each ending in a handle that drags the entity
along that axis alone -- the pointer's travel along the arm, as a fraction of
the arm, is the distance moved -- and a fourth, yellow, the way the entity
faces, whose handle dragged round the entity turns it about Y. `testScripts/scene63.singe` and `scene80`
drive it.
=== Keys
[cols="1,4"] [cols="1,4"]
|=== |===
| `Tab` | entities or rules | `Tab` | entities, kinds, rules, tracks
| Up, Down | move through the list, and through the parts of the open rule | Up, Down | move through the list, and through the parts of the open rule or track
| Left, Right | slide the panel | Left, Right | slide the panel
| `PgUp`, `PgDn` | the previous or next room
| `Enter` | type a value for whatever is selected; again for its next value | `Enter` | type a value for whatever is selected; again for its next value
| `Esc` | put the value back | `Esc` | put the value back; otherwise close the description
| `A`, `D`, `Delete` | add, duplicate, delete the selected entity | `A`, `D`, `Delete` | add, duplicate, delete an entity or a kind
| `N` | a new rule | `N` | a new rule, a new track, or (in the entities) a new room
| `C`, `T` | add a condition, an action -- picked from the vocabulary, with its help beside it | `E` | pick what the rule is on
| `[`, `]` | move the rule up or down the sheet | `C`, `O`, `T` | add a condition, an any-of condition, an action
| `Delete` | delete the selected rule, or the selected condition or action | `[`, `]` | move the rule up or down; in the tracks, leap the cursor
| `,`, `.` | step the cursor
| `V` | draw a walk area, or the selected hotspot's outline, corner by corner
| `K` | a key at the cursor; in a 3D room, a rail point where the camera stands
| `J`, `L`, `Y`, `H`, `I`, `K` | in a 3D room: orbit, tilt, and zoom the editor camera
| `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history | `U`, `R` | undo and redo, forty steps deep; a drag or a typed form is one step, and a change by hand ends the redo history
| `S`, `B`, `P` | save, build, play | `S`, `B`, `P` | save, build, play
|=== |===
With the rule itself selected rather than one of its parts, `Enter` edits its From a script the same work is `forgeEntityAdd`, `forgeEntityDuplicate`,
note. `forgeEntityDelete`, `forgeEntitySet`, `forgeKindAdd`, `forgeKindSet`,
`forgeRuleNew`, `forgeRuleOn`, `forgeRuleAdd("when"|"any"|"act", name)`,
`forgePartSet`, `forgePartDelete`, `forgeRuleMove`, `forgeTrackNew`,
`forgeKeyAdd`, `forgeCursorTo`, `forgeUndo`, and `forgeRedo`.
A typed number comes back a number rather than a string, because `100` and A description survives the round trip: loading one, saving it, and loading it
`"100"` compile to different source and a description that changed shape when a again compiles to the same game, byte for byte. The editor depends on that
value was retyped would stop round-tripping. and `testScripts/scene54.singe` asserts it.
From a script, `forgeRuleNew(note)`, `forgeRuleAdd("when"|"act",
name)`, `forgePartSet(key, value)`, `forgePartDelete()`, `forgeRuleMove(by)`,
`forgeUndo()` and `forgeRedo()` do the same work. Adding a condition selects it, so its
parameters can be set at once.
The vocabulary comes from the `AUTHOR` manifest, so the rule editor never needs
changing when a condition or an action is added: it offers whatever is
declared. A new parameter gets a sensible starting value for its type, so a
rule compiles the moment it is made rather than only once every field is
filled in.
A description survives the round trip: loading one, saving it and loading it
again compiles to the same game, byte for byte. The editor depends on that and
the test asserts it.
=== Releasing a game === Releasing a game
@ -244,10 +628,8 @@ Every built game finds its own directory to load that runtime from, using
engine was *launched* with, so a game reached by `dofile` -- a test, a engine was *launched* with, so a game reached by `dofile` -- a test, a
launcher, a preview -- would otherwise look beside the caller. launcher, a preview -- would otherwise look beside the caller.
The panel slides. Drag the tab on its outer edge and it moves across the `forgeBuild(path)` compiles what is on screen (and puts the runtime beside it,
window, so an entity that lives underneath it -- a score readout at `12, 12` so the result plays); what `authorCheck` has to say about the description --
does -- is never permanently out of reach. `forgePanelTo(x)` moves it from a kind that is not declared, an expression that does not parse, a field a
a script, and `forgeOverPanel(x)` says whether a point is currently look, a behaviour, or an event does not take (a misspelling, nine times in
covered. ten) -- goes to the console and the panel.
[#migrating]

687
docs/ForgeVocabulary.adoc Normal file
View file

@ -0,0 +1,687 @@
// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit.
=== Layers
[cols="1,3,2"]
|===
| Name | What it does | Parameters
| `bezel`
| The arcade score panel around the picture, showing the vars score (and score2), lives (and lives2), and credits as they change.
| `twin` (boolean)
| `disc`
| The video the game is played over. The file is what the editor scrubs; games.dat names it when the game runs.
| `file` (file), `start` (number)
| `hud`
| An RmlUi document over everything. bind maps element ids to game vars, refreshed as they change.
| `bind` (table), `document` (file)
| `music`
| A track that plays from the start, looping.
| `file` (file), `volume` (number)
| `overlay`
| Flat drawing over everything: sprites, scores, prompts.
| --
| `parser`
| A text parser, the Sierra way: a prompt the player types at, and words the game knows. What is typed raises the said event.
| `ignore` (table), `prompt` (string), `words` (table)
| `scene3d`
| The 3D scene: physics in three axes, a sun, a sky, fog. Positions are world units.
| `ambient` (table), `exposure` (number), `fog` (table), `fov` (number), `gravity` (number), `sky` (file), `sun` (table)
| `world2d`
| A flat world with gravity: physics in the XY plane, drawn into the overlay.
| `gravity` (number)
|===
=== Looks
[cols="1,3,2"]
|===
| Name | What it does | Parameters
| `billboard`
| A sprite in the scene, always facing the camera: a tree, a puff, a health bar.
| `d` (number), `file` (file), `h` (number), `height` (number), `w` (number)
| `box`
| A filled rectangle, centred on the instance -- or standing on it, with anchor feet.
| `anchor` (string), `b` (number), `g` (number), `h` (number), `r` (number), `w` (number)
| `grid`
| A map of tiles from a sheet, its top left at the instance: columns is the sheet's width in tiles, tile the tile's size, map the rows of tile numbers (1 is the first; 0 is empty), rows separated by semicolons.
| `columns` (number), `file` (file), `map` (string), `tile` (number)
| `light`
| A point or spot light, or a sun, on the instance.
| `b` (number), `g` (number), `intensity` (number), `r` (number), `range` (number), `shadow` (boolean), `type` (string)
| `mesh`
| A box, sphere, cylinder, or plane with a colour or a texture. Floors, walls, crates, targets. An occluder is not seen but hides what is behind it: a pillar that is only in the painting.
| `b` (number), `d` (number), `g` (number), `h` (number), `metallic` (number), `occluder` (boolean), `r` (number), `roughness` (number), `shape` (string), `texture` (file), `unlit` (boolean), `w` (number)
| `model`
| A glTF model under the instance, scaled and turned, playing a clip. w, h, d say how big it counts as.
| `clip` (string), `d` (number), `file` (file), `h` (number), `rx` (number), `ry` (number), `rz` (number), `scale` (number), `w` (number)
| `none`
| Nothing drawn: a trigger, a spawner, a hitbox over video, a camera.
| `d` (number), `h` (number), `w` (number)
| `particles`
| A steady stream of particles from the instance: a fire, smoke, a thruster, rain. Colour, size, speed, and life as an emitter takes them.
| `b` (number), `g` (number), `gravity` (number), `life` (number), `r` (number), `rate` (number), `size` (number), `speed` (number), `spread` (number)
| `sprite`
| An image, centred on the instance -- or standing on it, with anchor feet. frames splits a sheet into that many columns; the var frame picks one. The var angle turns it, starting from the entity's rz. faces says which way the art looks (right unless said, or none): while the var facing is the other way the image is mirrored, so art drawn one way walks both.
| `anchor` (string), `faces` (string), `file` (file), `frames` (number)
| `text`
| A line of text, its top left at the instance. The var text is what it says.
| `b` (number), `g` (number), `r` (number), `text` (string)
| `text3d`
| A line of text standing in the scene. The var text is what it says.
| `height` (number), `text` (string)
|===
=== Behaviours
[cols="1,3,2"]
|===
| Name | What it does | Parameters
| `animator`
| A clip per state: idle, walk, attack, hit, dead, or any state a rule sets. A clip that does not loop raises animationDone when it ends.
| `attack` (string), `dead` (string), `fade` (number), `hit` (string), `idle` (string), `loop` (string), `walk` (string)
| `body`
| A thing physics moves: a crate, a ball, debris. Mass, bounce, friction, and buoyancy as Jolt takes them.
| `bounce` (number), `buoyancy` (number), `friction` (number), `mass` (number), `shape` (string)
| `branching`
| Dragon's Lair: a track of branches, each a disc frame window, the move it wants, and where the disc goes on success and on failure. Raises branchTaken and branchMissed.
| `track` (track)
| `camera`
| The scene is drawn from this instance. fixed looks at a point; follow keeps an offset from a target; first sits on a target's eyes and the mouse looks; orbit circles a target; rail rides a track of points with stops.
| `eye` (number), `lag` (number), `lookX` (number), `lookY` (number), `lookZ` (number), `mode` (string), `target` (string), `track` (track), `x` (number), `y` (number), `z` (number)
| `character`
| Walks, falls, and jumps in the scene: the engine's character controller. Moves by the vars dx and dy, relative to the camera.
| `height` (number), `jump` (number), `radius` (number), `speed` (number)
| `drift`
| Moves steadily, for a cloud, a platform, or a target.
| `vx` (number), `vy` (number)
| `frames`
| Steps a sprite look's frame: a range of frames per state, as "idle=1-1, walk=2-5", at a rate a second. A sheet drawn facing both ways names the other way's frames stateLeft or stateRight (walkLeft), used while the var facing is that way instead of mirroring the look.
| `fps` (number), `states` (string)
| `gun`
| The pointer as a gun for one player: a press of the trigger raises hit on what is under it, or miss. Ammo counts down; a shot off the screen reloads. With aim centre it fires from the middle of the picture -- a first-person gun.
| `aim` (string), `ammo` (number), `player` (number), `reload` (string), `trigger` (switch)
| `health`
| The var health; the damage action lowers it, and at zero the instance raises death and is destroyed unless keep is set. With ragdoll, a model falls limp instead and goes after linger seconds.
| `keep` (boolean), `linger` (number), `max` (number), `ragdoll` (boolean)
| `hitbox`
| The instance's hit shape comes from a track of boxes keyed by disc frame or time; between keys it is interpolated, outside them there is none.
| `track` (track)
| `hotspot`
| Something a verb can be used on: a name for the sentence line, a place to walk to first, and a polygon (x,y pairs) when its look's box will not do.
| `name` (string), `polygon` (table), `walkX` (number), `walkY` (number), `walkZ` (number)
| `joint`
| Joins this body to another's -- or to the world, with no other -- by a hinge, a ball, or a slider through a world point along an axis.
| `ax` (number), `ay` (number), `az` (number), `dx` (number), `dy` (number), `dz` (number), `other` (string), `type` (string)
| `keys`
| Reads a player's keys into the vars dx and dy (-1 to 1) and fire, so rules and movers read intent rather than hardware.
| `down` (scancode), `fire` (scancode), `left` (scancode), `player` (number), `right` (scancode), `up` (scancode)
| `mover`
| Moves by the vars dx and dy at a speed, kept on the screen when clamp is set. Pair it with keys.
| `clamp` (boolean), `speed` (number)
| `patrol`
| Walks a list of points in turn -- a creep's lane, a guard's round -- looping or stopping, and raises patrolEnd at the last.
| `loop` (boolean), `points` (table)
| `platformer`
| Runs, falls, and jumps: the engine's character controller in 2D. Rules say which way with run and jump.
| `jump` (number), `speed` (number)
| `projectile`
| Flies at a velocity and is dropped past the edge (or far away, in 3D) or after life seconds. With aim, a sprite turns to point the way it flies.
| `aim` (boolean), `life` (number), `vx` (number), `vy` (number), `vz` (number)
| `racer`
| Drives a vehicle round a track of points on its own: steering toward the next, throttling by how straight the road is.
| `reach` (number), `throttle` (number), `track` (track)
| `seek`
| Moves straight toward a target -- an entity's id, or "camera" -- and raises arrived within stopAt of it.
| `fly` (boolean), `speed` (number), `stopAt` (number), `target` (string)
| `shooter`
| Spawns a kind from an offset while the var fire is set (or always, with auto), no faster than the rate.
| `auto` (boolean), `offsetX` (number), `offsetY` (number), `rate` (number), `spawns` (kind)
| `soft`
| A mesh look made soft: a cloth, or a body inflated to a pressure.
| `mass` (number), `pressure` (number), `stiffness` (number), `type` (string)
| `solid`
| Immovable ground or a wall, in a world2d or a scene3d room. With moving, a body that follows wherever the instance is moved -- a paddle, a lift.
| `moving` (boolean)
| `sound`
| A clip per event: spawn, hit, death, or any event the instance sees. Values are file names. volume is the clips' own, 0 to 100, under the game's.
| `death` (file), `hit` (file), `pressed` (file), `spawn` (file), `volume` (number)
| `spawner`
| Makes instances of a kind every so many seconds at its own position or at one of its points, up to max alive at once and total in all.
| `every` (number), `max` (number), `pick` (string), `points` (table), `spawns` (kind), `total` (number)
| `spin`
| Turns steadily: the var angle in 2D (a sprite turns with it), the node about Y in 3D, in degrees a second.
| `rate` (number)
| `target`
| Something a gun can hit. In 2D its look's box; in 3D a body the size of its look, plus zones -- boxes on named bones, so a shot says which part it hit.
| `zones` (table)
| `thrust`
| A body pushed along its nose by the var dy and turned by dx: a hovercraft, a ship, a plane without lift.
| `force` (number), `turn` (number)
| `timer`
| Raises the timer event with its name after some seconds, or every so many. start false waits for timerStart.
| `after` (number), `every` (number), `name` (string), `start` (boolean)
| `trigger`
| A volume that reports what enters and leaves it, as the enter and leave events. A checkpoint, a doorway, a prize.
| --
| `turret`
| Fires at the nearest instance of a kind within range, no faster than the rate: spawns a projectile aimed at it, or, with no projectile, does the damage itself.
| `damage` (number), `range` (number), `rate` (number), `spawns` (kind), `speed` (number), `targets` (kind)
| `vehicle`
| A car, motorcycle, tank, or boat on the engine's vehicle physics, driven by the vars dy (throttle, up is forward) and dx (steering) from keys. Wheels hang at the four corners the sizes say; a boat needs none.
| `mass` (number), `radius` (number), `suspension` (number), `thrust` (number), `torque` (number), `type` (string), `wheelX` (number), `wheelZ` (number), `width` (number)
| `walker`
| Walks the room's walk areas (2D) or its floors (3D) on the navigation mesh: walkTo sends it, a click on the floor can, follow keeps it after an entity (or the camera), and it raises arrived. Its state is walk or idle as it goes.
| `every` (number), `follow` (string), `radius` (number), `speed` (number), `stopAt` (number)
| `water`
| Fills a mesh look with water: its top is the surface, and bodies inside float or sink by their buoyancy.
| `density` (number), `drag` (number)
|===
=== Events
[cols="1,3,2"]
|===
| Name | What it does | Parameters
| `animationDone`
| A clip that does not loop ended on self; event.state says which state it was for.
| `kind` (kind), `state` (state)
| `arrived`
| A seeking instance reached its target.
| `kind` (kind)
| `branchMissed`
| The window closed without the move and the disc went to the fail frame.
| --
| `branchOpen`
| A branch's window opened; event.move says what it wants.
| --
| `branchTaken`
| The move was made in time and the disc went to the success frame.
| --
| `collision`
| An instance of kind a began touching one of kind b (self is a, other is b).
| `a` (kind), `b` (kind)
| `death`
| self's health reached zero.
| `kind` (kind)
| `enter`
| An instance of kind b entered a trigger of kind a (self is the trigger, other what entered).
| `a` (kind), `b` (kind)
| `frame`
| Every frame.
| --
| `frameReached`
| The disc passed a frame.
| `frame` (number)
| `gameOver`
| The game ended.
| --
| `hit`
| A gun shot landed on self; other is the gun.
| `kind` (kind), `player` (number)
| `leave`
| An instance of kind b left a trigger of kind a.
| `a` (kind), `b` (kind)
| `midi`
| A MIDI note came in on the port the game opened; event.pitch and event.velocity say which and how hard. (pitch, since note is what a rule's comment is called.)
| `pitch` (number)
| `miss`
| A gun shot hit nothing; self is the gun. event.offscreen says whether it left the screen.
| `player` (number)
| `patrolEnd`
| A patrolling instance reached the last of its points.
| `kind` (kind)
| `pressed`
| A key or a switch went down.
| `key` (scancode), `player` (number), `switch` (switch)
| `railEnd`
| A rail camera reached the end of its track.
| --
| `released`
| A key or a switch came up.
| `key` (scancode), `player` (number), `switch` (switch)
| `roomEnd`
| A room is about to be left.
| `room` (room)
| `roomStart`
| A room has been entered.
| `room` (room)
| `said`
| A line was typed at the parser: its verb, noun, and second noun as the words table knows them, or unknown for a word it does not. The most specific rule wins.
| `noun` (string), `second` (string), `verb` (string)
| `spawn`
| self has just been made.
| `kind` (kind)
| `stopped`
| A rail camera reached a stop; event.name says which. pathNext moves it on.
| `name` (string)
| `timer`
| A timer on self went off; event.name says which.
| `kind` (kind), `name` (string)
| `verb`
| A verb was used on a hotspot (self), with an item or not: "use key on door". The most specific rule wins.
| `item` (string), `target` (string), `verb` (string)
|===
=== Conditions
[cols="1,3,2"]
|===
| Name | What it does | Parameters
| `below`
| An instance has fallen past a line -- a pit, or the bottom of the screen.
| `entity` (entity), `y` (number)
| `chance`
| True with a probability, 0 to 1.
| `p` (number)
| `discBetween`
| The disc is between two frames -- the window a QTE is answered in.
| `from` (number), `to` (number)
| `every`
| True once every so many seconds, for a rule that fires steadily.
| `seconds` (number), `tag` (string)
| `has`
| The inventory holds an item.
| `item` (string)
| `hitPart`
| The shot landed on a named zone of the target: head, weakspot.
| `part` (string)
| `hover`
| A player's pointer is over the named hotspot.
| `player` (number), `target` (string)
| `inState`
| An instance's state is this.
| `entity` (entity), `state` (state)
| `keyHeld`
| A key is down.
| `key` (scancode)
| `keyPressed`
| A key went down this frame.
| `key` (scancode)
| `onGround`
| A platformer has ground under it.
| `entity` (entity)
| `onScreen`
| An instance's box is at least partly on the overlay.
| `entity` (entity)
| `once`
| Only the first time this rule would run -- per instance and per room unless scope is "game".
| `scope` (string), `tag` (string)
| `pointerIn`
| A player's pointer is over an instance.
| `entity` (entity), `player` (number)
| `pointerOffscreen`
| A player's pointer is off the picture, which is how a light gun reloads.
| `player` (number)
| `switchHeld`
| A pad, gun, or mouse switch is down.
| `switch` (switch)
| `test`
| An expression is true: self.health <= 0, count("zombie") == 0, score > 1000.
| `expr` (expression)
| `timeBetween`
| The game is between two moments, in seconds.
| `from` (number), `to` (number)
| `touching`
| Two instances overlap.
| `entity` (entity), `other` (entity)
| `waiting`
| A rail camera is held at a stop.
| `entity` (entity)
|===
=== Actions
[cols="1,3,2,1"]
|===
| Name | What it does | Parameters | Waits
| `addScore`
| Add to the score.
| `amount` (number)
|
| `addVar`
| Add to a var on an instance, or on the game with no entity.
| `amount` (number), `entity` (entity), `name` (string)
|
| `cameraCut`
| Draw the scene from another camera instance.
| `entity` (entity)
|
| `credit`
| A coin: one more credit.
| --
|
| `damage`
| Take from an instance's health; at zero it dies.
| `amount` (number), `entity` (entity)
|
| `destroy`
| Remove an instance.
| `entity` (entity)
|
| `die`
| The Sierra death: a message, then the game starts over.
| `text` (expression)
| yes
| `discTo`
| Send the disc to a frame -- the branch a QTE takes.
| `frame` (number)
|
| `emit`
| A burst of particles at an instance: an explosion, sparks, a puff.
| `b` (number), `count` (number), `entity` (entity), `g` (number), `r` (number), `speed` (number)
|
| `face`
| Turn a walker to face left or right, or toward an instance.
| `direction` (string), `entity` (entity), `target` (entity)
|
| `fade`
| Fade the picture to black, or back (with out false), and wait.
| `out` (boolean), `seconds` (number)
| yes
| `flash`
| Flash the screen a colour.
| `b` (number), `g` (number), `r` (number), `seconds` (number)
|
| `gameOver`
| End the game: rules stop, the gameOver event fires, restart begins again.
| --
|
| `give`
| Put an item in the inventory.
| `item` (string)
|
| `goTo`
| Go to a room, taking the named entity along and placing it at a point there -- or where the entity called at stands in that room.
| `at` (string), `entity` (entity), `room` (room), `x` (number), `y` (number), `z` (number)
|
| `jump`
| Ask a platformer to jump.
| `entity` (entity)
|
| `loadGame`
| Bring a slot back.
| `slot` (number)
|
| `lookAt`
| Turn an instance toward another.
| `entity` (entity), `target` (entity)
|
| `lua`
| Anything the vocabulary cannot say. The way out, and it is meant to be used.
| `code` (lua)
|
| `moveTo`
| Put an instance somewhere.
| `entity` (entity), `x` (number), `y` (number), `z` (number)
|
| `nextVerb`
| Cycle to the next verb in the game's list.
| --
|
| `pathNext`
| Send a rail camera on from its stop.
| `entity` (entity)
|
| `playAnimation`
| Play a model's clip, once or looping.
| `clip` (string), `entity` (entity), `loop` (boolean)
|
| `playMusic`
| Play a track, looping.
| `file` (file)
|
| `playSound`
| Play a clip, at a volume of its own (0 to 100) under the game's.
| `file` (file), `volume` (number)
|
| `push`
| Shove a body, or a ragdoll's bone, along a direction.
| `bone` (string), `entity` (entity), `x` (number), `y` (number), `z` (number)
|
| `reload`
| Fill a gun's ammo.
| `entity` (entity)
|
| `restart`
| Begin the game again from its first room.
| --
|
| `run`
| Drive a platformer left (-1) or right (1) this frame.
| `direction` (number), `entity` (entity)
|
| `saveGame`
| Keep the whole game -- room, positions, vars, inventory -- in a numbered slot.
| `slot` (number)
|
| `say`
| Show a line for a while, and wait for it. With a hud it goes in the element called say; otherwise it is drawn.
| `seconds` (number), `text` (expression)
| yes
| `setState`
| Change an instance's state.
| `entity` (entity), `state` (state)
|
| `setText`
| Change what a text instance says.
| `entity` (entity), `text` (expression)
|
| `setVar`
| Set a var on an instance, or on the game with no entity.
| `entity` (entity), `name` (string), `value` (expression)
|
| `setVerb`
| Choose the verb the next click uses.
| `verb` (string)
|
| `shake`
| Shake the camera for a moment.
| `amount` (number), `seconds` (number)
|
| `show`
| Show or hide an instance.
| `entity` (entity), `visible` (boolean)
|
| `spawn`
| Make an instance of a kind at a point.
| `kind` (kind), `x` (number), `y` (number), `z` (number)
|
| `spawnAtPointer`
| Make an instance of a kind where the player's pointer is: on the overlay in 2D, on the floor the ray meets in 3D.
| `kind` (kind), `player` (number)
|
| `stopMusic`
| Stop the track that is playing.
| --
|
| `submitScore`
| Send the score to the master service's board for this game, queued until it can go.
| `board` (string)
|
| `take`
| Take an item out of the inventory.
| `item` (string)
|
| `talk`
| Run a dialogue from the game's dialogues, and wait for it to end.
| `dialogue` (string)
| yes
| `timerStart`
| Start a named timer on an instance.
| `after` (number), `entity` (entity), `name` (string)
|
| `useItem`
| Choose an inventory item for the next click: "use key on ...".
| `item` (string)
|
| `wait`
| Wait some seconds before the next action.
| `seconds` (number)
| yes
| `walkTo`
| Send a walker to a point, and wait until it gets there.
| `entity` (entity), `x` (number), `y` (number), `z` (number)
| yes
| `walkToHotspot`
| Send a walker to a hotspot's walk point (the one the verb was used on, with self), and wait.
| `entity` (entity), `target` (entity)
| yes
| `walkToPointer`
| Send a walker where the player's pointer is, and wait.
| `entity` (entity), `player` (number)
| yes
|===

View file

@ -8591,6 +8591,37 @@ nodeSetPosition(wall, 0, 2, -3)
nodeSetRotation(wall, 90, 0, 0) nodeSetRotation(wall, 90, 0, 0)
---- ----
[#materialsetoccluder]
==== materialSetOccluder
[source,text]
----
materialSetOccluder(material, occluder)
----
Makes the material write depth and no colour. Whatever the mesh covers is
hidden as if the mesh were solid, but the mesh itself is not seen: what was
drawn behind it -- a painted backdrop, the disc -- shows through. It is the
stand-in for something that is in a picture but not in the scene: a pillar in
a painted room that a 3D character has to walk behind, a doorway, a table.
An occluder casts no shadow, since the painting already has one, and takes
no light. `false` (the default) makes the material ordinary again.
*Since:* 3.00.
*See also:* <<materialsetblend,materialSetBlend>>, <<materialsetunlit,materialSetUnlit>>
.Example
[source,lua]
----
-- A pillar that is in the painting: an invisible box where it stands, so
-- the hero disappears behind it and reappears on the other side.
hidden = materialNew()
materialSetOccluder(hidden, true)
pillar = nodeNew()
nodeSetMesh(pillar, meshBox(0.8, 4, 0.8), hidden)
nodeSetPosition(pillar, 2, 2, -3)
----
[#materialsetocclusionmap] [#materialsetocclusionmap]
==== materialSetOcclusionMap ==== materialSetOcclusionMap
@ -15244,19 +15275,21 @@ end
---- ----
channel = soundPlay(id) channel = soundPlay(id)
channel = soundPlay(id, loops) channel = soundPlay(id, loops)
channel = soundPlay(id, loops, volume)
---- ----
Starts a loaded clip on the first free channel and returns that channel's number. A channel counts as free when it is neither playing nor paused. The new channel starts at the master volume, centered, with no scene position and the default range, whatever the channel's previous use set. When all sixteen channels are busy nothing plays and `-1` (`SOUND_ERROR_INVALID`) comes back; a game that fires many overlapping sounds should check for it. A handle that is not loaded aborts the script. Starts a loaded clip on the first free channel and returns that channel's number. A channel counts as free when it is neither playing nor paused. The new channel starts at the master volume (or at `volume` under it), centered, with no scene position and the default range, whatever the channel's previous use set. When all sixteen channels are busy nothing plays and `-1` (`SOUND_ERROR_INVALID`) comes back; a game that fires many overlapping sounds should check for it. A handle that is not loaded aborts the script.
*Parameters:* *Parameters:*
* `id` -- sound handle from `soundLoad`. * `id` -- sound handle from `soundLoad`.
* `loops` -- `0` (the default) plays the clip once, a positive count repeats it that many more times, `-1` loops it until it is stopped. Anything below `-1` aborts the script. * `loops` -- `0` (the default) plays the clip once, a positive count repeats it that many more times, `-1` loops it until it is stopped. Anything below `-1` aborts the script.
* `volume` -- the channel's own volume, `0` through `63`, under the master; the default is `63`, the clip as loud as the master allows. Outside that range aborts the script. `soundSetChannelVolume` changes it while the clip plays.
*Returns:* integer channel number, `0` to `15`, or `-1` when no channel is free. *Returns:* integer channel number, `0` to `15`, or `-1` when no channel is free.
*Since:* 1.x *Since:* 1.x; `volume` since 3.00.
*See also:* <<soundload,soundLoad>>, <<soundstop,soundStop>>, <<soundsetnode,soundSetNode>> *See also:* <<soundload,soundLoad>>, <<soundstop,soundStop>>, <<soundsetchannelvolume,soundSetChannelVolume>>, <<soundsetnode,soundSetNode>>
.Example .Example
[source,lua] [source,lua]
@ -15302,6 +15335,36 @@ function onSoundCompleted(channel)
end end
---- ----
[#soundsetchannelvolume]
==== soundSetChannelVolume
[source,text]
----
soundSetChannelVolume(channel, volume)
----
Sets one channel's own volume, on top of the master from `soundSetVolume`: the channel plays at the master times this, times its distance fade when it is in the scene. Every channel starts at full when `soundPlay` takes it, unless `soundPlay` was given a volume, so a quiet footstep and a loud shot can share one master. Values outside `0` to `63` abort the script, and so does a channel outside `0` to `15`.
*Parameters:*
* `channel` -- channel number from `soundPlay`.
* `volume` -- integer, `0` (silent) through `63` (as loud as the master).
*Since:* 3.00
*See also:* <<soundplay,soundPlay>>, <<soundsetvolume,soundSetVolume>>
.Example
[source,lua]
----
-- An engine that gets louder with the throttle: one looping clip, its channel's volume
-- following the pedal every frame.
engine = soundPlay(engineClip, -1, 20)
function onOverlayUpdate()
soundSetChannelVolume(engine, math.floor(20 + throttle * 43))
end
----
[#soundsetlistener] [#soundsetlistener]
==== soundSetListener ==== soundSetListener
@ -15442,14 +15505,14 @@ soundSetRange(siren, 10, 200)
soundSetVolume(volume) soundSetVolume(volume)
---- ----
Sets the master effects volume and applies it at once to every channel, playing or paused. Positioned channels keep their distance fade on top of it. Values outside `0` to `63` abort the script. Sets the master effects volume and applies it at once to every channel, playing or paused. Each channel's own volume from `soundSetChannelVolume` and, for positioned channels, the distance fade stay on top of it. Values outside `0` to `63` abort the script.
*Parameters:* *Parameters:*
* `volume` -- integer, `0` (silent) through `63` (loudest). * `volume` -- integer, `0` (silent) through `63` (loudest).
*Since:* 1.16 *Since:* 1.16
*See also:* <<soundgetvolume,soundGetVolume>> *See also:* <<soundgetvolume,soundGetVolume>>, <<soundsetchannelvolume,soundSetChannelVolume>>
.Example .Example
[source,lua] [source,lua]
@ -15592,7 +15655,7 @@ spriteDrawFrame(id, x, y, frame, scale)
spriteDrawFrame(id, x, y, frame, scaleX, scaleY) spriteDrawFrame(id, x, y, frame, scaleX, scaleY)
---- ----
Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`. Hypseus extension. Blits one frame of a sprite sheet (or of any animation) onto the overlay with `(x, y)` as its top-left corner, without changing which frame the sprite is parked on or whether it is playing. Frames are numbered from `1`, as Hypseus counts them; a frame outside `1` to the frame count draws frame `1`. A still image draws itself and ignores `frame`. A mirror set by `spriteFlip` applies. The sprite's own rotation and scale from `spriteRotate` and `spriteScale` are ignored: the first form draws the frame at its natural size, the others multiply the frame's width by `scaleX` (or `scale`) and its height by `scaleY` (or `scale`) with nearest-neighbor sampling. Call it from `onOverlayUpdate`.
*Parameters:* *Parameters:*
@ -15695,6 +15758,40 @@ spriteRotateFrame(needle, heading, 1)
spriteDrawRotatedFrame(needle, 320, 240) spriteDrawRotatedFrame(needle, 320, 240)
---- ----
[#spriteflip]
==== spriteFlip
[source,text]
----
spriteFlip(id, horizontal, vertical)
----
Mirrors the sprite left to right, top to bottom, or both, until it is called again with `false, false`. The mirror applies to every draw of the sprite: `spriteDraw`, each frame from `spriteDrawFrame`, and the turned frame from `spriteDrawRotatedFrame`. It is applied after the rotation and scale from `spriteRotate` and `spriteScale`, so a turned sprite is mirrored as it appears on screen, and a turned frame is simply mirrored the other way rather than turned again. The mirrored image is rebuilt only when the flags change; a frame drawn by `spriteDrawFrame` is mirrored once and kept until another frame is asked for. `spriteDrawGrid` ignores the mirror, since its rectangle is cut from the unmirrored image.
A character that walks both ways needs only art that faces one way: flip the sprite when it turns round.
*Parameters:*
* `id` -- sprite handle.
* `horizontal` -- `true` mirrors left to right.
* `vertical` -- `true` mirrors top to bottom.
*Returns:* nothing.
*Since:* 3.00
*See also:* <<spriterotate,spriteRotate>>, <<spritedrawframe,spriteDrawFrame>>, <<spritedrawrotatedframe,spriteDrawRotatedFrame>>
.Example
[source,lua]
----
-- The hero's sheet faces right; facing left is the same frames, mirrored.
function onOverlayUpdate()
overlayClear()
spriteFlip(hero, heroVx < 0, false)
spriteDrawFrame(hero, heroX, heroY, walkFrame)
end
----
[#spriteframeheight] [#spriteframeheight]
==== spriteFrameHeight ==== spriteFrameHeight

View file

@ -1555,7 +1555,7 @@ static bool _runTool(const ConfigT *conf) {
// know so that later versions may add them. // know so that later versions may add them.
static void _showApiVersion(void) { static void _showApiVersion(void) {
// VERSION_STRING is "vX.YY" for people; a machine wants the number on its own. // VERSION_STRING is "vX.YY" for people; a machine wants the number on its own.
printf("singe version=%s protocol=%d\n", VERSION_STRING + 1, API_VERSION_PROTOCOL); printf("singe version=%s protocol=%d\n", &VERSION_STRING[1], API_VERSION_PROTOCOL);
fflush(stdout); fflush(stdout);
exit(EXIT_SUCCESS); exit(EXIT_SUCCESS);
} }

View file

@ -563,6 +563,9 @@ int32_t navAgentNew(int32_t nav, int32_t node, float radius, float height, float
} }
radius = SDL_max(radius, MIN_AGENT_SIZE); radius = SDL_max(radius, MIN_AGENT_SIZE);
height = SDL_max(height, MIN_AGENT_SIZE); height = SDL_max(height, MIN_AGENT_SIZE);
// A node placed this frame has no world matrix until the scene walks them, and a game that
// never draws the scene (a 2D room walking a mesh) never walks them at all.
sceneUpdateTransforms();
position = nodeGetWorldPosition(node); position = nodeGetWorldPosition(node);
if (_nearestPoly(record, position, start) == 0) { if (_nearestPoly(record, position, start) == 0) {
start[0] = position.x; start[0] = position.x;

View file

@ -63,10 +63,11 @@
#define SH_SAMPLES_ACROSS 128 // Equirect columns sampled for the harmonics #define SH_SAMPLES_ACROSS 128 // Equirect columns sampled for the harmonics
#define HALF_BYTES 2 #define HALF_BYTES 2
#define CUBE_CHANNELS 4 #define CUBE_CHANNELS 4
#define PIPELINE_COUNT 8 // skinned x blend x double sided #define PIPELINE_COUNT 16 // skinned x blend x double sided x occluder
#define PIPELINE_SKINNED 1 #define PIPELINE_SKINNED 1
#define PIPELINE_BLEND 2 #define PIPELINE_BLEND 2
#define PIPELINE_TWO_SIDED 4 #define PIPELINE_TWO_SIDED 4
#define PIPELINE_OCCLUDER 8 // Writes depth and no colour: a stand-in for something painted
#define SHADOW_PIPELINES 8 // skinned x double sided x cutout #define SHADOW_PIPELINES 8 // skinned x double sided x cutout
#define SHADOW_PIPELINE_SKINNED 1 #define SHADOW_PIPELINE_SKINNED 1
#define SHADOW_PIPELINE_TWO_SIDED 2 #define SHADOW_PIPELINE_TWO_SIDED 2
@ -245,6 +246,7 @@ typedef struct MaterialS {
bool unlit; bool unlit;
bool doubleSided; bool doubleSided;
bool blend; bool blend;
bool occluder; // Depth only: hides what is behind it and shows what is behind itself
bool textureBorrowed; // The base texture belongs to a sprite node: never released here bool textureBorrowed; // The base texture belongs to a sprite node: never released here
bool used; bool used;
} MaterialT; } MaterialT;
@ -1218,6 +1220,12 @@ static bool _createPipeline(int32_t sampleSet, int32_t variant) {
if (variant & PIPELINE_BLEND) { if (variant & PIPELINE_BLEND) {
_alphaBlendState(&colour, false); _alphaBlendState(&colour, false);
} }
if (variant & PIPELINE_OCCLUDER) {
// The shape goes into the depth buffer and nowhere else, so a painted backdrop stays in
// view where it stands while whatever walks behind it is hidden.
colour.blend_state.enable_color_write_mask = true;
colour.blend_state.color_write_mask = 0;
}
info.vertex_shader = (variant & PIPELINE_SKINNED) ? _scene.vertexSkinned : _scene.vertexStatic; info.vertex_shader = (variant & PIPELINE_SKINNED) ? _scene.vertexSkinned : _scene.vertexStatic;
info.fragment_shader = _scene.fragment; info.fragment_shader = _scene.fragment;
info.vertex_input_state.vertex_buffer_descriptions = &buffer; info.vertex_input_state.vertex_buffer_descriptions = &buffer;
@ -1811,7 +1819,8 @@ static void _drawList(SDL_GPUCommandBuffer *commands, SDL_GPURenderPass *pass, i
continue; continue;
} }
if (shadowPass) { if (shadowPass) {
if (!node->shadowCaster && !_scene.depthPrepass) { // An occluder stands for something already painted, shadows included.
if ((!node->shadowCaster && !_scene.depthPrepass) || material->occluder) {
continue; continue;
} }
// A bulb inside a closed mesh sees only its back faces; they must still cast. // A bulb inside a closed mesh sees only its back faces; they must still cast.
@ -3021,6 +3030,9 @@ static int32_t _pipelineVariant(int32_t node) {
if (material->doubleSided) { if (material->doubleSided) {
variant |= PIPELINE_TWO_SIDED; variant |= PIPELINE_TWO_SIDED;
} }
if (material->occluder) {
variant |= PIPELINE_OCCLUDER;
}
} }
return variant; return variant;
} }
@ -4113,6 +4125,15 @@ bool materialSetMetallic(int32_t material, float metallic) {
} }
bool materialSetOccluder(int32_t material, bool occluder) {
if (!materialValid(material)) {
return false;
}
_scene.materials[material].occluder = occluder;
return true;
}
bool materialSetRoughness(int32_t material, float roughness) { bool materialSetRoughness(int32_t material, float roughness) {
if (!materialValid(material)) { if (!materialValid(material)) {
return false; return false;

View file

@ -152,6 +152,7 @@ bool materialSetGui(int32_t material, int32_t gui); // A GUI's texture
bool materialSetMap(int32_t material, MaterialMapE map, const Ktx2ImageT *image, float strength); // A transcoded KTX2 image (NULL clears); strength for normal and occlusion bool materialSetMap(int32_t material, MaterialMapE map, const Ktx2ImageT *image, float strength); // A transcoded KTX2 image (NULL clears); strength for normal and occlusion
bool materialSetMapSurface(int32_t material, MaterialMapE map, SDL_Surface *image, float strength); // The same from a surface (NULL clears) bool materialSetMapSurface(int32_t material, MaterialMapE map, SDL_Surface *image, float strength); // The same from a surface (NULL clears)
bool materialSetMetallic(int32_t material, float metallic); bool materialSetMetallic(int32_t material, float metallic);
bool materialSetOccluder(int32_t material, bool occluder); // Depth only: hides what is behind it, shows the backdrop through it
bool materialSetRoughness(int32_t material, float roughness); bool materialSetRoughness(int32_t material, float roughness);
bool materialSetTiling(int32_t material, float u, float v); bool materialSetTiling(int32_t material, float u, float v);
bool materialSetUnlit(int32_t material, bool unlit); bool materialSetUnlit(int32_t material, bool unlit);

View file

@ -427,6 +427,9 @@ typedef struct SpriteS {
SDL_Surface *originalSurface; // Owned unless it points into animation->frames SDL_Surface *originalSurface; // Owned unless it points into animation->frames
SDL_Surface *surface; // What gets drawn: originalSurface, or a transformed copy SDL_Surface *surface; // What gets drawn: originalSurface, or a transformed copy
SDL_Surface *rotatedFrame; // spriteRotateFrame's turned copy of one sheet frame, drawn by spriteDrawRotatedFrame SDL_Surface *rotatedFrame; // spriteRotateFrame's turned copy of one sheet frame, drawn by spriteDrawRotatedFrame
SDL_Surface *flippedFrame; // spriteFlip's mirrored copy of the sheet frame spriteDrawFrame last drew
int32_t flippedIndex; // Which frame that is
SDL_FlipMode flip; // How spriteFlip mirrors every draw
bool surfaceOwned; // True when surface is a transformed copy to free bool surfaceOwned; // True when surface is a transformed copy to free
int32_t frameWidth; // Unscaled size of one frame (the image, for a still) int32_t frameWidth; // Unscaled size of one frame (the image, for a still)
int32_t frameHeight; int32_t frameHeight;
@ -480,7 +483,7 @@ typedef struct EffectS {
float farOff; // ... silent beyond this float farOff; // ... silent beyond this
float relative[3]; // The last position handed to the mixer, listener space float relative[3]; // The last position handed to the mixer, listener space
float gain; // The last distance gain float gain; // The last distance gain
float baseGain; // The effects volume the track gain was last built from float volume; // The channel's own volume, 0 to 1, on top of the master
bool positioned; bool positioned;
} EffectT; } EffectT;
@ -785,6 +788,7 @@ static void _drawPauseIndicator(const SDL_FRect *target);
static void _drawRing(const SDL_FRect *outer, const SDL_FRect *inner, const SDL_Color *color); static void _drawRing(const SDL_FRect *outer, const SDL_FRect *inner, const SDL_Color *color);
static void _drawSindenBorder(void); static void _drawSindenBorder(void);
static void _drawTrails2D(const EmitterViewT *view, const ParticleTexturesT *cache, const SDL_FRect *target, float scaleX, float scaleY); static void _drawTrails2D(const EmitterViewT *view, const ParticleTexturesT *cache, const SDL_FRect *target, float scaleX, float scaleY);
static void _effectApplyGain(int32_t channel);
static float _effectGain(const EffectT *effect, float distance); static float _effectGain(const EffectT *effect, float distance);
static void _effectReset(int32_t channel); static void _effectReset(int32_t channel);
static void _effectStopped(void *userdata, MIX_Track *track); static void _effectStopped(void *userdata, MIX_Track *track);
@ -941,11 +945,13 @@ static SoundT *_soundLoadFromIO(lua_State *L, const char *method, SDL_IOStr
static int32_t _soundQueueDrain(int32_t *finished); static int32_t _soundQueueDrain(int32_t *finished);
static void _spriteAdoptAnimation(SpriteT *sprite); static void _spriteAdoptAnimation(SpriteT *sprite);
static void _spriteDestroy(SpriteT *sprite); static void _spriteDestroy(SpriteT *sprite);
static SDL_Surface *_spriteFlippedFrame(SpriteT *sprite, SDL_Surface *source, int32_t frame);
static void _spriteFreeSurface(SpriteT *sprite); static void _spriteFreeSurface(SpriteT *sprite);
static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io, int32_t width, int32_t height); static SpriteT *_spriteLoadFromIO(lua_State *L, const char *method, SDL_IOStream *io, int32_t width, int32_t height);
static SpriteT *_spriteNew(lua_State *L, const char *method); static SpriteT *_spriteNew(lua_State *L, const char *method);
static void _spriteRebuildSurface(SpriteT *sprite); static void _spriteRebuildSurface(SpriteT *sprite);
static void _spriteRegister(SpriteT *sprite); static void _spriteRegister(SpriteT *sprite);
static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source);
static const char *_startAudioSuffix(void); static const char *_startAudioSuffix(void);
static void _startControllers(void); static void _startControllers(void);
static void _startLuaContext(lua_State *L); static void _startLuaContext(lua_State *L);
@ -1142,6 +1148,7 @@ static int32_t apiMaterialSetGui(lua_State *L);
static int32_t apiMaterialSetMetallic(lua_State *L); static int32_t apiMaterialSetMetallic(lua_State *L);
static int32_t apiMaterialSetMetallicRoughnessMap(lua_State *L); static int32_t apiMaterialSetMetallicRoughnessMap(lua_State *L);
static int32_t apiMaterialSetNormalMap(lua_State *L); static int32_t apiMaterialSetNormalMap(lua_State *L);
static int32_t apiMaterialSetOccluder(lua_State *L);
static int32_t apiMaterialSetOcclusionMap(lua_State *L); static int32_t apiMaterialSetOcclusionMap(lua_State *L);
static int32_t apiMaterialSetRoughness(lua_State *L); static int32_t apiMaterialSetRoughness(lua_State *L);
static int32_t apiMaterialSetTexture(lua_State *L); static int32_t apiMaterialSetTexture(lua_State *L);
@ -1371,6 +1378,7 @@ static int32_t apiSoundLoadData(lua_State *L);
static int32_t apiSoundPause(lua_State *L); static int32_t apiSoundPause(lua_State *L);
static int32_t apiSoundPlay(lua_State *L); static int32_t apiSoundPlay(lua_State *L);
static int32_t apiSoundResume(lua_State *L); static int32_t apiSoundResume(lua_State *L);
static int32_t apiSoundSetChannelVolume(lua_State *L);
static int32_t apiSoundSetListener(lua_State *L); static int32_t apiSoundSetListener(lua_State *L);
static int32_t apiSoundSetNode(lua_State *L); static int32_t apiSoundSetNode(lua_State *L);
static int32_t apiSoundSetPan(lua_State *L); static int32_t apiSoundSetPan(lua_State *L);
@ -1384,6 +1392,7 @@ static int32_t apiSpriteDraw(lua_State *L);
static int32_t apiSpriteDrawFrame(lua_State *L); static int32_t apiSpriteDrawFrame(lua_State *L);
static int32_t apiSpriteDrawGrid(lua_State *L); static int32_t apiSpriteDrawGrid(lua_State *L);
static int32_t apiSpriteDrawRotatedFrame(lua_State *L); static int32_t apiSpriteDrawRotatedFrame(lua_State *L);
static int32_t apiSpriteFlip(lua_State *L);
static int32_t apiSpriteFrameHeight(lua_State *L); static int32_t apiSpriteFrameHeight(lua_State *L);
static int32_t apiSpriteFrameWidth(lua_State *L); static int32_t apiSpriteFrameWidth(lua_State *L);
static int32_t apiSpriteGetFrame(lua_State *L); static int32_t apiSpriteGetFrame(lua_State *L);
@ -3108,6 +3117,16 @@ static float _effectGain(const EffectT *effect, float distance) {
// A channel about to play afresh: no position, no pan, the default range. // A channel about to play afresh: no position, no pan, the default range.
// What a channel's track plays at: the master effects volume, the channel's own volume, and its
// distance gain while it is in the scene. Every gain change goes through here so there is one
// answer to what a channel is worth, and the master can move without losing the rest.
static void _effectApplyGain(int32_t channel) {
EffectT *effect = &_effects[channel];
MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME) * effect->volume * (effect->positioned ? effect->gain : 1.0f));
}
static void _effectReset(int32_t channel) { static void _effectReset(int32_t channel) {
EffectT *effect = &_effects[channel]; EffectT *effect = &_effects[channel];
@ -3116,7 +3135,9 @@ static void _effectReset(int32_t channel) {
effect->nearBy = SOUND_DEFAULT_NEAR; effect->nearBy = SOUND_DEFAULT_NEAR;
effect->farOff = SOUND_DEFAULT_FAR; effect->farOff = SOUND_DEFAULT_FAR;
effect->gain = 1.0f; effect->gain = 1.0f;
effect->volume = 1.0f;
MIX_SetTrack3DPosition(_effectTracks[channel], NULL); MIX_SetTrack3DPosition(_effectTracks[channel], NULL);
_effectApplyGain(channel);
} }
@ -5909,6 +5930,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "materialSetMetallic", apiMaterialSetMetallic); // 3.00 lua_register(L, "materialSetMetallic", apiMaterialSetMetallic); // 3.00
lua_register(L, "materialSetMetallicRoughnessMap", apiMaterialSetMetallicRoughnessMap); // 3.00 lua_register(L, "materialSetMetallicRoughnessMap", apiMaterialSetMetallicRoughnessMap); // 3.00
lua_register(L, "materialSetNormalMap", apiMaterialSetNormalMap); // 3.00 lua_register(L, "materialSetNormalMap", apiMaterialSetNormalMap); // 3.00
lua_register(L, "materialSetOccluder", apiMaterialSetOccluder); // 3.00
lua_register(L, "materialSetOcclusionMap", apiMaterialSetOcclusionMap); // 3.00 lua_register(L, "materialSetOcclusionMap", apiMaterialSetOcclusionMap); // 3.00
lua_register(L, "materialSetRoughness", apiMaterialSetRoughness); // 3.00 lua_register(L, "materialSetRoughness", apiMaterialSetRoughness); // 3.00
lua_register(L, "materialSetTexture", apiMaterialSetTexture); // 3.00 lua_register(L, "materialSetTexture", apiMaterialSetTexture); // 3.00
@ -6141,6 +6163,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "soundPause", apiSoundPause); // 1.16 RDG lua_register(L, "soundPause", apiSoundPause); // 1.16 RDG
lua_register(L, "soundPlay", apiSoundPlay); // 1.xx lua_register(L, "soundPlay", apiSoundPlay); // 1.xx
lua_register(L, "soundResume", apiSoundResume); // 1.16 RDG lua_register(L, "soundResume", apiSoundResume); // 1.16 RDG
lua_register(L, "soundSetChannelVolume", apiSoundSetChannelVolume); // 3.00
lua_register(L, "soundSetListener", apiSoundSetListener); // 3.00 lua_register(L, "soundSetListener", apiSoundSetListener); // 3.00
lua_register(L, "soundSetNode", apiSoundSetNode); // 3.00 lua_register(L, "soundSetNode", apiSoundSetNode); // 3.00
lua_register(L, "soundSetPan", apiSoundSetPan); // 3.00 lua_register(L, "soundSetPan", apiSoundSetPan); // 3.00
@ -6155,6 +6178,7 @@ static void _registerApi(lua_State *L) {
lua_register(L, "spriteDrawFrame", apiSpriteDrawFrame); // Hypseus lua_register(L, "spriteDrawFrame", apiSpriteDrawFrame); // Hypseus
lua_register(L, "spriteDrawGrid", apiSpriteDrawGrid); // Hypseus lua_register(L, "spriteDrawGrid", apiSpriteDrawGrid); // Hypseus
lua_register(L, "spriteDrawRotatedFrame", apiSpriteDrawRotatedFrame); // Hypseus Handle first since 3.00. lua_register(L, "spriteDrawRotatedFrame", apiSpriteDrawRotatedFrame); // Hypseus Handle first since 3.00.
lua_register(L, "spriteFlip", apiSpriteFlip); // 3.00
lua_register(L, "spriteFrameHeight", apiSpriteFrameHeight); // Hypseus lua_register(L, "spriteFrameHeight", apiSpriteFrameHeight); // Hypseus
lua_register(L, "spriteFrameWidth", apiSpriteFrameWidth); // Hypseus lua_register(L, "spriteFrameWidth", apiSpriteFrameWidth); // Hypseus
lua_register(L, "spriteGetFrame", apiSpriteGetFrame); // 2.10 lua_register(L, "spriteGetFrame", apiSpriteGetFrame); // 2.10
@ -6352,7 +6376,9 @@ static void _resetScriptState(void) {
} }
_soundQueueDrain(finished); _soundQueueDrain(finished);
_global.effectsVolume = _defaultVolume(AUDIO_MAX_VOLUME); _global.effectsVolume = _defaultVolume(AUDIO_MAX_VOLUME);
MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); for (x = 0; x < EFFECT_TRACKS; x++) {
_effectApplyGain(x);
}
// Music starts as loud as the sound effects, on its own scale and with its own gain, so // Music starts as loud as the sound effects, on its own scale and with its own gain, so
// musicSetVolume and soundSetVolume never move each other. // musicSetVolume and soundSetVolume never move each other.
_global.musicVolume = _defaultVolume(MUSIC_MAX_VOLUME); _global.musicVolume = _defaultVolume(MUSIC_MAX_VOLUME);
@ -7239,6 +7265,7 @@ static void _spriteDestroy(SpriteT *sprite) {
HASH_DEL(_global.spriteList, sprite); HASH_DEL(_global.spriteList, sprite);
_spriteFreeSurface(sprite); _spriteFreeSurface(sprite);
SDL_DestroySurface(sprite->rotatedFrame); SDL_DestroySurface(sprite->rotatedFrame);
SDL_DestroySurface(sprite->flippedFrame);
if (sprite->animation != NULL) { if (sprite->animation != NULL) {
// Frames belong to the animation. // Frames belong to the animation.
IMG_FreeAnimation(sprite->animation); IMG_FreeAnimation(sprite->animation);
@ -7249,6 +7276,23 @@ static void _spriteDestroy(SpriteT *sprite) {
} }
// The mirrored copy of one sheet frame that spriteDrawFrame draws while the sprite is flipped, kept
// from one draw to the next so a sprite standing on a frame is not mirrored again every frame.
static SDL_Surface *_spriteFlippedFrame(SpriteT *sprite, SDL_Surface *source, int32_t frame) {
if ((sprite->flippedFrame == NULL) || (sprite->flippedIndex != frame)) {
SDL_DestroySurface(sprite->flippedFrame);
sprite->flippedFrame = SDL_DuplicateSurface(source);
if (sprite->flippedFrame == NULL) {
utilDie("Unable to mirror frame %d of sprite %d.", frame, sprite->id);
}
SDL_FlipSurface(sprite->flippedFrame, sprite->flip);
sprite->flippedIndex = frame;
}
return sprite->flippedFrame;
}
// Releases the drawn surface if it is a transformed copy. Animation frames are never freed here. // Releases the drawn surface if it is a transformed copy. Animation frames are never freed here.
static void _spriteFreeSurface(SpriteT *sprite) { static void _spriteFreeSurface(SpriteT *sprite) {
if (sprite->surfaceOwned) { if (sprite->surfaceOwned) {
@ -7324,11 +7368,11 @@ static SpriteT *_spriteNew(lua_State *L, const char *method) {
// Rebuilds the drawn surface after a frame, angle, scale, or quality change. // Rebuilds the drawn surface after a frame, angle, scale, or quality change.
static void _spriteRebuildSurface(SpriteT *sprite) { static void _spriteRebuildSurface(SpriteT *sprite) {
_spriteFreeSurface(sprite); _spriteFreeSurface(sprite);
if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) { if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0) && (sprite->flip == SDL_FLIP_NONE)) {
// Untransformed sprites draw straight from the original. // Untransformed sprites draw straight from the original.
sprite->surface = sprite->originalSurface; sprite->surface = sprite->originalSurface;
} else { } else {
sprite->surface = rotoZoomSurface(sprite->originalSurface, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); sprite->surface = _spriteTransform(sprite, sprite->originalSurface);
if (sprite->surface == NULL) { if (sprite->surface == NULL) {
utilDie("Unable to transform sprite %d.", sprite->id); utilDie("Unable to transform sprite %d.", sprite->id);
} }
@ -7350,6 +7394,25 @@ static void _spriteRegister(SpriteT *sprite) {
} }
// A fresh copy of source turned by the sprite's angle, scaled by its scale, and then mirrored as
// its flip says, or NULL when the copy could not be made. Mirroring after turning is what lets
// spriteFlip change the mirror on a turned frame without turning it again.
static SDL_Surface *_spriteTransform(SpriteT *sprite, SDL_Surface *source) {
SDL_Surface *copy = NULL;
if ((sprite->angle == 0.0) && (sprite->scaleX == 1.0) && (sprite->scaleY == 1.0)) {
copy = SDL_DuplicateSurface(source);
} else {
copy = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0);
}
if ((copy != NULL) && (sprite->flip != SDL_FLIP_NONE)) {
SDL_FlipSurface(copy, sprite->flip);
}
return copy;
}
// The disc audio suffix a run and a reload start from: --altaudio's, or the game's own audio. // The disc audio suffix a run and a reload start from: --altaudio's, or the game's own audio.
static const char *_startAudioSuffix(void) { static const char *_startAudioSuffix(void) {
return (_global.conf->audioSuffix != NULL) ? _global.conf->audioSuffix : ""; return (_global.conf->audioSuffix != NULL) ? _global.conf->audioSuffix : "";
@ -8082,7 +8145,6 @@ static void _updateSounds(void) {
Vec3T relative; Vec3T relative;
MIX_Point3D point; MIX_Point3D point;
EffectT *effect = NULL; EffectT *effect = NULL;
float baseGain = _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME);
float distance = 0.0f; float distance = 0.0f;
float gain = 0.0f; float gain = 0.0f;
bool useNode = false; bool useNode = false;
@ -8103,7 +8165,7 @@ static void _updateSounds(void) {
if (!nodeValid(effect->node)) { if (!nodeValid(effect->node)) {
effect->positioned = false; effect->positioned = false;
MIX_SetTrack3DPosition(_effectTracks[x], NULL); MIX_SetTrack3DPosition(_effectTracks[x], NULL);
MIX_SetTrackGain(_effectTracks[x], baseGain); _effectApplyGain(x);
continue; continue;
} }
world = nodeGetWorldPosition(effect->node); world = nodeGetWorldPosition(effect->node);
@ -8117,11 +8179,10 @@ static void _updateSounds(void) {
} }
gain = _effectGain(effect, distance); gain = _effectGain(effect, distance);
// The mixer only hears about changes. // The mixer only hears about changes.
if ((gain == effect->gain) && (baseGain == effect->baseGain) && (relative.x == effect->relative[0]) && (relative.y == effect->relative[1]) && (relative.z == effect->relative[2])) { if ((gain == effect->gain) && (relative.x == effect->relative[0]) && (relative.y == effect->relative[1]) && (relative.z == effect->relative[2])) {
continue; continue;
} }
effect->gain = gain; effect->gain = gain;
effect->baseGain = baseGain;
effect->relative[0] = relative.x; effect->relative[0] = relative.x;
effect->relative[1] = relative.y; effect->relative[1] = relative.y;
effect->relative[2] = relative.z; effect->relative[2] = relative.z;
@ -8129,7 +8190,7 @@ static void _updateSounds(void) {
point.y = relative.y; point.y = relative.y;
point.z = relative.z; point.z = relative.z;
MIX_SetTrack3DPosition(_effectTracks[x], &point); MIX_SetTrack3DPosition(_effectTracks[x], &point);
MIX_SetTrackGain(_effectTracks[x], baseGain * gain); _effectApplyGain(x);
} }
} }
@ -10404,6 +10465,14 @@ static int32_t apiMaterialSetNormalMap(lua_State *L) {
} }
// materialSetOccluder(material, bool)
static int32_t apiMaterialSetOccluder(lua_State *L) {
_argCheck(L, "materialSetOccluder", 2, 2);
materialSetOccluder(_argMaterial(L, "materialSetOccluder", 1), _argBoolean(L, "materialSetOccluder", 2));
return 0;
}
// materialSetOcclusionMap(material[, image[, strength]]) occlusion in R; nil clears; strength defaults to 1. // materialSetOcclusionMap(material[, image[, strength]]) occlusion in R; nil clears; strength defaults to 1.
static int32_t apiMaterialSetOcclusionMap(lua_State *L) { static int32_t apiMaterialSetOcclusionMap(lua_State *L) {
return _materialSetMap(L, "materialSetOcclusionMap", MAP_OCCLUSION, true); return _materialSetMap(L, "materialSetOcclusionMap", MAP_OCCLUSION, true);
@ -13727,9 +13796,10 @@ static int32_t apiSoundPlay(lua_State *L) {
SoundT *sound = NULL; SoundT *sound = NULL;
int32_t channel = SOUND_CHANNEL_NONE; int32_t channel = SOUND_CHANNEL_NONE;
int32_t loops = 0; int32_t loops = 0;
int32_t volume = AUDIO_MAX_VOLUME;
SDL_PropertiesID options; SDL_PropertiesID options;
_argCheck(L, "soundPlay", 1, 2); _argCheck(L, "soundPlay", 1, 3);
sound = _argSound(L, "soundPlay", 1); sound = _argSound(L, "soundPlay", 1);
if (lua_gettop(L) >= 2) { if (lua_gettop(L) >= 2) {
loops = _argInteger(L, "soundPlay", 2); loops = _argInteger(L, "soundPlay", 2);
@ -13737,11 +13807,18 @@ static int32_t apiSoundPlay(lua_State *L) {
_luaDie(L, "soundPlay", "Loops must be -1 (forever), 0 (once) or a repeat count: %d", loops); _luaDie(L, "soundPlay", "Loops must be -1 (forever), 0 (once) or a repeat count: %d", loops);
} }
} }
if (lua_gettop(L) >= 3) {
volume = _argInteger(L, "soundPlay", 3);
if ((volume < 0) || (volume > AUDIO_MAX_VOLUME)) {
_luaDie(L, "soundPlay", "Invalid sound volume value: %d", volume);
}
}
channel = _effectTrackFree(); channel = _effectTrackFree();
if (channel >= 0) { if (channel >= 0) {
_effectReset(channel); _effectReset(channel);
_effects[channel].volume = _mixerGain(volume, AUDIO_MAX_VOLUME);
_effectApplyGain(channel);
MIX_SetTrackAudio(_effectTracks[channel], sound->audio); MIX_SetTrackAudio(_effectTracks[channel], sound->audio);
MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME));
options = SDL_CreateProperties(); options = SDL_CreateProperties();
SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops); SDL_SetNumberProperty(options, MIX_PROP_PLAY_LOOPS_NUMBER, loops);
if (!MIX_PlayTrack(_effectTracks[channel], options)) { if (!MIX_PlayTrack(_effectTracks[channel], options)) {
@ -13750,7 +13827,7 @@ static int32_t apiSoundPlay(lua_State *L) {
} }
SDL_DestroyProperties(options); SDL_DestroyProperties(options);
} }
_luaTrace(L, "soundPlay", "%d %d loops %d", sound->id, channel, loops); _luaTrace(L, "soundPlay", "%d %d loops %d volume %d", sound->id, channel, loops, volume);
lua_pushinteger(L, channel); lua_pushinteger(L, channel);
return 1; return 1;
@ -13773,6 +13850,26 @@ static int32_t apiSoundResume(lua_State *L) {
} }
// soundSetChannelVolume(channel, volume): one channel's own volume, 0 to AUDIO_MAX_VOLUME, under
// the master. soundPlay starts every channel at full.
static int32_t apiSoundSetChannelVolume(lua_State *L) {
int32_t channel = 0;
int32_t volume = 0;
_argCheck(L, "soundSetChannelVolume", 2, 2);
channel = _argChannel(L, "soundSetChannelVolume", 1);
volume = _argInteger(L, "soundSetChannelVolume", 2);
if ((volume < 0) || (volume > AUDIO_MAX_VOLUME)) {
_luaDie(L, "soundSetChannelVolume", "Invalid sound volume value: %d", volume);
}
_effects[channel].volume = _mixerGain(volume, AUDIO_MAX_VOLUME);
_effectApplyGain(channel);
_luaTrace(L, "soundSetChannelVolume", "%d %d", channel, volume);
return 0;
}
// soundSetListener([node]): positioned sounds are heard from this node; none means the scene camera // soundSetListener([node]): positioned sounds are heard from this node; none means the scene camera
static int32_t apiSoundSetListener(lua_State *L) { static int32_t apiSoundSetListener(lua_State *L) {
_argCheck(L, "soundSetListener", 0, 1); _argCheck(L, "soundSetListener", 0, 1);
@ -13797,7 +13894,7 @@ static int32_t apiSoundSetNode(lua_State *L) {
effect->node = LISTENER_CAMERA; effect->node = LISTENER_CAMERA;
effect->positioned = false; effect->positioned = false;
MIX_SetTrack3DPosition(_effectTracks[channel], NULL); MIX_SetTrack3DPosition(_effectTracks[channel], NULL);
MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); _effectApplyGain(channel);
} }
return 0; return 0;
} }
@ -13818,7 +13915,7 @@ static int32_t apiSoundSetPan(lua_State *L) {
gains.left = SDL_min(1.0f, 1.0f - pan); gains.left = SDL_min(1.0f, 1.0f - pan);
gains.right = SDL_min(1.0f, 1.0f + pan); gains.right = SDL_min(1.0f, 1.0f + pan);
MIX_SetTrackStereo(_effectTracks[channel], &gains); MIX_SetTrackStereo(_effectTracks[channel], &gains);
MIX_SetTrackGain(_effectTracks[channel], _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); _effectApplyGain(channel);
return 0; return 0;
} }
@ -13862,6 +13959,7 @@ static int32_t apiSoundSetRange(lua_State *L) {
// soundSetVolume(volume) 0 to AUDIO_MAX_VOLUME, applied to every effect channel. // soundSetVolume(volume) 0 to AUDIO_MAX_VOLUME, applied to every effect channel.
static int32_t apiSoundSetVolume(lua_State *L) { static int32_t apiSoundSetVolume(lua_State *L) {
int32_t volume = 0; int32_t volume = 0;
int32_t x = 0;
_argCheck(L, "soundSetVolume", 1, 1); _argCheck(L, "soundSetVolume", 1, 1);
volume = _argInteger(L, "soundSetVolume", 1); volume = _argInteger(L, "soundSetVolume", 1);
@ -13869,7 +13967,9 @@ static int32_t apiSoundSetVolume(lua_State *L) {
_luaDie(L, "soundSetVolume", "Invalid sound volume value: %d", volume); _luaDie(L, "soundSetVolume", "Invalid sound volume value: %d", volume);
} }
_global.effectsVolume = volume; _global.effectsVolume = volume;
MIX_SetTagGain(videoGetMixer(), EFFECT_TAG, _mixerGain(_global.effectsVolume, AUDIO_MAX_VOLUME)); for (x = 0; x < EFFECT_TRACKS; x++) {
_effectApplyGain(x);
}
_luaTrace(L, "soundSetVolume", "%d", _global.effectsVolume); _luaTrace(L, "soundSetVolume", "%d", _global.effectsVolume);
return 0; return 0;
@ -14022,6 +14122,9 @@ static int32_t apiSpriteDrawFrame(lua_State *L) {
} }
source = sprite->animation->frames[frame - 1]; source = sprite->animation->frames[frame - 1];
} }
if (sprite->flip != SDL_FLIP_NONE) {
source = _spriteFlippedFrame(sprite, source, (sprite->animation != NULL) ? frame : 1);
}
dest.w = source->w; dest.w = source->w;
dest.h = source->h; dest.h = source->h;
if (n == 4) { if (n == 4) {
@ -14110,6 +14213,36 @@ static int32_t apiSpriteDrawRotatedFrame(lua_State *L) {
} }
// spriteFlip(id, horizontal, vertical) Mirrors the sprite: every draw of it, of its frames, and of
// its turned frame, until it is called again with false, false. The mirror is applied after the
// turn, so a turned frame is just mirrored the other way rather than turned again.
static int32_t apiSpriteFlip(lua_State *L) {
SpriteT *sprite = NULL;
SDL_FlipMode flip = SDL_FLIP_NONE;
_argCheck(L, "spriteFlip", 3, 3);
sprite = _argSprite(L, "spriteFlip", 1);
if (_argBoolean(L, "spriteFlip", 2)) {
flip |= SDL_FLIP_HORIZONTAL;
}
if (_argBoolean(L, "spriteFlip", 3)) {
flip |= SDL_FLIP_VERTICAL;
}
if (flip != sprite->flip) {
if (sprite->rotatedFrame != NULL) {
SDL_FlipSurface(sprite->rotatedFrame, sprite->flip ^ flip);
}
SDL_DestroySurface(sprite->flippedFrame);
sprite->flippedFrame = NULL;
sprite->flip = flip;
_spriteRebuildSurface(sprite);
}
_luaTrace(L, "spriteFlip", "%d %d", sprite->id, flip);
return 0;
}
// height = spriteFrameHeight(id) Hypseus extension. One frame's height before scaling and rotation; the image's, for a still. // height = spriteFrameHeight(id) Hypseus extension. One frame's height before scaling and rotation; the image's, for a still.
static int32_t apiSpriteFrameHeight(lua_State *L) { static int32_t apiSpriteFrameHeight(lua_State *L) {
SpriteT *sprite = NULL; SpriteT *sprite = NULL;
@ -14462,7 +14595,7 @@ static int32_t apiSpriteRotateFrame(lua_State *L) {
source = sprite->animation->frames[frame - 1]; source = sprite->animation->frames[frame - 1];
} }
SDL_DestroySurface(sprite->rotatedFrame); SDL_DestroySurface(sprite->rotatedFrame);
sprite->rotatedFrame = rotoZoomSurface(source, -sprite->angle, sprite->scaleX, sprite->scaleY, sprite->smooth != 0); sprite->rotatedFrame = _spriteTransform(sprite, source);
if (sprite->rotatedFrame == NULL) { if (sprite->rotatedFrame == NULL) {
_luaDie(L, "spriteRotateFrame", "Unable to turn frame %d of sprite %d.", frame, sprite->id); _luaDie(L, "spriteRotateFrame", "Unable to turn frame %d of sprite %d.", frame, sprite->id);
} }

View file

@ -0,0 +1,140 @@
-- Genre one of milestone 3: a point-and-click adventure in a painted room, Sierra and LucasArts
-- in one table. Walk areas that become a navigation mesh, hotspots, a verb line, a text parser
-- as well, an inventory, a dialogue with a choice that changes the world, a cut-scene, two rooms
-- that remember themselves, and a save. The "painting" is a box, since no art is needed.
return {
title = "Author test: adventure",
size = { w = 720, h = 480 },
layers = { { kind = "overlay" },
{ kind = "parser", prompt = "> ",
words = { look = { "look", "examine", "l" }, take = { "take", "get", "pick" }, use = { "use" }, open = { "open" }, talk = { "talk", "speak" },
key = { "key" }, door = { "door", "gate" }, guard = { "guard", "man" } },
ignore = { "the", "a", "an", "at", "to", "with", "on", "up" } } },
vars = { score = 0, allowed = false },
verbs = { "walk", "look", "take", "use", "open", "talk" },
kinds = {
backdrop = { look = { kind = "box", w = 720, h = 480, r = 40, g = 45, b = 60 } },
floor = { look = { kind = "box", w = 720, h = 200, r = 70, g = 60, b = 50, anchor = "feet" } },
hero = { look = { kind = "box", w = 30, h = 70, r = 230, g = 90, b = 170, anchor = "feet" },
behaviours = { { kind = "walker", speed = 160 } } },
door = { look = { kind = "box", w = 60, h = 120, r = 90, g = 60, b = 40, anchor = "feet" },
behaviours = { { kind = "hotspot", name = "door", walkX = 600, walkY = 330 } } },
key = { look = { kind = "box", w = 16, h = 8, r = 255, g = 220, b = 60 },
behaviours = { { kind = "hotspot", name = "key", walkX = 200, walkY = 400 } } },
guard = { look = { kind = "box", w = 34, h = 80, r = 80, g = 120, b = 200, anchor = "feet" },
behaviours = { { kind = "hotspot", name = "guard", walkX = 480, walkY = 360 } } },
pillar = { look = { kind = "box", w = 40, h = 200, r = 60, g = 60, b = 80, anchor = "feet" } },
marker = { look = { kind = "none" } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "hall",
depthSort = true,
scaleBy = { { y = 300, scale = 0.6 }, { y = 460, scale = 1.0 } },
walk = { { 120, 300, 640, 300, 700, 460, 60, 460 } },
entities = {
{ kind = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 },
{ kind = "floor", id = "floor", x = 360, y = 480, z = 0 },
{ kind = "door", id = "door", x = 600, y = 300, z = 0 },
{ kind = "guard", id = "guard", x = 540, y = 340, z = 0 },
{ kind = "pillar", id = "pillar", x = 360, y = 420, z = 0 },
{ kind = "key", id = "key", x = 200, y = 380, z = 0 },
{ kind = "hero", id = "hero", x = 150, y = 440, z = 0 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} },
{ name = "yard",
depthSort = true,
walk = { { 40, 320, 680, 320, 680, 460, 40, 460 } },
entities = {
{ kind = "backdrop", id = "backdrop", x = 360, y = 240, z = 0 },
{ kind = "door", id = "door", x = 100, y = 300, z = 0 },
{ kind = "marker", id = "arrival", x = 160, y = 420, z = 0 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
dialogues = {
guard = {
start = "hello",
nodes = {
hello = { who = "Guard", text = "Nobody passes.",
choices = { { text = "Why not?", next = "why" },
{ text = "Fine." } } },
why = { who = "Guard", text = "The door is locked, and I have no key.",
choices = { { text = "I have a key.", when = 'has("key")', act = { { "setVar", name = "allowed", value = true }, { "addScore", amount = 1 } }, next = "ok" },
{ text = "I see." } } },
ok = { who = "Guard", text = "Then go ahead." }
}
}
},
rules = {
{ note = "A click on the floor walks there",
on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true,
act = { { "walkToPointer", entity = "hero", player = 1 } } },
{ note = "Look at anything",
on = "verb", verb = "look",
act = { { "say", text = '"It is a " .. event.target .. "."', seconds = 1 } } },
{ note = "Take the key",
on = "verb", verb = "take", target = "key",
act = { { "walkToHotspot", entity = "hero", target = "self" },
{ "give", item = "key" },
{ "addScore", amount = 1 },
{ "destroy", entity = "self" } } },
{ note = "Talk to the guard",
on = "verb", verb = "talk", target = "guard",
act = { { "walkToHotspot", entity = "hero", target = "self" },
{ "face", entity = "hero", target = "self" },
{ "talk", dialogue = "guard" } } },
{ note = "Open the door while the guard objects",
on = "verb", verb = "open", target = "door",
when = { { "test", expr = "not allowed" } },
act = { { "say", text = '"The guard shakes his head."', seconds = 1 } } },
{ note = "Open the door: the cut-scene",
on = "verb", verb = "open", target = "door", room = "hall", controls = false,
when = { { "test", expr = "allowed" } },
act = { { "walkToHotspot", entity = "hero", target = "self" },
{ "say", text = '"The door swings open."', seconds = 1 },
{ "fade", seconds = 0.5, out = true },
{ "goTo", room = "yard", entity = "hero", at = "arrival" },
{ "fade", seconds = 0.5, out = false },
{ "addScore", amount = 1 },
{ "setVar", name = "outside", value = true } } },
{ note = "Use the key on the door",
on = "verb", verb = "use", item = "key", target = "door",
act = { { "say", text = '"It is not that kind of lock."', seconds = 1 } } },
{ note = "Anything else",
on = "verb",
act = { { "say", text = '"That does not work."', seconds = 1 } } },
{ note = "Typed: look at the door",
on = "said", verb = "look", noun = "door",
act = { { "say", text = '"A heavy door."', seconds = 1 } } },
{ note = "Typed: a word the game does not know",
on = "said",
when = { { "test", expr = "event.unknown != nil" } },
act = { { "say", text = '"I do not know the word \\"" .. event.unknown .. "\\"."', seconds = 1 } } },
{ note = "Typed: anything else",
on = "said",
act = { { "say", text = '"You cannot do that here."', seconds = 1 } } },
{ note = "The verb keys",
on = "pressed", key = "L",
act = { { "setVerb", verb = "look" } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '(sentence or "") .. " score " .. score' } } }
}
}

View file

@ -0,0 +1,53 @@
-- Genre two of milestone 3: the same adventure in a modelled room from a fixed camera -- Grim
-- Fandango's shape. The rules, the dialogue, the verbs, and the parser are the 2D game's own,
-- taken from its description; only the kinds and the rooms are new. That the rules do not
-- change between a painting and a scene is the point.
local base = dofile("testScripts/author/adventure2d.game")
return {
title = "Author test: adventure in 3D",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 50 }, { kind = "overlay" }, base.layers[2] },
vars = { score = 0, allowed = false },
verbs = base.verbs,
dialogues = base.dialogues,
rules = base.rules,
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 16, h = 0.4, d = 12, r = 80, g = 70, b = 60 }, behaviours = { { kind = "solid" } } },
hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.8, d = 0.6 },
behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 }, { kind = "animator", idle = "Survey", walk = "Walk" } } },
door = { look = { kind = "mesh", shape = "box", w = 1.2, h = 2.4, d = 0.2, r = 90, g = 60, b = 40 },
behaviours = { { kind = "hotspot", name = "door", walkX = 5, walkY = 0, walkZ = -3 } } },
key = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 220, b = 60 },
behaviours = { { kind = "hotspot", name = "key", walkX = -3, walkY = 0, walkZ = 1 } } },
guard = { look = { kind = "mesh", shape = "cylinder", w = 0.7, h = 1.8, r = 80, g = 120, b = 200 },
behaviours = { { kind = "hotspot", name = "guard", walkX = 3.2, walkY = 0, walkZ = -1.5 } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0.5, lookZ = -1 } } },
marker = { look = { kind = "none" } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "hall",
navFrom = { "floor" },
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = -1 },
{ kind = "camera", id = "camera", x = 0, y = 6, z = 8 },
{ kind = "door", id = "door", x = 5, y = 1.2, z = -4.2 },
{ kind = "guard", id = "guard", x = 4, y = 0.9, z = -3 },
{ kind = "key", id = "key", x = -3, y = 0.15, z = 0 },
{ kind = "hero", id = "hero", x = -4, y = 0, z = 3 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} },
{ name = "yard",
navFrom = { "floor" },
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = -1 },
{ kind = "camera", id = "camera", x = 0, y = 6, z = 8 },
{ kind = "door", id = "door", x = -5, y = 1.2, z = -4.2 },
{ kind = "marker", id = "arrival", x = -4, y = 0, z = -2 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
}
}

View file

@ -0,0 +1,55 @@
-- Sports with a ball, from the catalogue: a ball on a body shoved down a lane by a charged
-- press, pins that are bodies, a gutter that is a trigger, and pins counted by how far they fell.
return {
title = "Author test: bowling",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 100, g = 100, b = 110 }, fov = 50 }, { kind = "overlay" } },
vars = { score = 0, down = 0, thrown = false },
kinds = {
lane = { look = { kind = "mesh", shape = "box", w = 3, h = 0.3, d = 24, r = 180, g = 150, b = 100 }, behaviours = { { kind = "solid" } } },
ball = { look = { kind = "mesh", shape = "sphere", w = 0.44, r = 40, g = 40, b = 60 }, behaviours = { { kind = "body", shape = "sphere", mass = 7, friction = 0.2, bounce = 0.1 } } },
pin = { look = { kind = "mesh", shape = "cylinder", w = 0.24, h = 0.8, r = 240, g = 240, b = 240 }, vars = { counted = false },
behaviours = { { kind = "body", mass = 1.5, friction = 0.5 } } },
gutter = { look = { kind = "none", w = 3, h = 1, d = 2 }, behaviours = { { kind = "trigger" } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ball", x = 0, y = 2.5, z = 5, lookY = 0.2, lag = 0.3 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "alley",
entities = {
{ kind = "lane", id = "lane", x = 0, y = -0.15, z = -8 },
{ kind = "ball", id = "ball", x = 0, y = 0.3, z = 2 },
{ kind = "pin", id = "pin1", x = 0, y = 0.4, z = -16 },
{ kind = "pin", id = "pin2", x = -0.4, y = 0.4, z = -16.7 },
{ kind = "pin", id = "pin3", x = 0.4, y = 0.4, z = -16.7 },
{ kind = "pin", id = "pin4", x = -0.8, y = 0.4, z = -17.4 },
{ kind = "pin", id = "pin5", x = 0, y = 0.4, z = -17.4 },
{ kind = "pin", id = "pin6", x = 0.8, y = 0.4, z = -17.4 },
{ kind = "gutter", id = "gutter", x = 0, y = 0, z = -21 },
{ kind = "camera", id = "camera", x = 0, y = 2.5, z = 7 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "The throw",
on = "pressed", key = "SPACE",
when = { { "test", expr = "not thrown" } },
act = { { "setVar", name = "thrown", value = true }, { "push", entity = "ball", x = 0, y = 0, z = -60 } } },
{ note = "A pin has fallen when it leans past the point of no return",
on = "frame", each = "pin",
when = { { "test", expr = "not self.counted and self.y < 0.25" } },
act = { { "setVar", entity = "self", name = "counted", value = true }, { "addVar", name = "down", amount = 1 }, { "addScore", amount = 10 } } },
{ note = "The ball reaches the end",
on = "enter", a = "gutter", b = "ball",
act = { { "setVar", name = "done", value = true } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"pins down " .. down .. " score " .. score' } } }
}
}

View file

@ -0,0 +1,55 @@
-- Arcade physics, from the catalogue: a ball that bounces, bricks that break, a paddle the keys
-- move, and a life lost when the ball is missed. world2d with no gravity.
return {
title = "Author test: breakout",
size = { w = 720, h = 480 },
layers = { { kind = "world2d", gravity = 0 } },
vars = { score = 0, lives = 3, broken = 0 },
kinds = {
wall = { look = { kind = "box", w = 20, h = 480, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } },
roof = { look = { kind = "box", w = 720, h = 20, r = 90, g = 90, b = 110 }, behaviours = { { kind = "solid" } } },
paddle = { look = { kind = "box", w = 100, h = 14, r = 230, g = 230, b = 240 },
behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT" }, { kind = "mover", speed = 400, clamp = true }, { kind = "solid", moving = true } } },
ball = { look = { kind = "box", w = 14, h = 14, r = 255, g = 220, b = 80 },
behaviours = { { kind = "body", shape = "sphere", mass = 1, bounce = 1, friction = 0 } } },
brick = { look = { kind = "box", w = 60, h = 20, r = 220, g = 90, b = 90 }, behaviours = { { kind = "solid" } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "court",
entities = {
{ kind = "wall", id = "wallL", x = 10, y = 240 },
{ kind = "wall", id = "wallR", x = 710, y = 240 },
{ kind = "roof", id = "roof", x = 360, y = 10 },
{ kind = "brick", id = "brick1", x = 200, y = 100 },
{ kind = "brick", id = "brick2", x = 280, y = 100 },
{ kind = "brick", id = "brick3", x = 360, y = 100 },
{ kind = "brick", id = "brick4", x = 440, y = 100 },
{ kind = "brick", id = "brick5", x = 520, y = 100 },
{ kind = "paddle", id = "paddle", x = 360, y = 440 },
{ kind = "ball", id = "ball", x = 360, y = 380 },
{ kind = "readout", id = "readout", x = 30, y = 30 }
} }
},
rules = {
{ note = "Serve",
on = "roomStart",
act = { { "push", entity = "ball", x = 120, y = -380 } } },
{ note = "A brick breaks",
on = "collision", a = "ball", b = "brick",
act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "broken", amount = 1 } } },
{ note = "The ball is missed",
on = "frame",
when = { { "test", expr = "ball.y > 500" } },
act = { { "addVar", name = "lives", amount = -1 }, { "moveTo", entity = "ball", x = 360, y = 380 }, { "push", entity = "ball", x = -100, y = -380 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives' } } }
}
}

View file

@ -0,0 +1,74 @@
-- Genre one of milestone 5: branching full-motion video, Dragon's Lair in a table. Windows on
-- the disc that want a move, the disc sent to a success or a failure clip, lives, credits and a
-- continue, the arcade score panel, and the score sent to the board at the end. The menu video
-- stands in for the disc.
return {
title = "Author test: branching video",
layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" }, { kind = "bezel" } },
vars = { score = 0, lives = 3, credits = 0, taken = 0, missed = 0 },
kinds = {
dirk = { look = { kind = "none" }, behaviours = { { kind = "branching", track = "moves" } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } },
prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } }
},
rooms = {
{ name = "cavern",
entities = {
{ kind = "dirk", id = "dirk", x = 0, y = 0 },
{ kind = "prompt", id = "prompt", x = 250, y = 60 },
{ kind = "readout", id = "readout", x = 12, y = 12 }
},
tracks = {
{ name = "moves", key = "frame",
branches = { { from = 120, to = 160, move = "RIGHT", success = 200, fail = 340 },
{ from = 220, to = 260, move = "UP", success = 300, fail = 340 } } }
} }
},
rules = {
{ note = "A window opens: say what it wants",
on = "branchOpen",
act = { { "setText", entity = "prompt", text = '"MOVE " .. event.move' } } },
{ note = "Made it",
on = "branchTaken",
act = { { "addScore", amount = 500 },
{ "addVar", name = "taken", amount = 1 },
{ "setText", entity = "prompt", text = '""' } } },
{ note = "Missed it: a life gone",
on = "branchMissed",
act = { { "addVar", name = "lives", amount = -1 },
{ "addVar", name = "missed", amount = 1 },
{ "flash", r = 255, g = 40, b = 40, seconds = 0.3 },
{ "setText", entity = "prompt", text = '""' } } },
{ note = "The death clip is over: back to the cavern, or game over",
on = "frameReached", frame = 380,
when = { { "test", expr = "lives > 0" } },
act = { { "discTo", frame = 100 } } },
{ note = "Out of lives",
on = "frameReached", frame = 380,
when = { { "test", expr = "lives <= 0" } },
act = { { "submitScore", board = "default" },
{ "gameOver" } } },
{ note = "A coin",
on = "pressed", switch = "SWITCH_COIN1",
act = { { "credit" } } },
{ note = "Continue",
on = "pressed", switch = "SWITCH_START1",
when = { { "test", expr = "credits > 0 and lives <= 0" } },
act = { { "addVar", name = "credits", amount = -1 },
{ "setVar", name = "lives", value = 3 },
{ "restart" } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " lives " .. lives .. " credits " .. credits' } } }
}
}

View file

@ -0,0 +1,81 @@
-- A first-person shooter, from the catalogue: a character the keys move relative to a camera on
-- its eyes that the mouse turns, a gun that fires from the middle of the picture, and enemies
-- that walk the navigation mesh after the hero and bite when they reach it.
return {
title = "Author test: first person",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 60, g = 60, b = 80 }, fog = { r = 10, g = 10, b = 16, near = 20, far = 50 }, fov = 70 }, { kind = "overlay" } },
vars = { score = 0, health = 5, bites = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 70, g = 70, b = 80 }, behaviours = { { kind = "solid" } } },
wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 40, r = 110, g = 60, b = 60 }, behaviours = { { kind = "solid" } } },
hero = { look = { kind = "none", w = 0.6, h = 1.7, d = 0.6 },
behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" },
{ kind = "character", speed = 4, jump = 5, radius = 0.3, height = 1.7 } } },
eyes = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "first", target = "hero", eye = 1.5 } } },
gun = { look = { kind = "none" }, vars = { ammo = 30 }, behaviours = { { kind = "gun", player = 1, ammo = 30, reload = "key", aim = "centre" } } },
zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 },
vars = { health = 2 },
behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1.5 },
{ kind = "walker", speed = 2.5, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 },
{ kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 },
{ kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } },
{ kind = "timer", name = "bite", after = 1.2, start = false } } },
hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "zombie", every = 2, max = 4, total = 6, points = { { x = -8, y = 0, z = -12 }, { x = 8, y = 0, z = -12 }, { x = 0, y = 0, z = -16 } } } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "arena",
navFrom = { "floor" },
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 },
{ kind = "wall", id = "wallL", x = -12, y = 1.5, z = 0 },
{ kind = "wall", id = "wallR", x = 12, y = 1.5, z = 0 },
{ kind = "hero", id = "hero", x = 0, y = 0.2, z = 8 },
{ kind = "eyes", id = "eyes", x = 0, y = 1.7, z = 8 },
{ kind = "gun", id = "gun1", x = 0, y = 0, z = 0 },
{ kind = "hive", id = "hive", x = 0, y = 0, z = -14 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "A zombie walks from the moment it is made",
on = "spawn", kind = "zombie",
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "A shot lands",
on = "hit", kind = "zombie",
act = { { "damage", entity = "self", amount = 1 }, { "addScore", amount = 50 } } },
{ note = "A headshot",
on = "hit", kind = "zombie",
when = { { "hitPart", part = "head" } },
act = { { "damage", entity = "self", amount = 2 }, { "addScore", amount = 150 } } },
{ note = "It reaches the hero and rears up",
on = "arrived", kind = "zombie",
act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.2 } } },
{ note = "The bite lands unless it was killed",
on = "timer", name = "bite", kind = "zombie",
when = { { "inState", entity = "self", state = "attack" } },
act = { { "addVar", name = "health", amount = -1 }, { "addVar", name = "bites", amount = 1 }, { "flash", r = 255, g = 40, b = 40, seconds = 0.2 },
{ "timerStart", entity = "self", name = "bite", after = 1.2 } } },
{ note = "Reload on R",
on = "pressed", key = "R",
act = { { "reload", entity = "gun1" } } },
{ note = "Out of health",
on = "frame",
when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } },
act = { { "gameOver" } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo .. " zombies " .. count("zombie")' } } }
}
}

View file

@ -0,0 +1,61 @@
-- Genre two of milestone 1: a light gun game over video. Hitboxes keyed to disc frames, a gun
-- that hits or misses, ammo and a reload, a prompt at a frame, and the disc as the picture -- Mad
-- Dog McCree in a table. The boxes follow nothing in the menu video; they are where the test
-- expects them.
return {
title = "Author test: gun over video",
players = 1,
layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 100 }, { kind = "overlay" } },
vars = { score = 0, misses = 0 },
kinds = {
gun = { look = { kind = "none" },
vars = { ammo = 3 },
behaviours = { { kind = "gun", player = 1, ammo = 3, reload = "offscreen" } } },
bandit = { look = { kind = "none" },
vars = { health = 1 },
behaviours = { { kind = "hitbox", track = "bandit" }, { kind = "health", max = 1 } } },
hostage = { look = { kind = "none" },
behaviours = { { kind = "hitbox", track = "hostage" } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "street",
entities = {
{ kind = "gun", id = "gun1", x = 0, y = 0 },
{ kind = "bandit", id = "bandit", x = 0, y = 0 },
{ kind = "hostage", id = "hostage", x = 0, y = 0 },
{ kind = "readout", id = "readout", x = 12, y = 12 }
},
tracks = {
{ name = "bandit", key = "frame", boxes = { { at = 120, x = 100, y = 200, w = 60, h = 100 }, { at = 220, x = 400, y = 200, w = 60, h = 100 } } },
{ name = "hostage", key = "frame", boxes = { { at = 100, x = 560, y = 240, w = 50, h = 80 }, { at = 300, x = 560, y = 240, w = 50, h = 80 } } }
} }
},
rules = {
{ note = "The bandit is shot",
on = "hit", kind = "bandit",
act = { { "addScore", amount = 100 },
{ "damage", entity = "self", amount = 1 } } },
{ note = "The hostage is shot",
on = "hit", kind = "hostage",
act = { { "addScore", amount = -200 },
{ "flash", r = 255, g = 40, b = 40, seconds = 0.3 } } },
{ note = "A miss on the picture",
on = "miss",
when = { { "test", expr = "not event.offscreen" } },
act = { { "addVar", name = "misses", amount = 1 } } },
{ note = "Draw!",
on = "frameReached", frame = 120,
act = { { "say", text = '"DRAW!"', seconds = 1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " ammo " .. gun1.ammo' } } }
}
}

View file

@ -0,0 +1,17 @@
<rml>
<head>
<title>hud</title>
<link type="text/rcss" href="Singe/gui.rcss"/>
<style>
body { pointer-events: none; font-family: FreeSans; font-size: 18px; color: #f0f0f8; }
#score { position: absolute; left: 12px; top: 8px; }
#lives { position: absolute; right: 12px; top: 8px; }
#say { position: absolute; left: 0px; right: 0px; top: 200px; text-align: center; font-size: 28px; color: #ffd84a; }
</style>
</head>
<body>
<div id="score">0</div>
<div id="lives">3</div>
<div id="say"></div>
</body>
</rml>

View file

@ -0,0 +1,48 @@
-- A maze, from the catalogue: corridors that are a walk area, a hero sent by clicks, dots that
-- are eaten on touch, and a ghost that follows the hero across the mesh.
return {
title = "Author test: maze",
size = { w = 720, h = 480 },
layers = { { kind = "overlay" } },
vars = { score = 0, eaten = 0 },
kinds = {
walls = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 1 1 1 1 1 1 1 1 1 1; 1 0 0 0 0 0 0 0 0 0 1; 1 0 1 1 1 0 1 1 1 0 1; 1 0 0 0 0 0 0 0 0 0 1; 1 1 1 1 1 1 1 1 1 1 1" } },
hero = { look = { kind = "box", w = 24, h = 24, r = 255, g = 230, b = 60 }, behaviours = { { kind = "walker", speed = 140, radius = 6 } } },
ghost = { look = { kind = "box", w = 24, h = 24, r = 90, g = 120, b = 255 }, behaviours = { { kind = "walker", speed = 90, radius = 6, follow = "hero", every = 0.3, stopAt = 10 } } },
dot = { look = { kind = "box", w = 8, h = 8, r = 255, g = 255, b = 255 } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "maze",
walk = { { 40, 140, 360, 140, 360, 220, 40, 220 }, { 40, 220, 360, 220, 360, 300, 40, 300 } },
entities = {
{ kind = "walls", id = "walls", x = 8, y = 100 },
{ kind = "dot", id = "dot1", x = 150, y = 180 },
{ kind = "dot", id = "dot2", x = 250, y = 180 },
{ kind = "dot", id = "dot3", x = 250, y = 260 },
{ kind = "hero", id = "hero", x = 60, y = 180 },
{ kind = "ghost", id = "ghost", x = 340, y = 260 },
{ kind = "readout", id = "readout", x = 12, y = 12 }
} }
},
rules = {
{ note = "A click sends the hero",
on = "pressed", switch = "SWITCH_BUTTON3", interrupt = true,
act = { { "walkToPointer", entity = "hero", player = 1 } } },
{ note = "A dot is eaten",
on = "collision", a = "hero", b = "dot",
act = { { "destroy", entity = "other" }, { "addScore", amount = 10 }, { "addVar", name = "eaten", amount = 1 } } },
{ note = "Caught",
on = "collision", a = "ghost", b = "hero",
act = { { "setVar", name = "caught", value = true } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " eaten " .. eaten' } } }
}
}

View file

@ -0,0 +1,43 @@
-- A painted room in 3D, the shape Grim Fandango has: the backdrop is a picture on a flat mesh, a
-- pillar that is only in the picture is an occluder -- an invisible box that hides what walks
-- behind it -- and a character crosses the room behind it and comes out the other side.
return {
title = "Author test: painted room",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 120, g = 120, b = 130 }, sun = false, fov = 50 }, { kind = "overlay" } },
vars = { score = 0 },
kinds = {
painting = { look = { kind = "mesh", shape = "box", w = 16, h = 9, d = 0.2, r = 255, g = 255, b = 255, texture = "testScripts/hills.png", unlit = true } },
pillar = { look = { kind = "mesh", shape = "box", w = 1.6, h = 5, d = 1.6, occluder = true } },
floor = { look = { kind = "mesh", shape = "box", w = 20, h = 0.2, d = 12, r = 90, g = 90, b = 90 }, behaviours = { { kind = "solid" } } },
hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.012, clip = "Walk", w = 0.8, h = 1, d = 1.5 },
behaviours = { { kind = "seek", target = "far", speed = 2.2, stopAt = 0.3 } } },
marker = { look = { kind = "none" } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1.2, lookZ = -4 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "hall",
entities = {
{ kind = "painting", id = "painting", x = 0, y = 3, z = -9 },
{ kind = "floor", id = "floor", x = 0, y = -0.1, z = -4 },
{ kind = "pillar", id = "pillar", x = 0, y = 2.5, z = -4 },
{ kind = "hero", id = "hero", x = -5, y = 0, z = -6 },
{ kind = "marker", id = "far", x = 5, y = 0, z = -6, ry = 90, scale = 2 },
{ kind = "camera", id = "camera", x = 0, y = 2.2, z = 5 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "Across the room",
on = "arrived", kind = "hero",
act = { { "setVar", name = "across", value = true } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"the hero is at " .. floor(hero.x * 10) / 10' } } }
}
}

View file

@ -1,52 +1,62 @@
-- A game description: data, not code. Forge/AuthorCompile.singe turns it into a Singe game. -- A game description: data, not code. Forge/AuthorCompile.singe turns it into a Singe game.
-- --
-- Genre one of two. This one uses a single world2d layer and shares nothing with the QTE beside -- Genre one of the two the first slice was built on. A single world2d room, a character that runs
-- it -- no disc, no branching, no time windows -- which is the point: if the core can express -- and jumps, a prize, and a readout. It shares nothing with the QTE beside it -- no disc, no
-- both, it is not secretly a platformer engine or secretly a laserdisc engine. -- branching, no time windows -- which is the point: if the core can express both, it is not
-- secretly a platformer engine or secretly a laserdisc engine.
return { return {
title = "Author test: platformer", title = "Author test: platformer",
layers = { { kind = "world2d", gravity = 1500 } },
vars = { score = 0 },
layers = { kinds = {
{ kind = "world2d", gravity = 1500 } ground = { look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } },
ledge = { look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 }, behaviours = { { kind = "solid" } } },
prize = { look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 }, behaviours = { { kind = "drift", vx = 0, vy = 0 } } },
hero = { look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 }, behaviours = { { kind = "platformer", speed = 210, jump = 620 } } },
readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
}, },
rooms = {
{ name = "field",
entities = { entities = {
{ id = "ground", x = 360, y = 440, look = { kind = "box", w = 720, h = 40, r = 60, g = 70, b = 90 }, { kind = "ground", id = "ground", x = 360, y = 440 },
behaviours = { { kind = "solid" } } }, { kind = "ledge", id = "ledge", x = 520, y = 330 },
{ id = "ledge", x = 520, y = 330, look = { kind = "box", w = 180, h = 20, r = 60, g = 70, b = 90 }, { kind = "prize", id = "prize", x = 520, y = 300 },
behaviours = { { kind = "solid" } } }, { kind = "hero", id = "hero", x = 120, y = 380 },
{ id = "prize", x = 520, y = 300, look = { kind = "box", w = 18, h = 18, r = 255, g = 200, b = 60 }, { kind = "readout", id = "readout", x = 12, y = 12 }
behaviours = { { kind = "drift", vx = 0, vy = 0 } } }, } }
{ id = "hero", x = 120, y = 380, look = { kind = "box", w = 24, h = 44, r = 230, g = 90, b = 170 },
behaviours = { { kind = "platformer", speed = 210, jump = 620 } } },
{ id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
}, },
rules = { rules = {
{ note = "Run left", { note = "Run left",
on = "frame",
when = { { "keyHeld", key = "LEFT" } }, when = { { "keyHeld", key = "LEFT" } },
act = { { "run", entity = "hero", direction = -1 } } }, act = { { "run", entity = "hero", direction = -1 } } },
{ note = "Run right", { note = "Run right",
on = "frame",
when = { { "keyHeld", key = "RIGHT" } }, when = { { "keyHeld", key = "RIGHT" } },
act = { { "run", entity = "hero", direction = 1 } } }, act = { { "run", entity = "hero", direction = 1 } } },
{ note = "Jump, but only with ground underfoot", { note = "Jump, but only with ground underfoot",
on = "frame",
when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } }, when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } },
act = { { "jump", entity = "hero" } } }, act = { { "jump", entity = "hero" } } },
{ note = "Take the prize once", { note = "Take the prize",
when = { { "touching", entity = "hero", other = "prize" }, { "once", tag = "prize" } }, on = "collision", a = "hero", b = "prize",
act = { { "addScore", amount = 100 }, act = { { "addScore", amount = 100 },
{ "show", entity = "prize", visible = false }, { "destroy", entity = "other" },
{ "setFlag", flag = "won", value = true } } }, { "setVar", name = "won", value = true } } },
{ note = "Fallen off the world: back to the start", { note = "Fallen off the world: back to the start",
on = "frame",
when = { { "below", entity = "hero", y = 520 } }, when = { { "below", entity = "hero", y = 520 } },
act = { { "moveTo", entity = "hero", x = 120, y = 380 } } }, act = { { "moveTo", entity = "hero", x = 120, y = 380 } } },
{ note = "The readout, every frame", { note = "The readout, every frame",
when = {}, on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } } act = { { "setText", entity = "readout", text = '"score " .. score' } } }
} }
} }

View file

@ -0,0 +1,65 @@
-- Genre two of milestone 2: a 3D platformer. A character on the engine's controller, a camera
-- that follows, floors and ledges, a prize that is a trigger, and a fall that puts the hero back.
-- Nothing in it is shared with the rail shooter but the scene itself.
return {
title = "Author test: 3D platformer",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 55 }, { kind = "overlay" } },
vars = { score = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 12, r = 90, g = 110, b = 90 }, behaviours = { { kind = "solid" } } },
ledge = { look = { kind = "mesh", shape = "box", w = 4, h = 0.4, d = 4, r = 110, g = 90, b = 70 }, behaviours = { { kind = "solid" } } },
prize = { look = { kind = "mesh", shape = "sphere", w = 0.6, r = 255, g = 200, b = 60 }, behaviours = { { kind = "trigger" } } },
hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.9, d = 0.6 },
behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" },
{ kind = "character", speed = 4, jump = 6.5, radius = 0.3, height = 0.9 },
{ kind = "animator", idle = "Survey", walk = "Walk" } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "hero", x = 0, y = 3, z = 7, lookY = 0.6, lag = 0.2 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "field",
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 },
{ kind = "ledge", id = "ledge", x = 6, y = 1.2, z = 0 },
{ kind = "prize", id = "prize", x = 6, y = 2.0, z = 0 },
{ kind = "hero", id = "hero", x = -4, y = 0.5, z = 0 },
{ kind = "camera", id = "camera", x = -4, y = 3, z = 7 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "Walking sets the state the animator plays",
on = "frame", each = "hero",
when = { { "test", expr = "self.dx != 0 or self.dy != 0" } },
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "Standing still",
on = "frame", each = "hero",
when = { { "test", expr = "self.dx == 0 and self.dy == 0" } },
act = { { "setState", entity = "self", state = "idle" } } },
{ note = "Jump, with ground underfoot",
on = "frame",
when = { { "keyHeld", key = "SPACE" }, { "onGround", entity = "hero" } },
act = { { "jump", entity = "hero" } } },
{ note = "The prize is taken",
on = "enter", a = "prize", b = "hero",
act = { { "destroy", entity = "self" },
{ "addScore", amount = 100 },
{ "setVar", name = "won", value = true } } },
{ note = "Fallen off the world: back to the start",
on = "frame",
when = { { "test", expr = "hero.y < -5" } },
act = { { "moveTo", entity = "hero", x = -4, y = 0.5, z = 0 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score' } } }
}
}

View file

@ -0,0 +1,42 @@
-- The long tail, in 3D: water a body floats in, a boat pushed by its propeller, and a soft
-- body -- the pieces a boat game or a physics toy needs.
return {
title = "Author test: the pool",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 90, g = 100, b = 120 }, fov = 55 }, { kind = "overlay" } },
vars = { score = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 90, g = 90, b = 80 }, behaviours = { { kind = "solid" } } },
pool = { look = { kind = "mesh", shape = "box", w = 20, h = 3, d = 20, r = 60, g = 120, b = 200 }, behaviours = { { kind = "water", density = 1 } } },
ball = { look = { kind = "mesh", shape = "sphere", w = 0.8, r = 220, g = 80, b = 80 }, behaviours = { { kind = "body", shape = "sphere", mass = 2, buoyancy = 2 } } },
boat = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.4, d = 2.6, r = 200, g = 170, b = 90 },
behaviours = { { kind = "body", mass = 120, buoyancy = 3 }, { kind = "vehicle", type = "boat", mass = 120, thrust = 600 } } },
balloon = { look = { kind = "mesh", shape = "sphere", w = 1, r = 240, g = 220, b = 80 }, behaviours = { { kind = "soft", type = "body", pressure = 2, mass = 0.6 } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 1, lookZ = 0 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "pool",
entities = {
{ kind = "floor", id = "floor", x = 0, y = -3.2, z = 0 },
{ kind = "pool", id = "pool", x = 0, y = -1.5, z = 0 },
{ kind = "ball", id = "ball", x = 3, y = 3, z = 2 },
{ kind = "boat", id = "boat", x = -4, y = 0.4, z = 6 },
{ kind = "balloon", id = "balloon", x = 6, y = 4, z = -6 },
{ kind = "camera", id = "camera", x = 0, y = 10, z = 16 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "The boat runs its propeller",
on = "frame", each = "boat",
act = { { "setVar", entity = "self", name = "dy", value = -1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"ball y " .. floor(ball.y * 10) / 10 .. " boat z " .. floor(boat.z * 10) / 10' } } }
}
}

View file

@ -1,37 +1,47 @@
-- Genre two of two: a quick-time event over video. No physics, no character, no gravity -- the -- Genre two of the first slice: a quick-time event over video. No physics, no character, no
-- only layers are the disc and a flat overlay, and the rules talk about disc frames and windows. -- gravity -- the only layers are the disc and a flat overlay, and the rules talk about disc frames
-- and windows.
return { return {
title = "Author test: QTE over video", title = "Author test: QTE over video",
layers = { { kind = "disc", file = "Singe/menuBackground.mkv", start = 180 }, { kind = "overlay" } },
vars = { score = 0 },
layers = { kinds = {
{ kind = "disc", start = 180 }, prompt = { look = { kind = "text", text = "", r = 255, g = 220, b = 80 } },
{ kind = "overlay" } verdict = { look = { kind = "text", text = "", r = 160, g = 255, b = 180 } },
readout = { look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } }
}, },
rooms = {
{ name = "scene",
entities = { entities = {
{ id = "prompt", x = 250, y = 60, look = { kind = "text", text = "", r = 255, g = 220, b = 80 } }, { kind = "prompt", id = "prompt", x = 250, y = 60 },
{ id = "verdict", x = 250, y = 100, look = { kind = "text", text = "", r = 160, g = 255, b = 180 } }, { kind = "verdict", id = "verdict", x = 250, y = 100 },
{ id = "readout", x = 12, y = 12, look = { kind = "text", text = "score 0", r = 235, g = 235, b = 245 } } { kind = "readout", id = "readout", x = 12, y = 12 }
} }
}, },
rules = { rules = {
{ note = "The window is open: ask for the button", { note = "The window is open: ask for the button",
on = "frame",
when = { { "discBetween", from = 200, to = 260 } }, when = { { "discBetween", from = 200, to = 260 } },
act = { { "setText", entity = "prompt", text = '"PRESS!"' } } }, act = { { "setText", entity = "prompt", text = '"PRESS!"' } } },
{ note = "Answered in time, once", { note = "Answered in time, once",
on = "frame",
when = { { "discBetween", from = 200, to = 260 }, { "switchHeld", switch = "SWITCH_BUTTON1" }, { "once", tag = "hit" } }, when = { { "discBetween", from = 200, to = 260 }, { "switchHeld", switch = "SWITCH_BUTTON1" }, { "once", tag = "hit" } },
act = { { "setFlag", flag = "hit", value = true }, act = { { "setVar", name = "hit", value = true },
{ "addScore", amount = 250 }, { "addScore", amount = 250 },
{ "setText", entity = "verdict", text = '"HIT"' } } }, { "setText", entity = "verdict", text = '"HIT"' } } },
{ note = "The window has closed without an answer", { note = "The window has closed without an answer",
when = { { "discBetween", from = 260, to = 320 }, { "once", tag = "judged" } }, on = "frameReached", frame = 260,
when = { { "test", expr = "not hit" } },
act = { { "setText", entity = "prompt", text = '""' }, act = { { "setText", entity = "prompt", text = '""' },
{ "lua", code = 'if not AUTHOR_FLAG["hit"] then authorSetText("verdict", "MISS") end' } } }, { "setText", entity = "verdict", text = '"MISS"' } } },
{ note = "The readout, every frame", { note = "The readout, every frame",
when = {}, on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. AUTHOR_SCORE' } } } act = { { "setText", entity = "readout", text = '"score " .. score' } } }
} }
} }

View file

@ -0,0 +1,66 @@
-- Genre one of milestone 4: racing. A car on the engine's vehicle physics driven by the keys,
-- a rival that drives a track of points on its own, checkpoints that are triggers, laps counted
-- by rules, and a camera that follows.
return {
title = "Author test: racing",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 90, g = 90, b = 110 }, fov = 60 }, { kind = "overlay" } },
vars = { score = 0, laps = 0, next = 1 },
kinds = {
ground = { look = { kind = "mesh", shape = "box", w = 120, h = 0.4, d = 200, r = 90, g = 100, b = 90 }, behaviours = { { kind = "solid" } } },
car = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 220, g = 60, b = 60 },
behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN" },
{ kind = "vehicle", type = "car", torque = 900, mass = 800 } } },
rival = { look = { kind = "mesh", shape = "box", w = 1.6, h = 0.6, d = 3.2, r = 60, g = 90, b = 220 },
behaviours = { { kind = "vehicle", type = "car", torque = 700, mass = 800 },
{ kind = "racer", track = "lap", throttle = 0.5, reach = 4 } } },
gate = { look = { kind = "none", w = 10, h = 3, d = 1 }, vars = { index = 1 }, behaviours = { { kind = "trigger" } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "car", x = 0, y = 4, z = 9, lookY = 0.5, lag = 0.3 } } },
bar = { look = { kind = "mesh", shape = "box", w = 4, h = 0.3, d = 0.3, r = 200, g = 200, b = 60 },
behaviours = { { kind = "body", mass = 20 }, { kind = "joint", type = "hinge", ax = -8, ay = 2, az = -20, dx = 0, dy = 0, dz = 1 } } },
hover = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2, r = 60, g = 220, b = 200 },
behaviours = { { kind = "body", mass = 100, friction = 0 }, { kind = "thrust", force = 900, turn = 0.5 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "track",
entities = {
{ kind = "ground", id = "ground", x = 0, y = -0.2, z = -40 },
{ kind = "car", id = "car", x = 0, y = 0.8, z = 10 },
{ kind = "rival", id = "rival", x = 4, y = 0.8, z = 12 },
{ kind = "gate", id = "gate1", x = 0, y = 1.5, z = -10, vars = { index = 1 } },
{ kind = "gate", id = "gate2", x = 0, y = 1.5, z = -35, vars = { index = 2 } },
{ kind = "camera", id = "camera", x = 0, y = 4, z = 19 },
{ kind = "bar", id = "bar", x = -6, y = 2, z = -20 },
{ kind = "hover", id = "hover", x = 8, y = 0.4, z = 10 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
},
tracks = {
{ name = "lap", key = "time", points = { { at = 0, x = 4, y = 0, z = -10 }, { at = 1, x = 4, y = 0, z = -35 }, { at = 2, x = 4, y = 0, z = -70 } } }
} }
},
rules = {
{ note = "Through the next gate",
on = "enter", a = "gate", b = "car",
when = { { "test", expr = "self.index == next" } },
act = { { "addVar", name = "next", amount = 1 },
{ "addScore", amount = 10 } } },
{ note = "Both gates passed: a lap",
on = "frame",
when = { { "test", expr = "next > 2" } },
act = { { "addVar", name = "laps", amount = 1 },
{ "setVar", name = "next", value = 1 } } },
{ note = "The hovercraft pushes forward on its own",
on = "frame", each = "hover",
act = { { "setVar", entity = "self", name = "dy", value = -1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"speed " .. floor(car.speed) .. " next gate " .. next .. " laps " .. laps' } } }
}
}

View file

@ -0,0 +1,116 @@
-- Genre one of milestone 2: a rail shooter, House of the Dead in a table. A camera on a rail
-- that stops at encounters, zombies (the Fox, standing in) that spawn at each stop, walk to the
-- camera, and bite on a timer unless they are killed, a gun that hits what the pointer is over --
-- and which part -- ragdolls, and a level end.
return {
title = "Author test: rail shooter",
players = 1,
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 70, g = 70, b = 90 }, fog = { r = 12, g = 12, b = 22, near = 25, far = 60 }, fov = 60 },
{ kind = "overlay" } },
vars = { score = 0, misses = 0, health = 3, bites = 0, stops = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.2, d = 100, r = 70, g = 75, b = 90 }, behaviours = { { kind = "solid" } } },
wall = { look = { kind = "mesh", shape = "box", w = 1, h = 3, d = 12, r = 120, g = 60, b = 60 }, behaviours = { { kind = "solid" } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "rail", track = "rail" } } },
gun = { look = { kind = "none" }, vars = { ammo = 6 }, behaviours = { { kind = "gun", player = 1, ammo = 6, reload = "offscreen" } } },
zombie = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 },
vars = { health = 2 },
behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 2 },
{ kind = "seek", target = "camera", speed = 1.6, stopAt = 2.2 },
{ kind = "animator", idle = "Survey", walk = "Walk", attack = "Run", fade = 0.2 },
{ kind = "target", zones = { { name = "head", bone = "b_Head_05", w = 0.5, h = 0.5, d = 0.5 } } },
{ kind = "timer", name = "bite", after = 1.5, start = false } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "street",
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.1, z = -30 },
{ kind = "wall", id = "wallL", x = -6, y = 1.5, z = -12 },
{ kind = "wall", id = "wallR", x = 6, y = 1.5, z = -12 },
{ kind = "camera", id = "camera", x = 0, y = 1.6, z = 0 },
{ kind = "gun", id = "gun1", x = 0, y = 0, z = 0 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
},
tracks = {
{ name = "rail", key = "time",
points = { { at = 0, x = 0, y = 1.6, z = 0, look = { 0, 1.2, -10 } },
{ at = 4, x = 0, y = 1.6, z = -6, look = { 0, 1.2, -16 }, stop = "hall" },
{ at = 10, x = 2, y = 1.6, z = -16, look = { 2, 1.2, -26 }, stop = "yard" },
{ at = 14, x = 2, y = 1.6, z = -22, look = { 2, 1.2, -32 } } } }
} }
},
rules = {
{ note = "The hall: three come out of the dark",
on = "stopped", name = "hall",
act = { { "addVar", name = "stops", amount = 1 },
{ "spawn", kind = "zombie", x = -2, y = 0, z = -14 },
{ "spawn", kind = "zombie", x = 2, y = 0, z = -15 },
{ "spawn", kind = "zombie", x = 0, y = 0, z = -17 } } },
{ note = "The yard: four more",
on = "stopped", name = "yard",
act = { { "addVar", name = "stops", amount = 1 },
{ "spawn", kind = "zombie", x = -1, y = 0, z = -24 },
{ "spawn", kind = "zombie", x = 4, y = 0, z = -25 },
{ "spawn", kind = "zombie", x = 2, y = 0, z = -27 },
{ "spawn", kind = "zombie", x = 0, y = 0, z = -28 } } },
{ note = "A zombie starts walking the moment it is made",
on = "spawn", kind = "zombie",
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "A shot lands",
on = "hit", kind = "zombie",
act = { { "damage", entity = "self", amount = 1 },
{ "addScore", amount = 100 } } },
{ note = "A headshot lands harder",
on = "hit", kind = "zombie",
when = { { "hitPart", part = "head" } },
act = { { "damage", entity = "self", amount = 2 },
{ "addScore", amount = 300 } } },
{ note = "It reaches the camera and rears up",
on = "arrived", kind = "zombie",
act = { { "setState", entity = "self", state = "attack" },
{ "timerStart", entity = "self", name = "bite", after = 1.5 } } },
{ note = "The bite lands unless it was killed in time",
on = "timer", name = "bite", kind = "zombie",
when = { { "inState", entity = "self", state = "attack" } },
act = { { "addVar", name = "health", amount = -1 },
{ "addVar", name = "bites", amount = 1 },
{ "flash", r = 255, g = 40, b = 40, seconds = 0.3 },
{ "shake", amount = 0.3, seconds = 0.4 },
{ "timerStart", entity = "self", name = "bite", after = 1.5 } } },
{ note = "A miss on the picture",
on = "miss",
when = { { "test", expr = "not event.offscreen" } },
act = { { "addVar", name = "misses", amount = 1 } } },
{ note = "The stop is clear: move on",
on = "frame",
when = { { "waiting", entity = "camera" }, { "test", expr = 'count("zombie") == 0' } },
act = { { "pathNext", entity = "camera" } } },
{ note = "Out of health",
on = "frame",
when = { { "test", expr = "health <= 0" }, { "once", tag = "over", scope = "game" } },
act = { { "say", text = '"YOU ARE DEAD"', seconds = 2 }, { "gameOver" } } },
{ note = "The end of the rail",
on = "railEnd",
act = { { "setVar", name = "done", value = true },
{ "say", text = '"STAGE CLEAR"', seconds = 2 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health .. " ammo " .. gun1.ammo' } } }
}
}

View file

@ -0,0 +1,50 @@
-- A rhythm game, from the catalogue: a track plays, notes are windows in time, and a press inside
-- one scores while one outside is a miss. The notes are a track of points keyed by time, so the
-- editor's timeline lays them out.
return {
title = "Author test: rhythm",
size = { w = 720, h = 480 },
layers = { { kind = "overlay" }, { kind = "music", file = "testScripts/tune.mp3", volume = 60 } },
vars = { score = 0, hits = 0, misses = 0, early = 0 },
kinds = {
lane = { look = { kind = "box", w = 60, h = 400, r = 40, g = 40, b = 60 } },
marker = { look = { kind = "box", w = 80, h = 6, r = 200, g = 200, b = 220 } },
note = { look = { kind = "box", w = 50, h = 16, r = 255, g = 200, b = 60 },
behaviours = { { kind = "drift", vx = 0, vy = 200 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "stage",
entities = {
{ kind = "lane", id = "lane", x = 360, y = 240 },
{ kind = "marker", id = "marker", x = 360, y = 400 },
{ kind = "readout", id = "readout", x = 12, y = 12 }
},
tracks = {
{ name = "notes", key = "time", points = { { at = 1.0 }, { at = 2.0 }, { at = 2.5 }, { at = 3.5 } } }
} }
},
rules = {
{ note = "A note falls two seconds before its time",
on = "frame", each = "lane",
when = { { "every", seconds = 0.5, tag = "spawn" }, { "test", expr = "time < 2" } },
act = { { "spawn", kind = "note", x = 360, y = 0 } } },
{ note = "A press on a note at the marker",
on = "pressed", key = "SPACE", each = "note",
when = { { "test", expr = "abs(self.y - 400) < 24" } },
act = { { "destroy", entity = "self" }, { "addScore", amount = 100 }, { "addVar", name = "hits", amount = 1 } } },
{ note = "A note that falls past the marker is a miss",
on = "frame", each = "note",
when = { { "test", expr = "self.y > 440" } },
act = { { "destroy", entity = "self" }, { "addVar", name = "misses", amount = 1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " hits " .. hits .. " misses " .. misses' } } }
}
}

View file

@ -0,0 +1,77 @@
-- Genre one of milestone 1: a shoot-em-up. Kinds spawned by the dozen, bullets that collide,
-- health, a spawner, a HUD document, sound, and a game over -- none of which the first slice could
-- say. Everything is a box, so the description needs no art.
return {
title = "Author test: shoot-em-up",
layers = { { kind = "overlay" }, { kind = "hud", document = "testScripts/author/hud.rml", bind = { score = "score", lives = "lives" } } },
vars = { score = 0, lives = 3 },
kinds = {
ship = { look = { kind = "box", w = 28, h = 20, r = 120, g = 220, b = 255 },
vars = { health = 1 },
behaviours = { { kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" },
{ kind = "mover", speed = 260, clamp = true },
{ kind = "shooter", spawns = "shot", rate = 0.15, offsetY = -14 },
{ kind = "health", max = 1, keep = true },
{ kind = "sound", hit = "testScripts/crackle.wav" } } },
shot = { look = { kind = "box", w = 4, h = 10, r = 255, g = 255, b = 160 },
behaviours = { { kind = "projectile", vx = 0, vy = -520, life = 3 } } },
raider = { look = { kind = "box", w = 26, h = 18, r = 240, g = 80, b = 80 },
vars = { health = 2 },
behaviours = { { kind = "drift", vx = 0, vy = 90 },
{ kind = "health", max = 2 },
{ kind = "sound", death = "testScripts/crackle.wav" } } },
hive = { look = { kind = "none" },
behaviours = { { kind = "spawner", spawns = "raider", every = 0.6, max = 6, total = 12,
points = { { x = 100, y = -20 }, { x = 300, y = -20 }, { x = 500, y = -20 }, { x = 640, y = -20 } } } } }
},
rooms = {
{ name = "space",
entities = {
{ kind = "ship", id = "ship", x = 360, y = 420 },
{ kind = "hive", id = "hive", x = 360, y = -20 }
} }
},
rules = {
{ note = "A shot lands on a raider",
on = "collision", a = "shot", b = "raider",
act = { { "destroy", entity = "self" },
{ "damage", entity = "other", amount = 1 },
{ "addScore", amount = 10 } } },
{ note = "A raider dies",
on = "death", kind = "raider",
act = { { "emit", entity = "self", count = 24, r = 255, g = 120, b = 60 },
{ "addScore", amount = 50 } } },
{ note = "A raider rams the ship",
on = "collision", a = "raider", b = "ship",
act = { { "destroy", entity = "self" },
{ "damage", entity = "other", amount = 1 } } },
{ note = "The ship is lost",
on = "death", kind = "ship",
act = { { "addVar", name = "lives", amount = -1 },
{ "flash", r = 255, g = 60, b = 60, seconds = 0.3 },
{ "setVar", entity = "self", name = "health", value = 1 },
{ "moveTo", entity = "self", x = 360, y = 420 } } },
{ note = "Out of lives",
on = "frame",
when = { { "test", expr = "lives <= 0" } },
act = { { "gameOver" } } },
{ note = "A raider slips past the bottom",
on = "frame", each = "raider",
when = { { "test", expr = "self.y > 500" } },
act = { { "destroy", entity = "self" } } },
{ note = "The wave is done",
on = "frame",
when = { { "test", expr = 'count("raider") == 0 and hive.made >= 12' }, { "once", tag = "wave", scope = "game" } },
act = { { "setVar", name = "cleared", value = true },
{ "say", text = '"WAVE CLEAR"', seconds = 2 } } }
}
}

View file

@ -0,0 +1,48 @@
-- A space shooter, from the catalogue: a ship on a body pushed by thrust and turned by the keys,
-- bolts it fires, rocks that come at it, and a camera behind. No gravity.
return {
title = "Author test: space",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", gravity = 0, ambient = { r = 40, g = 40, b = 70 }, fov = 65 }, { kind = "overlay" } },
vars = { score = 0, rocks = 0 },
kinds = {
ship = { look = { kind = "mesh", shape = "box", w = 1.2, h = 0.4, d = 2.4, r = 200, g = 210, b = 240 },
behaviours = { { kind = "body", mass = 50, friction = 0 },
{ kind = "thrust", force = 400, turn = 1.2 },
{ kind = "keys", player = 1, left = "LEFT", right = "RIGHT", up = "UP", down = "DOWN", fire = "SPACE" },
{ kind = "shooter", spawns = "bolt", rate = 0.2, offsetY = 0 } } },
bolt = { look = { kind = "mesh", shape = "sphere", w = 0.3, r = 255, g = 240, b = 120 },
behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = -30, life = 3 } } },
rock = { look = { kind = "mesh", shape = "sphere", w = 2, r = 130, g = 110, b = 90 },
vars = { health = 1 },
behaviours = { { kind = "projectile", vx = 0, vy = 0, vz = 6, life = 20 }, { kind = "health", max = 1 } } },
belt = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "rock", every = 0.8, max = 8, total = 12, points = { { x = -4, y = 0, z = -60 }, { x = 0, y = 0, z = -60 }, { x = 4, y = 0, z = -60 }, { x = 2, y = 1, z = -60 } } } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "follow", target = "ship", x = 0, y = 3, z = 8, lookY = 0, lag = 0.2 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "belt",
entities = {
{ kind = "ship", id = "ship", x = 0, y = 0, z = 0 },
{ kind = "belt", id = "belt", x = 0, y = 0, z = -60 },
{ kind = "camera", id = "camera", x = 0, y = 3, z = 8 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "A bolt meets a rock",
on = "collision", a = "bolt", b = "rock",
act = { { "destroy", entity = "self" }, { "damage", entity = "other", amount = 1 } } },
{ note = "A rock is broken",
on = "death", kind = "rock",
act = { { "addScore", amount = 25 }, { "addVar", name = "rocks", amount = 1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " rocks " .. rocks' } } }
}
}

View file

@ -0,0 +1,73 @@
-- The long tail, in 2D: a sprite sheet stepped by state, a map of tiles, and MIDI notes as an
-- event -- the pieces a rhythm game or a maze game needs.
return {
title = "Author test: the long tail",
size = { w = 720, h = 480 },
layers = { { kind = "overlay" } },
vars = { score = 0, notes = 0 },
kinds = {
runner = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" },
behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5, walkLeft=1-2" },
{ kind = "keys", player = 1, left = "LEFT", right = "RIGHT" },
{ kind = "mover", speed = 120, clamp = true } } },
-- The same sheet, drawn facing right only: facing left mirrors it, and rz turns a frame.
mirrored = { look = { kind = "sprite", file = "testScripts/strip.png", frames = 5, anchor = "feet" },
behaviours = { { kind = "frames", fps = 10, states = "idle=1-1, walk=3-5" },
{ kind = "keys", player = 1, left = "LEFT", right = "RIGHT" },
{ kind = "mover", speed = 120, clamp = true },
{ kind = "sound", hit = "testScripts/crackle.wav", volume = 30 } } },
floor = { look = { kind = "grid", file = "testScripts/checker.png", tile = 32, columns = 2, map = "1 2 1 2 1; 2 1 2 1 2; 3 4 3 4 3" } },
spinner = { look = { kind = "sprite", file = "testScripts/checker.png" }, behaviours = { { kind = "spin", rate = 90 } } },
fire = { look = { kind = "particles", r = 255, g = 140, b = 40, rate = 60, size = 10, speed = 80, life = 0.8 } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "stage",
entities = {
{ kind = "floor", id = "floor", x = 200, y = 300 },
{ kind = "runner", id = "runner", x = 360, y = 300 },
{ kind = "mirrored", id = "mirror", x = 360, y = 420 },
{ kind = "mirrored", id = "tilted", x = 120, y = 200, rz = 30 },
{ kind = "spinner", id = "spinner", x = 560, y = 200, rz = 45 },
{ kind = "fire", id = "fire", x = 640, y = 400 },
{ kind = "readout", id = "readout", x = 12, y = 12 }
} }
},
rules = {
{ note = "Moving means walking",
on = "frame", each = "runner",
when = { { "test", expr = "self.dx != 0" } },
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "Still means idle",
on = "frame", each = "runner",
when = { { "test", expr = "self.dx == 0" } },
act = { { "setState", entity = "self", state = "idle" } } },
{ note = "The mirrored one walks the same",
on = "frame", each = "mirrored",
when = { { "test", expr = "self.dx != 0" } },
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "Middle C is a quiet click as well",
on = "midi", pitch = 60,
act = { { "playSound", file = "testScripts/crackle.wav", volume = 40 } } },
{ note = "Middle C scores",
on = "midi", pitch = 60,
act = { { "addScore", amount = 10 },
{ "addVar", name = "notes", amount = 1 } } },
{ note = "Any other note is counted",
on = "midi",
when = { { "test", expr = "event.pitch != 60" } },
act = { { "addVar", name = "notes", amount = 1 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"frame " .. runner.frame .. " " .. runner.state .. " score " .. score .. " notes " .. notes' } } }
}
}

View file

@ -0,0 +1,76 @@
-- Third-person action, from the catalogue: a character the keys move relative to a camera that
-- orbits it under the mouse, enemies that walk the mesh after it and bite, and a swing of the
-- sword on a key at whatever is in reach.
return {
title = "Author test: third person",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 80, g = 80, b = 100 }, fov = 60 }, { kind = "overlay" } },
vars = { score = 0, health = 5, swings = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 40, h = 0.4, d = 40, r = 80, g = 100, b = 80 }, behaviours = { { kind = "solid" } } },
hero = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.008, w = 0.6, h = 0.9, d = 0.6 },
behaviours = { { kind = "keys", player = 1, left = "A", right = "D", up = "W", down = "S" },
{ kind = "character", speed = 4, jump = 5, radius = 0.3, height = 0.9 },
{ kind = "animator", idle = "Survey", walk = "Walk" } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "orbit", target = "hero", y = 1.2, z = 6, lookY = 0.6 } } },
enemy = { look = { kind = "model", file = "testScripts/Models/Fox.glb", scale = 0.01, w = 0.8, h = 0.9, d = 1.4 },
vars = { health = 2 },
behaviours = { { kind = "health", max = 2, ragdoll = true, linger = 1 },
{ kind = "walker", speed = 2, radius = 0.4, follow = "hero", every = 0.4, stopAt = 1.6 },
{ kind = "animator", idle = "Survey", walk = "Walk", attack = "Run" },
{ kind = "timer", name = "bite", after = 1.5, start = false } } },
hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "enemy", every = 1.5, max = 3, total = 4, points = { { x = -8, y = 0, z = -8 }, { x = 8, y = 0, z = -8 } } } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "field",
navFrom = { "floor" },
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 },
{ kind = "hero", id = "hero", x = 0, y = 0.2, z = 4 },
{ kind = "camera", id = "camera", x = 0, y = 2, z = 10 },
{ kind = "hive", id = "hive", x = 0, y = 0, z = -8 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "An enemy walks from the moment it is made",
on = "spawn", kind = "enemy",
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "Moving means walking",
on = "frame", each = "hero",
when = { { "test", expr = "self.dx != 0 or self.dy != 0" } },
act = { { "setState", entity = "self", state = "walk" } } },
{ note = "Still means idle",
on = "frame", each = "hero",
when = { { "test", expr = "self.dx == 0 and self.dy == 0" } },
act = { { "setState", entity = "self", state = "idle" } } },
{ note = "A swing of the sword hits whatever is in reach",
on = "pressed", key = "SPACE", each = "enemy",
when = { { "test", expr = 'distance(self, "hero") < 2.2' } },
act = { { "damage", entity = "self", amount = 1 }, { "addVar", name = "swings", amount = 1 } } },
{ note = "A kill",
on = "death", kind = "enemy",
act = { { "addScore", amount = 100 } } },
{ note = "It reaches the hero and rears up",
on = "arrived", kind = "enemy",
act = { { "setState", entity = "self", state = "attack" }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } },
{ note = "The bite lands unless it was killed",
on = "timer", name = "bite", kind = "enemy",
when = { { "inState", entity = "self", state = "attack" } },
act = { { "addVar", name = "health", amount = -1 }, { "timerStart", entity = "self", name = "bite", after = 1.5 } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"score " .. score .. " health " .. health' } } }
}
}

View file

@ -0,0 +1,70 @@
-- Genre two of milestone 4: tower defence. Creeps that walk a lane on the navigation mesh in
-- waves, turrets placed with a click on the floor that fire at the nearest creep in range,
-- projectiles that collide, gold and lives. Nothing here is shared with the racing game but the
-- scene.
return {
title = "Author test: tower defence",
size = { w = 720, h = 480 },
layers = { { kind = "scene3d", ambient = { r = 80, g = 90, b = 80 }, fov = 55 }, { kind = "overlay" } },
vars = { score = 0, gold = 120, lives = 5, killed = 0 },
kinds = {
floor = { look = { kind = "mesh", shape = "box", w = 30, h = 0.4, d = 24, r = 80, g = 110, b = 80 }, behaviours = { { kind = "solid" } } },
creep = { look = { kind = "mesh", shape = "box", w = 0.6, h = 0.8, d = 0.6, r = 120, g = 200, b = 90 },
vars = { health = 3 },
behaviours = { { kind = "walker", speed = 2.5, radius = 0.3 },
{ kind = "patrol", points = { { x = -10, y = 0, z = -6 }, { x = 10, y = 0, z = -6 }, { x = 10, y = 0, z = 6 }, { x = -10, y = 0, z = 6 } } },
{ kind = "health", max = 3 } } },
hive = { look = { kind = "none" }, behaviours = { { kind = "spawner", spawns = "creep", every = 1.2, max = 8, total = 10, points = { { x = -12, y = 0, z = -6 } } } } },
turret = { look = { kind = "mesh", shape = "cylinder", w = 0.8, h = 1.2, r = 70, g = 90, b = 220 },
behaviours = { { kind = "turret", targets = "creep", range = 6, rate = 0.5, spawns = "bolt", speed = 14 } } },
bolt = { look = { kind = "mesh", shape = "sphere", w = 0.25, r = 255, g = 230, b = 80 },
behaviours = { { kind = "projectile", life = 2 } } },
camera = { look = { kind = "none" }, behaviours = { { kind = "camera", mode = "fixed", lookX = 0, lookY = 0, lookZ = 0 } } },
readout = { look = { kind = "text", text = "", r = 235, g = 235, b = 245 } }
},
rooms = {
{ name = "field",
navFrom = { "floor" },
entities = {
{ kind = "floor", id = "floor", x = 0, y = -0.2, z = 0 },
{ kind = "hive", id = "hive", x = -12, y = 0, z = -6 },
{ kind = "camera", id = "camera", x = 0, y = 20, z = 14 },
{ kind = "readout", id = "readout", x = 12, y = 12, z = 0 }
} }
},
rules = {
{ note = "A click on the floor builds a turret, for fifty gold",
on = "pressed", switch = "SWITCH_BUTTON3",
when = { { "test", expr = "gold >= 50" } },
act = { { "spawnAtPointer", kind = "turret", player = 1 },
{ "addVar", name = "gold", amount = -50 } } },
{ note = "A bolt lands",
on = "collision", a = "bolt", b = "creep",
act = { { "destroy", entity = "self" },
{ "damage", entity = "other", amount = 1 } } },
{ note = "A creep dies",
on = "death", kind = "creep",
act = { { "addVar", name = "gold", amount = 20 },
{ "addScore", amount = 10 },
{ "addVar", name = "killed", amount = 1 } } },
{ note = "A creep reaches the end of the lane",
on = "patrolEnd", kind = "creep",
act = { { "destroy", entity = "self" },
{ "addVar", name = "lives", amount = -1 } } },
{ note = "Out of lives",
on = "frame",
when = { { "test", expr = "lives <= 0" }, { "once", tag = "over", scope = "game" } },
act = { { "gameOver" } } },
{ note = "The readout, every frame",
on = "frame",
act = { { "setText", entity = "readout", text = '"gold " .. gold .. " lives " .. lives .. " killed " .. killed .. " creeps " .. count("creep")' } } }
}
}

View file

@ -1,7 +1,8 @@
-- Authoring tools (PLAN section 58): compiles both sample descriptions and plays the platformer. -- Forge (FORGE.md): compiles all four sample descriptions and plays the platformer.
-- --
-- The two descriptions share nothing -- one is physics with a character, the other is disc frames -- The descriptions share nothing -- physics with a character, disc frames and time windows, a
-- and time windows -- so compiling both here is the test that the core has no genre baked into it. -- shoot-em-up of spawned kinds, a gun over video -- so compiling all of them here is the test
-- that the core has no genre baked into it.
-- Nothing in this scene knows what a platformer is; it drives keys and looks at the result. -- Nothing in this scene knows what a platformer is; it drives keys and looks at the result.
dofile("Forge/AuthorCompile.singe") dofile("Forge/AuthorCompile.singe")
@ -13,10 +14,16 @@ fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED) fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480) overlaySetResolution(720, 480)
built.platformer = authorBuild("testScripts/author/platformer.game", out .. "platformer.singe") for _, name in ipairs({ "platformer", "qte", "shmup", "gunvideo" }) do
built.qte = authorBuild("testScripts/author/qte.game", out .. "qte.singe") local game = authorLoad("testScripts/author/" .. name .. ".game")
debugPrint("AUTHOR built " .. built.platformer) local problems = authorCheck(game)
debugPrint("AUTHOR built " .. built.qte)
built[name] = authorBuild("testScripts/author/" .. name .. ".game", out .. name .. ".singe")
debugPrint("AUTHOR built " .. built[name] .. " with " .. #problems .. " problems")
for _, problem in ipairs(problems) do
debugPrint("AUTHOR FAIL " .. name .. ": " .. problem)
end
end
-- Both must at least load as Lua; a compiler that emits something unparseable is the failure this -- Both must at least load as Lua; a compiler that emits something unparseable is the failure this
-- catches earliest, and it costs nothing to check both even though only one is played. -- catches earliest, and it costs nothing to check both even though only one is played.
@ -75,7 +82,7 @@ function onOverlayUpdate()
-- The engine's own text over the authored game, so a screenshot says what it is looking at. -- The engine's own text over the authored game, so a screenshot says what it is looking at.
colorForeground(255, 255, 255, 255) colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_SCORE)) fontPrint(12, 450, string.format("authored platformer, %.1fs, score %d", t, AUTHOR_VARS.score))
-- Four shots at the moments that matter, found by watching rather than by timing: the start, -- Four shots at the moments that matter, found by watching rather than by timing: the start,
-- the run, the jump, and the frame after the prize was taken. -- the run, the jump, and the frame after the prize was taken.
@ -83,11 +90,11 @@ function onOverlayUpdate()
shots = shots + 1 shots = shots + 1
singeScreenshot() singeScreenshot()
end end
if AUTHOR_FLAG["won"] and won == 0 then if AUTHOR_VARS.won and won == 0 then
won = t won = t
end end
if (won > 0 and t > won + 0.6) or (t > GIVE_UP) then if (won > 0 and t > won + 0.6) or (t > GIVE_UP) then
debugPrint("AUTHOR RESULT score=" .. AUTHOR_SCORE .. " flagWon=" .. tostring(AUTHOR_FLAG["won"]) .. " after=" .. string.format("%.1fs", t)) debugPrint("AUTHOR RESULT score=" .. AUTHOR_VARS.score .. " flagWon=" .. tostring(AUTHOR_VARS.won) .. " after=" .. string.format("%.1fs", t))
singeQuit() singeQuit()
end end

View file

@ -1,4 +1,4 @@
-- Authoring tools (PLAN section 58), genre two: the QTE description, played over the disc. -- Forge (FORGE.md), genre two of the first slice: the QTE description, played over the disc.
-- --
-- Scene 52 plays the platformer. This one shares no vocabulary with it beyond the three nouns -- -- Scene 52 plays the platformer. This one shares no vocabulary with it beyond the three nouns --
-- no physics, no character, no gravity, and every rule is about disc frames -- which is what makes -- no physics, no character, no gravity, and every rule is about disc frames -- which is what makes
@ -43,7 +43,7 @@ function onOverlayUpdate()
singeScreenshot() singeScreenshot()
end end
if disc >= 310 or frames > 1200 then if disc >= 310 or frames > 1200 then
debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_SCORE .. " hit=" .. tostring(AUTHOR_FLAG["hit"])) debugPrint("AUTHOR QTE RESULT score=" .. AUTHOR_VARS.score .. " hit=" .. tostring(AUTHOR_VARS.hit))
singeQuit() singeQuit()
end end

View file

@ -1,10 +1,10 @@
-- Forge (PLAN section 58): opened on a real description, driven, saved, and the -- Forge (FORGE.md): opened on a real description, driven, saved, and the result compiled and
-- result compiled and played. -- played.
-- --
-- Driven by calling the editor's own functions rather than by a pointer, the way the menu's -- Driven by calling the editor's own functions rather than by a pointer, the way the menu's
-- sanitizer scene drives the menu: what is being tested is that moving something in the editor -- sanitizer scene drives the menu: what is being tested is that moving something in the editor
-- reaches the description, survives a save, and changes the game that comes out. The pointer path -- reaches the description, survives a save, and changes the game that comes out. The pointer path
-- itself was settled by the spike in PLAN section 58 and needs a real X server to exercise. -- itself was settled by the spike in FORGE.md section 1 and needs a real X server to exercise.
-- As a library: the tail of Forge.singe that launches it is skipped, because this scene -- As a library: the tail of Forge.singe that launches it is skipped, because this scene
-- drives it rather than being it. -- drives it rather than being it.
FORGE_LIBRARY = true FORGE_LIBRARY = true
@ -47,8 +47,8 @@ local function step()
if index == nil then if index == nil then
debugPrint("FORGE FAIL nothing under the prize's own position") debugPrint("FORGE FAIL nothing under the prize's own position")
else else
before = FORGE.game.entities[index].x before = forgeRoom().entities[index].x
debugPrint("FORGE picked " .. FORGE.game.entities[index].id .. " at x=" .. before) debugPrint("FORGE picked " .. forgeRoom().entities[index].id .. " at x=" .. before)
forgePress(520, 300) forgePress(520, 300)
end end
@ -59,7 +59,7 @@ local function step()
elseif stage == 3 then elseif stage == 3 then
forgeRelease() forgeRelease()
local moved = FORGE.game.entities[forgePick(300, 300)] local moved = forgeRoom().entities[forgePick(300, 300)]
debugPrint("FORGE moved to x=" .. moved.x .. " dirty=" .. tostring(FORGE.dirty)) debugPrint("FORGE moved to x=" .. moved.x .. " dirty=" .. tostring(FORGE.dirty))
@ -69,7 +69,7 @@ local function step()
local reloaded = authorLoad(work) local reloaded = authorLoad(work)
local found = nil local found = nil
for _, e in ipairs(reloaded.entities) do for _, e in ipairs(reloaded.rooms[1].entities) do
if e.id == "prize" then if e.id == "prize" then
found = e.x found = e.x
end end
@ -84,7 +84,7 @@ local function step()
local built = forgeBuild(out .. "edited.singe") local built = forgeBuild(out .. "edited.singe")
local text = assert(io.open(built, "r")):read("a") local text = assert(io.open(built, "r")):read("a")
if text:find('id = "prize", x = 300') then if text:find('id = "prize", kind = "prize", x = 300') then
debugPrint("FORGE the compiled game carries the new position") debugPrint("FORGE the compiled game carries the new position")
else else
debugPrint("FORGE FAIL the compiled game does not carry the new position") debugPrint("FORGE FAIL the compiled game does not carry the new position")
@ -135,7 +135,7 @@ local function step()
elseif stage == 9 then elseif stage == 9 then
-- And now the same press reaches the hero, which is the whole point of a movable panel. -- And now the same press reaches the hero, which is the whole point of a movable panel.
forgePress(120, 380) forgePress(120, 380)
if FORGE.selected ~= nil and FORGE.game.entities[FORGE.selected].id == "hero" then if FORGE.selected ~= nil and forgeRoom().entities[FORGE.selected].id == "hero" then
debugPrint("FORGE the hero is reachable once the panel has moved") debugPrint("FORGE the hero is reachable once the panel has moved")
else else
debugPrint("FORGE FAIL the hero is still unreachable") debugPrint("FORGE FAIL the hero is still unreachable")

View file

@ -1,4 +1,4 @@
-- The event sheet (PLAN section 58): rules edited, not just entities moved. -- The event sheet (FORGE.md): rules edited, not just entities moved.
-- --
-- Driven by keys, which is how the editor is meant to be used and how the bundled menu's document -- Driven by keys, which is how the editor is meant to be used and how the bundled menu's document
-- has always been driven. What is asserted is that an edit made through the rule editor reaches -- has always been driven. What is asserted is that an edit made through the rule editor reaches
@ -50,7 +50,8 @@ end
local function step() local function step()
if stage == 1 then if stage == 1 then
key(SCANCODE.TAB) key(SCANCODE.TAB) -- entities to kinds,
key(SCANCODE.TAB) -- kinds to rules.
debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules) debugPrint("RULES mode is now " .. FORGE.mode .. ", rules " .. #FORGE.game.rules)
elseif stage == 2 then elseif stage == 2 then
@ -77,7 +78,7 @@ local function step()
if after == before then if after == before then
debugPrint("RULES FAIL the new rule did not reach the compiled game") debugPrint("RULES FAIL the new rule did not reach the compiled game")
elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%("hero"%)') and after:find('authorJump%("hero"%)') then elseif after:find('authorKeyHeld%(SCANCODE%.UP%.value%)') and after:find('authorOnGround%(authorEntity%("hero"%)%)') and after:find('authorJump%(authorEntity%("hero"%)%)') then
debugPrint("RULES the compiled game carries the new rule") debugPrint("RULES the compiled game carries the new rule")
else else
debugPrint("RULES FAIL the compiled game is missing part of the new rule") debugPrint("RULES FAIL the compiled game is missing part of the new rule")
@ -116,9 +117,10 @@ local function step()
elseif stage == 7 then elseif stage == 7 then
-- A number has to come back a number: "100" and 100 compile to different source, so a -- A number has to come back a number: "100" and 100 compile to different source, so a
-- retyped value that turned into a string would stop the description round-tripping. -- retyped value that turned into a string would stop the description round-tripping.
-- Rule 4 is "Take the prize once"; its third part is addScore, whose value is amount. -- Rule 4 is "Take the prize", on a collision; its first part is addScore, whose value is
-- amount.
FORGE.rule = 4 FORGE.rule = 4
FORGE.part = 3 FORGE.part = 1
key(SCANCODE.RETURN) key(SCANCODE.RETURN)
for c in string.gmatch("250", ".") do for c in string.gmatch("250", ".") do
key(SCANCODE.A, string.byte(c)) key(SCANCODE.A, string.byte(c))
@ -132,15 +134,15 @@ local function step()
debugPrint("RULES FAIL amount is " .. type(amount) .. " " .. tostring(amount)) debugPrint("RULES FAIL amount is " .. type(amount) .. " " .. tostring(amount))
end end
-- And ESC has to put back what was there. -- And ESC has to put back what was there: the second part is destroy, on other.
FORGE.part = 1 FORGE.part = 2
key(SCANCODE.RETURN) key(SCANCODE.RETURN)
key(SCANCODE.A, string.byte("Z")) key(SCANCODE.A, string.byte("Z"))
key(SCANCODE.ESCAPE) key(SCANCODE.ESCAPE)
if FORGE.game.rules[4].when[1].entity == "hero" then if FORGE.game.rules[4].act[2].entity == "other" then
debugPrint("RULES escape put the value back") debugPrint("RULES escape put the value back")
else else
debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].when[1].entity)) debugPrint("RULES FAIL escape left " .. tostring(FORGE.game.rules[4].act[2].entity))
end end
elseif stage == 8 then elseif stage == 8 then

View file

@ -1,4 +1,4 @@
-- A Forge release is standalone (PLAN section 58). -- A Forge release is standalone (FORGE.md).
-- --
-- Building it is half the test; the half that matters is that the result runs somewhere Forge has -- Building it is half the test; the half that matters is that the result runs somewhere Forge has
-- never been. The runtime a compiled game loads is Singe/Author.singe, which whatever Singe the -- never been. The runtime a compiled game loads is Singe/Author.singe, which whatever Singe the

View file

@ -1,4 +1,4 @@
-- Forge as a game (PLAN section 58): the chooser, the engine callbacks, and the editing that -- Forge as a game (FORGE.md): the chooser, the engine callbacks, and the editing that
-- needs no pointer -- entities added, renamed and deleted, a rule built from the pickers, undo. -- needs no pointer -- entities added, renamed and deleted, a rule built from the pickers, undo.
-- --
-- Unlike scene54 to 56 this does not set FORGE_LIBRARY: Forge installs its own callbacks, and -- Unlike scene54 to 56 this does not set FORGE_LIBRARY: Forge installs its own callbacks, and
@ -40,31 +40,33 @@ local function step()
debugPrint("GAME FAIL ENTER on the chooser opened nothing") debugPrint("GAME FAIL ENTER on the chooser opened nothing")
else else
opened = FORGE.path opened = FORGE.path
debugPrint("GAME opened " .. opened .. " with " .. #FORGE.game.entities .. " entities") debugPrint("GAME opened " .. opened .. " with " .. #forgeRoom().entities .. " entities")
end end
elseif stage == 2 then elseif stage == 2 then
-- Add an entity, name it, and give it a look by typing the fields ENTER walks. -- Add an entity, name it, and make it a hero by typing the fields ENTER walks.
local count = #FORGE.game.entities local count = #forgeRoom().entities
key(SCANCODE.A) key(SCANCODE.A)
if #FORGE.game.entities ~= count + 1 then if #forgeRoom().entities ~= count + 1 then
debugPrint("GAME FAIL A did not add an entity") debugPrint("GAME FAIL A did not add an entity")
end end
key(SCANCODE.RETURN) -- id key(SCANCODE.RETURN) -- id
typeIn("coin") typeIn("coin")
key(SCANCODE.RETURN) -- kind
typeIn("hero")
key(SCANCODE.RETURN) -- x key(SCANCODE.RETURN) -- x
typeIn("300") typeIn("300")
key(SCANCODE.RETURN) -- y key(SCANCODE.RETURN) -- y
typeIn("200") typeIn("200")
key(SCANCODE.RETURN) -- kind key(SCANCODE.RETURN) -- z
key(SCANCODE.ESCAPE) -- untouched key(SCANCODE.ESCAPE) -- untouched
local coin = FORGE.game.entities[#FORGE.game.entities] local coin = forgeRoom().entities[#forgeRoom().entities]
if coin.id == "coin" and coin.x == 300 and coin.y == 200 and coin.look.kind == "box" then if coin.id == "coin" and coin.kind == "hero" and coin.x == 300 and coin.y == 200 then
debugPrint("GAME typed an entity in: " .. coin.id .. " at " .. coin.x .. "," .. coin.y) debugPrint("GAME typed an entity in: " .. coin.id .. " the " .. coin.kind .. " at " .. coin.x .. "," .. coin.y)
else else
debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y) .. " " .. tostring(coin.look.kind)) debugPrint("GAME FAIL the typed entity is " .. tostring(coin.id) .. " the " .. tostring(coin.kind) .. " at " .. tostring(coin.x) .. "," .. tostring(coin.y))
end end
elseif stage == 3 then elseif stage == 3 then
@ -77,7 +79,7 @@ local function step()
key(SCANCODE.RETURN) key(SCANCODE.RETURN)
typeIn("player") typeIn("player")
key(SCANCODE.ESCAPE) -- ESC puts it back: the hero keeps its name key(SCANCODE.ESCAPE) -- ESC puts it back: the hero keeps its name
if FORGE.game.entities[FORGE.selected].id ~= "hero" then if forgeRoom().entities[FORGE.selected].id ~= "hero" then
debugPrint("GAME FAIL escape did not put the name back") debugPrint("GAME FAIL escape did not put the name back")
end end
key(SCANCODE.RETURN) key(SCANCODE.RETURN)
@ -93,18 +95,22 @@ local function step()
end end
end end
end end
if FORGE.game.entities[FORGE.selected].id == "player" and named == 3 then if forgeRoom().entities[FORGE.selected].id == "player" and named == 3 then
debugPrint("GAME renamed the hero and " .. named .. " actions followed") debugPrint("GAME renamed the hero and " .. named .. " actions followed")
else else
debugPrint("GAME FAIL rename: id " .. tostring(FORGE.game.entities[FORGE.selected].id) .. ", " .. named .. " actions renamed") debugPrint("GAME FAIL rename: id " .. tostring(forgeRoom().entities[FORGE.selected].id) .. ", " .. named .. " actions renamed")
end end
elseif stage == 4 then elseif stage == 4 then
-- A rule from the pickers: N, then C picks the first condition, T the first action. -- A rule from the pickers: N, E picks an event, C a condition, T an action.
local rules = #FORGE.game.rules local rules = #FORGE.game.rules
key(SCANCODE.TAB) key(SCANCODE.TAB) -- kinds,
key(SCANCODE.TAB) -- rules.
key(SCANCODE.N) key(SCANCODE.N)
key(SCANCODE.E)
key(SCANCODE.DOWN)
key(SCANCODE.RETURN)
key(SCANCODE.C) key(SCANCODE.C)
if FORGE.picker == nil then if FORGE.picker == nil then
debugPrint("GAME FAIL C opened no picker") debugPrint("GAME FAIL C opened no picker")
@ -115,20 +121,21 @@ local function step()
key(SCANCODE.RETURN) key(SCANCODE.RETURN)
local rule = FORGE.game.rules[#FORGE.game.rules] local rule = FORGE.game.rules[#FORGE.game.rules]
if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 then if #FORGE.game.rules == rules + 1 and #rule.when == 1 and #rule.act == 1 and rule.on ~= "frame" then
debugPrint("GAME built a rule from the pickers: when " .. rule.when[1][1] .. " then " .. rule.act[1][1]) debugPrint("GAME built a rule from the pickers: on " .. rule.on .. " when " .. rule.when[1][1] .. " then " .. rule.act[1][1])
else else
debugPrint("GAME FAIL the picked rule has " .. #(rule.when or {}) .. " conditions and " .. #(rule.act or {}) .. " actions") debugPrint("GAME FAIL the picked rule has " .. #(rule.when or {}) .. " conditions and " .. #(rule.act or {}) .. " actions")
end end
elseif stage == 5 then elseif stage == 5 then
-- Undo takes the action back, then the condition, then the rule; redo brings the rule -- Undo takes the action back, then the condition, the event, then the rule; redo brings
-- back, and a change by hand ends the redo history. -- the rule back, and a change by hand ends the redo history.
local rules = #FORGE.game.rules local rules = #FORGE.game.rules
key(SCANCODE.U) key(SCANCODE.U)
key(SCANCODE.U) key(SCANCODE.U)
key(SCANCODE.U) key(SCANCODE.U)
key(SCANCODE.U)
if #FORGE.game.rules == rules - 1 then if #FORGE.game.rules == rules - 1 then
debugPrint("GAME three undos took the rule back out") debugPrint("GAME three undos took the rule back out")
else else
@ -152,15 +159,16 @@ local function step()
elseif stage == 6 then elseif stage == 6 then
-- Duplicate and delete in the entity list. -- Duplicate and delete in the entity list.
key(SCANCODE.TAB) key(SCANCODE.TAB) -- tracks,
local count = #FORGE.game.entities key(SCANCODE.TAB) -- entities.
local count = #forgeRoom().entities
key(SCANCODE.D) key(SCANCODE.D)
key(SCANCODE.DELETE) key(SCANCODE.DELETE)
if #FORGE.game.entities == count then if #forgeRoom().entities == count then
debugPrint("GAME duplicate then delete leaves " .. count .. " entities") debugPrint("GAME duplicate then delete leaves " .. count .. " entities")
else else
debugPrint("GAME FAIL duplicate then delete leaves " .. #FORGE.game.entities) debugPrint("GAME FAIL duplicate then delete leaves " .. #forgeRoom().entities)
end end
elseif stage == 7 then elseif stage == 7 then
@ -194,7 +202,7 @@ local function step()
else else
local found = false local found = false
for _, entity in ipairs(FORGE.game.entities) do for _, entity in ipairs(forgeRoom().entities) do
if entity.id == "player" then if entity.id == "player" then
found = true found = true
end end

71
testScripts/scene58.singe Normal file
View file

@ -0,0 +1,71 @@
-- Forge milestone 1, genre one: the shoot-em-up. Kinds spawned and destroyed by the dozen,
-- collision events between kinds, health and death, a spawner, a HUD document bound to vars, a
-- sequence (say waits), a flash, and a game over -- driven by what the game is doing.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
dofile(authorBuild("testScripts/author/shmup.game", singeGetDataPath() .. "shmup.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local maxRaiders = 0
local maxShots = 0
local reported = false
local GIVE_UP = 30
function onOverlayUpdate()
local t = authorTime()
local ship = authorEntity("ship")
frames = frames + 1
-- Hold fire from the start and sweep left and right under the spawn points.
if frames == 2 then
authorKeyDown(0, SCANCODE.SPACE.value)
end
if ship then
local x = authorX(ship)
if (x > 500) and authorKeyHeld(SCANCODE.RIGHT.value) then
authorKeyUp(0, SCANCODE.RIGHT.value)
authorKeyDown(0, SCANCODE.LEFT.value)
elseif (x < 120) and authorKeyHeld(SCANCODE.LEFT.value) then
authorKeyUp(0, SCANCODE.LEFT.value)
authorKeyDown(0, SCANCODE.RIGHT.value)
elseif not (authorKeyHeld(SCANCODE.LEFT.value) or authorKeyHeld(SCANCODE.RIGHT.value)) then
authorKeyDown(0, SCANCODE.RIGHT.value)
end
end
local r = gameUpdate()
maxRaiders = math.max(maxRaiders, authorCount("raider"))
maxShots = math.max(maxShots, authorCount("shot"))
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored shmup, %.1fs, raiders %d, shots %d, score %d, lives %d", t, authorCount("raider"), authorCount("shot"), AUTHOR_VARS.score, AUTHOR_VARS.lives))
if (shots == 0 and t > 1.5) or (shots == 1 and t > 4) or (shots == 2 and AUTHOR_VARS.cleared) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.cleared and t > 1 and not reported) or (t > GIVE_UP) then
reported = true
debugPrint(string.format("SHMUP RESULT score=%d lives=%d cleared=%s maxRaiders=%d maxShots=%d after=%.1fs",
AUTHOR_VARS.score, AUTHOR_VARS.lives, tostring(AUTHOR_VARS.cleared), maxRaiders, maxShots, t))
if maxRaiders < 3 then
debugPrint("SHMUP FAIL the spawner never had three raiders up at once")
end
if AUTHOR_VARS.score <= 0 then
debugPrint("SHMUP FAIL nothing was ever hit")
end
singeQuit()
end
return r
end

93
testScripts/scene59.singe Normal file
View file

@ -0,0 +1,93 @@
-- Forge milestone 1, genre two: a light gun over video. Hitboxes keyed to disc frames and
-- interpolated between them, a gun that raises hit and miss, ammo that runs out, a reload by a
-- shot off the picture, a prompt at a frame, and a penalty for the hostage. The pointer is
-- injected through authorSetPointer, the door a test uses instead of a mouse.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/gunvideo.game", singeGetDataPath() .. "gunvideo.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local stage = 0
local hits = 0
local misses = 0
local hostage = 0
local realEvent = authorEvent
function authorEvent(name, event)
if name == "hit" then
if event.self.kind == "bandit" then hits = hits + 1 elseif event.self.kind == "hostage" then hostage = hostage + 1 end
elseif name == "miss" then
misses = misses + 1
end
return realEvent(name, event)
end
local function shoot(x, y, on)
authorSetPointer(1, x, y, on)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
end
function onOverlayUpdate()
local disc = discGetFrame()
local box = authorTrackBox("bandit")
frames = frames + 1
-- Each shot waits for the disc to reach a frame, so the sequence is the same at any speed.
if (stage == 0) and (disc >= 130) and box then
-- Frame 130 of 120..220: the bandit box has moved a tenth of the way from x=100 to x=400.
debugPrint(string.format("GUN at frame %d the bandit box is at x=%.0f (interpolated from 100 toward 400)", disc, box.x))
shoot(box.x + 10, box.y + 10, true) -- hit
stage = 1
elseif (stage == 1) and (disc >= 150) then
shoot(20, 20, true) -- miss on the picture
shoot(box.x + 10, box.y + 10, true) -- hit (the bandit is dead; no target, so a miss)
stage = 2
elseif (stage == 2) and (disc >= 170) then
-- Out of ammo now (3 shots taken): a shot on the picture does nothing, off it reloads.
local ammo = authorEntity("gun1").vars.ammo
shoot(580, 260, true) -- hostage, but the gun is empty
debugPrint("GUN ammo before reload " .. ammo .. ", hostage hits so far " .. hostage)
shoot(-10, -10, false) -- reload
debugPrint("GUN ammo after reload " .. authorEntity("gun1").vars.ammo)
shoot(580, 260, true) -- hostage
stage = 3
elseif (stage == 3) and (disc >= 320) then
stage = 4
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, "authored gun game, disc frame " .. disc)
if box then
colorForeground(255, 80, 80, 255)
overlayBox(box.x, box.y, box.x + box.w, box.y + box.h)
end
if (shots == 0 and disc >= 125) or (shots == 1 and disc >= 160) or (shots == 2 and disc >= 200) then
shots = shots + 1
singeScreenshot()
end
if stage == 4 or frames > 1500 then
debugPrint(string.format("GUN RESULT score=%d hits=%d misses=%d hostage=%d ammo=%d", AUTHOR_VARS.score, hits, misses, hostage, authorEntity("gun1").vars.ammo))
if hits ~= 1 or hostage ~= 1 or misses ~= 2 then
debugPrint("GUN FAIL expected 1 hit, 2 misses, 1 hostage")
end
if authorTrackBoxAt({ boxes = { { at = 0, x = 0, y = 0, w = 10, h = 10 }, { at = 10, x = 100, y = 0, w = 10, h = 10 } } }, 5).x ~= 50 then
debugPrint("GUN FAIL interpolation")
end
singeQuit()
end
return r
end

148
testScripts/scene60.singe Normal file
View file

@ -0,0 +1,148 @@
-- The timeline (FORGE.md milestone 1): hitbox tracks keyed to disc frames, edited in Forge. The
-- cursor is stepped, keys are made at it, one is picked up off the canvas and dragged, one is
-- deleted, and the compiled game carries what was drawn. The description names its video, so the
-- editor has a frame to show under the cursor.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "timeline.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/gunvideo.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("TIMELINE FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local function key(code, character)
forgeKey(character or 0, code.value)
end
local function step()
local track = forgeRoom().tracks[1]
if stage == 1 then
debugPrint("TIMELINE video loaded: " .. tostring(FORGE.video ~= nil) .. "; track '" .. track.name .. "' has " .. #track.boxes .. " keys")
key(SCANCODE.TAB)
key(SCANCODE.TAB)
key(SCANCODE.TAB)
if FORGE.mode ~= "tracks" then
debugPrint("TIMELINE FAIL three tabs did not reach the tracks: " .. FORGE.mode)
end
elseif stage == 2 then
-- Step and leap the cursor to frame 170, halfway between the bandit's two keys.
for _ = 1, 17 do
key(SCANCODE.RIGHTBRACKET)
end
local box = forgeCursorBox()
debugPrint(string.format("TIMELINE cursor at %d, the bandit box is at x=%.0f (halfway from 100 to 400)", FORGE.cursor, box.x))
if math.abs(box.x - 250) > 1 then
debugPrint("TIMELINE FAIL interpolation at the cursor")
end
elseif stage == 3 then
-- A key at the cursor takes the interpolated box, and the list stays sorted.
key(SCANCODE.K)
if #track.boxes == 3 and track.boxes[2].at == 170 and track.boxes[2].x == 250 then
debugPrint("TIMELINE a key at 170 went in between the two, at x=250")
else
debugPrint("TIMELINE FAIL after K the track has " .. #track.boxes .. " keys; second at " .. tostring(track.boxes[2] and track.boxes[2].at))
end
elseif stage == 4 then
-- Pick the box up off the canvas and drag it, the way a pointer would.
local box = forgeCursorBox()
forgePress(box.x + 5, box.y + 5)
forgeDrag(box.x + 65, box.y + 25)
forgeRelease()
if track.boxes[2].x == 310 and track.boxes[2].y == 220 then
debugPrint("TIMELINE dragged the key to " .. track.boxes[2].x .. "," .. track.boxes[2].y)
else
debugPrint("TIMELINE FAIL the drag left the key at " .. tostring(track.boxes[2].x) .. "," .. tostring(track.boxes[2].y))
end
elseif stage == 5 then
-- Type the key's height, then step back a frame: the box between keys follows the new one.
key(SCANCODE.RETURN) -- at
key(SCANCODE.RETURN) -- x
key(SCANCODE.RETURN) -- y
key(SCANCODE.RETURN) -- w
key(SCANCODE.RETURN) -- h
for c in string.gmatch("40", ".") do
key(SCANCODE.A, string.byte(c))
end
key(SCANCODE.RETURN)
key(SCANCODE.ESCAPE)
key(SCANCODE.COMMA)
local box = forgeCursorBox()
debugPrint(string.format("TIMELINE key height typed to %d; at frame %d the box is %.0f tall", track.boxes[2].h, FORGE.cursor, box.h))
elseif stage == 6 then
-- Undo the typing and the drag, then delete the key. Undo puts a copy of the description
-- in place, so the track is looked up again afterwards.
key(SCANCODE.U)
key(SCANCODE.U)
track = forgeRoom().tracks[1]
if track.boxes[2].x ~= 250 then
debugPrint("TIMELINE FAIL undo did not bring the key back to x=250: " .. tostring(track.boxes[2].x))
end
FORGE.key = 2
key(SCANCODE.DELETE)
debugPrint("TIMELINE after two undos and a delete the track has " .. #track.boxes .. " keys")
elseif stage == 7 then
-- And a new key in a new track reaches the compiled game.
key(SCANCODE.N)
key(SCANCODE.K)
key(SCANCODE.S)
local built = forgeBuild(out .. "timeline.singe")
local text = assert(io.open(built, "r")):read("a")
if text:find('name = "track3"') and text:find("at = " .. FORGE.cursor) then
debugPrint("TIMELINE the compiled game carries the new track and its key")
else
debugPrint("TIMELINE FAIL the new track is missing from the compiled game")
end
debugPrint("TIMELINE RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 8 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 8, "timeline, stage " .. stage)
if frames == 44 or frames == 72 or frames == 100 then
singeScreenshot()
end
if frames > 130 then
singeQuit()
end
return OVERLAY_UPDATED
end

103
testScripts/scene61.singe Normal file
View file

@ -0,0 +1,103 @@
-- Forge milestone 2, genre one: the rail shooter. A camera on a rail that stops at encounters,
-- zombies that spawn there and walk to it, a gun whose ray says which part it hit, ragdolls, and
-- the rail moving on when the stop is clear. The pointer is aimed at each zombie's head through
-- sceneProject, which is what a light gun would do with a person behind it.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/railshooter.game", singeGetDataPath() .. "railshooter.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local hits = 0
local heads = 0
local kills = 0
local stopped = 0
local ended = false
local lastShot = 0
local GIVE_UP = 60
local realEvent = authorEvent
function authorEvent(name, event)
if name == "hit" then
hits = hits + 1
if event.part == "head" then
heads = heads + 1
end
elseif name == "death" then
kills = kills + 1
elseif name == "stopped" then
stopped = stopped + 1
elseif name == "railEnd" then
ended = true
end
return realEvent(name, event)
end
local function fire(x, y, on)
authorSetPointer(1, x, y, on)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
end
function onOverlayUpdate()
local t = authorTime()
local gun = authorEntity("gun1")
frames = frames + 1
-- Aim at the nearest living zombie's head and fire, no faster than a person would; reload
-- off the picture when empty.
if gun and (t - lastShot > 0.25) then
local nearest, best = nil, math.huge
for _, zombie in ipairs(authorEach("zombie")) do
if zombie.vars.state ~= "dead" then
local far = authorDistance(zombie, authorEntity("camera"))
if far < best then
nearest, best = zombie, far
end
end
end
if gun.vars.ammo <= 0 then
fire(-10, -10, false)
lastShot = t
elseif nearest then
local head = nodeFind("b_Head_05", nearest.model)
local hx, hy, hz = nodeGetWorldPosition(head)
local sx, sy, _, inFront = sceneProject(hx, hy, hz)
if inFront then
fire(sx, sy, true)
lastShot = t
end
end
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored rail shooter, %.1fs, zombies %d, kills %d, heads %d, stops %d", t, authorCount("zombie"), kills, heads, stopped))
if (shots == 0 and t > 5) or (shots == 1 and kills > 0) or (shots == 2 and stopped == 2) or (shots == 3 and ended) then
shots = shots + 1
singeScreenshot()
end
if (ended and t > 1) or (t > GIVE_UP) then
debugPrint(string.format("RAIL RESULT score=%d health=%d bites=%d stops=%d kills=%d hits=%d heads=%d ended=%s after=%.1fs",
AUTHOR_VARS.score, AUTHOR_VARS.health, AUTHOR_VARS.bites, stopped, kills, hits, heads, tostring(ended), t))
if stopped < 2 then debugPrint("RAIL FAIL the rail did not reach both stops") end
if kills < 7 then debugPrint("RAIL FAIL not every zombie was killed") end
if heads == 0 then debugPrint("RAIL FAIL no shot was reported on the head") end
if not ended then debugPrint("RAIL FAIL the rail never ended") end
singeQuit()
end
return r
end

64
testScripts/scene62.singe Normal file
View file

@ -0,0 +1,64 @@
-- Forge milestone 2, genre two: the 3D platformer. A character on the controller moving
-- relative to a following camera, a jump onto a ledge, and a prize that is a trigger. Driven by
-- what the hero is doing, as scene52 drives the 2D one.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/platformer3d.game", singeGetDataPath() .. "platformer3d.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local jumped = false
local won = 0
local GIVE_UP = 25
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local hx, hy, hz = authorPosition(hero)
frames = frames + 1
if frames == 2 then
authorKeyDown(0, SCANCODE.RIGHT.value)
end
-- Jump once the ledge is near and there is ground underfoot.
if (not jumped) and (hx > 3.2) and authorOnGround(hero) then
authorKeyDown(0, SCANCODE.SPACE.value)
jumped = true
elseif jumped then
authorKeyUp(0, SCANCODE.SPACE.value)
end
-- Past the prize, turn back so the ledge is not overshot for good.
if hx > 7.5 then
authorKeyUp(0, SCANCODE.RIGHT.value)
authorKeyDown(0, SCANCODE.LEFT.value)
elseif hx < 5 and jumped and AUTHOR_VARS.won == nil then
authorKeyUp(0, SCANCODE.LEFT.value)
authorKeyDown(0, SCANCODE.RIGHT.value)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored 3D platformer, %.1fs, hero %.1f %.1f %.1f, state %s, score %d", t, hx, hy, hz, hero.vars.state, AUTHOR_VARS.score))
if (shots == 0 and t > 0.5) or (shots == 1 and jumped and not authorOnGround(hero)) or (shots == 2 and won > 0) then
shots = shots + 1
singeScreenshot()
end
if AUTHOR_VARS.won and won == 0 then
won = t
end
if (won > 0 and t > won + 0.5) or (t > GIVE_UP) then
debugPrint(string.format("PLATFORM3D RESULT score=%d won=%s hero=%.1f,%.1f after=%.1fs", AUTHOR_VARS.score, tostring(AUTHOR_VARS.won), hx, hy, t))
if not AUTHOR_VARS.won then debugPrint("PLATFORM3D FAIL the prize was never taken") end
singeQuit()
end
return r
end

139
testScripts/scene63.singe Normal file
View file

@ -0,0 +1,139 @@
-- The 3D viewport (FORGE.md milestone 2): a room with a scene3d layer is shown as the scene,
-- built from the kinds' looks under an orbiting editor camera. Entities are picked and dragged
-- by where they project, the camera orbits by key, and a rail point is put where the camera
-- stands.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "rail.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/railshooter.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("VIEW FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local wall = nil
local function key(code, character)
forgeKey(character or 0, code.value)
end
local function step()
local room = forgeRoom()
if stage == 1 then
local built = 0
for _ in pairs(FORGE.scene and FORGE.scene.nodes or {}) do
built = built + 1
end
debugPrint("VIEW the preview holds " .. built .. " of " .. #room.entities .. " entities")
if built ~= #room.entities then
debugPrint("VIEW FAIL the preview is incomplete")
end
elseif stage == 2 then
-- Pick the left wall by where it projects.
for index, entry in ipairs(room.entities) do
if entry.id == "wallL" then
wall = index
end
end
local entry = room.entities[wall]
local sx, sy, _, inFront = sceneProject(entry.x, entry.y, entry.z)
forgePress(sx, sy)
if FORGE.selected == wall then
debugPrint("VIEW picked " .. entry.id .. " at its projection " .. math.floor(sx) .. "," .. math.floor(sy))
else
debugPrint("VIEW FAIL the press at the wall selected " .. tostring(FORGE.selected) .. " (in front " .. tostring(inFront) .. ")")
end
-- Drag it a little to the right on screen and let go: it moves across the floor.
forgeDrag(sx + 40, sy)
forgeRelease()
if (entry.y == 1.5) and ((entry.x ~= -6) or (entry.z ~= -12)) then
debugPrint(string.format("VIEW dragged it to %.1f, %.1f, %.1f, staying on its floor", entry.x, entry.y, entry.z))
else
debugPrint(string.format("VIEW FAIL the drag left it at %.1f, %.1f, %.1f", entry.x, entry.y, entry.z))
end
elseif stage == 3 then
local yaw = FORGE.scene.yaw
local far = FORGE.scene.distance
key(SCANCODE.J)
key(SCANCODE.J)
key(SCANCODE.I)
debugPrint(string.format("VIEW orbit: yaw %d to %d, distance %.1f to %.1f", yaw, FORGE.scene.yaw, far, FORGE.scene.distance))
elseif stage == 4 then
-- A rail point where the camera stands, at a typed time.
key(SCANCODE.TAB)
key(SCANCODE.TAB)
key(SCANCODE.TAB)
local track = room.tracks[1]
local count = #track.points
key(SCANCODE.K)
key(SCANCODE.RETURN) -- at
for c in string.gmatch("20", ".") do
key(SCANCODE.A, string.byte(c))
end
key(SCANCODE.RETURN) -- commits at, moves on to x
key(SCANCODE.ESCAPE)
track = forgeRoom().tracks[1]
local last = track.points[#track.points]
if (#track.points == count + 1) and (last.at == 20) and (last.look ~= nil) then
debugPrint(string.format("VIEW a rail point went in at %d: %.1f, %.1f, %.1f looking at %s", last.at, last.x, last.y, last.z, tostring(last.look[1] or last.look.x)))
else
debugPrint("VIEW FAIL the rail has " .. #track.points .. " points; the last is at " .. tostring(last and last.at))
end
elseif stage == 5 then
key(SCANCODE.S)
local reloaded = authorLoad(work)
local built = forgeBuild(out .. "rail.singe")
local text = assert(io.open(built, "r")):read("a")
debugPrint("VIEW saved: the rail has " .. #reloaded.rooms[1].tracks[1].points .. " points on disc; compiled carries at = 20: " .. tostring(text:find("at = 20") ~= nil))
debugPrint("VIEW RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 6 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 8, "3D viewport, stage " .. stage)
if frames == 20 or frames == 50 or frames == 80 then
singeScreenshot()
end
if frames > 100 then
singeQuit()
end
return OVERLAY_UPDATED
end

145
testScripts/scene64.singe Normal file
View file

@ -0,0 +1,145 @@
-- Forge milestone 3, genre one: the adventure in a painted room. Walk areas baked to a
-- navigation mesh, hotspots, verbs and the sentence line, the parser, an inventory, a dialogue
-- whose choice changes the world, a cut-scene into a second room, and a save and load. Driven the
-- way a player drives it: a verb, a click on a hotspot, a typed line, a numbered choice.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 16)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/adventure2d.game", singeGetDataPath() .. "adventure2d.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local stage = 0
local shots = 0
local said = {}
local GIVE_UP = 90
local realSay = authorSay
function authorSay(text, seconds)
said[#said + 1] = tostring(text)
return realSay(text, seconds)
end
local function clickOn(verb, id)
local target = authorEntity(id)
local bx, by, bw, bh = authorBounds(target)
authorSetVerb(verb)
authorSetPointer(1, bx + bw / 2, by + bh / 2, true)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
end
local function lastSaid()
return said[#said] or "(nothing)"
end
-- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking --
-- or for a dialogue to offer its choices, before the next.
local function idle()
local hero = authorEntity("hero")
if authorChoices() ~= nil then
return true
end
return (not authorBusy()) and ((hero == nil) or not hero.walking)
end
local steps = {
function()
debugPrint("ADV the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb))
clickOn("look", "door")
end,
function()
debugPrint("ADV look at the door said: " .. lastSaid())
clickOn("take", "key")
end,
function()
debugPrint("ADV took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. math.floor(authorX(authorEntity("hero"))) .. "," .. math.floor(authorY(authorEntity("hero"))))
clickOn("open", "door")
end,
function()
debugPrint("ADV the guard objects: " .. lastSaid())
clickOn("talk", "guard")
end,
function()
-- The dialogue is up: the first choice, then the one the key allows.
debugPrint("ADV the guard says: " .. lastSaid())
authorKeyDown(0, SCANCODE.MAIN_1.value)
end,
function()
debugPrint("ADV and then: " .. lastSaid())
authorKeyDown(0, SCANCODE.MAIN_1.value)
end,
function()
debugPrint("ADV allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid())
authorSaid("look at the door")
end,
function()
debugPrint("ADV typed 'look at the door': " .. lastSaid())
authorSaid("frobnicate the key")
end,
function()
debugPrint("ADV typed nonsense: " .. lastSaid())
clickOn("open", "door")
end,
function()
debugPrint("ADV after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside))
authorSaveGame(1)
authorRestart()
debugPrint("ADV restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score)
authorLoadGame(1)
debugPrint("ADV loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key")))
end,
function()
if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then
debugPrint("ADV RESULT done")
else
debugPrint("ADV FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score)
end
singeQuit()
end
}
local waited = 0
function onOverlayUpdate()
local t = authorTime()
frames = frames + 1
-- Move on when the game is idle for a moment, or after a while regardless.
if idle() then
waited = waited + 1
else
waited = 0
end
if (stage < #steps) and ((waited > 12) or (frames == 20)) then
stage = stage + 1
waited = 0
steps[stage]()
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(300, 450, string.format("adventure, stage %d, %.1fs", stage, t))
if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then
shots = shots + 1
singeScreenshot()
end
if t > GIVE_UP then
debugPrint("ADV FAIL gave up at stage " .. stage)
singeQuit()
end
return r
end

147
testScripts/scene65.singe Normal file
View file

@ -0,0 +1,147 @@
-- Forge milestone 3, genre two: the same adventure in a modelled room from a fixed camera. The Walk areas baked to a
-- baked from the floor and a hotspot is what the ray through the pointer meets. Driven as scene64
-- is, with the pointer placed by where each hotspot projects.
--
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 16)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/adventure3d.game", singeGetDataPath() .. "adventure3d.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local stage = 0
local shots = 0
local said = {}
local GIVE_UP = 90
local realSay = authorSay
function authorSay(text, seconds)
said[#said + 1] = tostring(text)
return realSay(text, seconds)
end
local function clickOn(verb, id)
local target = authorEntity(id)
local x, y, z = authorPosition(target)
local w, h = authorSize(target)
local sx, sy = sceneProject(x, y + h / 2, z)
authorSetVerb(verb)
authorSetPointer(1, sx, sy, true)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
end
local function lastSaid()
return said[#said] or "(nothing)"
end
-- Each stage waits for the game to settle -- no sequence running, no line up, nobody walking --
-- or for a dialogue to offer its choices, before the next.
local function idle()
local hero = authorEntity("hero")
if authorChoices() ~= nil then
return true
end
return (not authorBusy()) and ((hero == nil) or not hero.walking)
end
local steps = {
function()
debugPrint("ADV3 the hall has a navigation mesh: " .. tostring(authorRoomNav() ~= nil) .. "; verb is " .. tostring(AUTHOR_VARS.verb))
clickOn("look", "door")
end,
function()
debugPrint("ADV3 look at the door said: " .. lastSaid())
clickOn("take", "key")
end,
function()
debugPrint("ADV3 took the key: has it " .. tostring(authorHas("key")) .. ", key gone " .. tostring(authorEntity("key") == nil) .. ", hero at " .. string.format("%.1f", authorX(authorEntity("hero"))) .. "," .. string.format("%.1f", authorField(authorEntity("hero"), "z")))
clickOn("open", "door")
end,
function()
debugPrint("ADV3 the guard objects: " .. lastSaid())
clickOn("talk", "guard")
end,
function()
-- The dialogue is up: the first choice, then the one the key allows.
debugPrint("ADV3 the guard says: " .. lastSaid())
authorKeyDown(0, SCANCODE.MAIN_1.value)
end,
function()
debugPrint("ADV3 and then: " .. lastSaid())
authorKeyDown(0, SCANCODE.MAIN_1.value)
end,
function()
debugPrint("ADV3 allowed now: " .. tostring(AUTHOR_VARS.allowed) .. "; last line: " .. lastSaid())
authorSaid("look at the door")
end,
function()
debugPrint("ADV3 typed 'look at the door': " .. lastSaid())
authorSaid("frobnicate the key")
end,
function()
debugPrint("ADV3 typed nonsense: " .. lastSaid())
clickOn("open", "door")
end,
function()
debugPrint("ADV3 after the door: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", outside " .. tostring(AUTHOR_VARS.outside))
authorSaveGame(1)
authorRestart()
debugPrint("ADV3 restarted: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score)
authorLoadGame(1)
debugPrint("ADV3 loaded: room " .. AUTHOR_ROOM.name .. ", score " .. AUTHOR_VARS.score .. ", has key " .. tostring(authorHas("key")))
end,
function()
if AUTHOR_ROOM.name == "yard" and AUTHOR_VARS.score == 3 and authorHas("key") then
debugPrint("ADV3 RESULT done")
else
debugPrint("ADV3 FAIL room " .. AUTHOR_ROOM.name .. " score " .. AUTHOR_VARS.score)
end
singeQuit()
end
}
local waited = 0
function onOverlayUpdate()
local t = authorTime()
frames = frames + 1
-- Move on when the game is idle for a moment, or after a while regardless.
if idle() then
waited = waited + 1
else
waited = 0
end
if (stage < #steps) and ((waited > 12) or (frames == 20)) then
stage = stage + 1
waited = 0
steps[stage]()
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(300, 450, string.format("adventure in 3D, stage %d, %.1fs", stage, t))
if (shots == 0 and stage == 2) or (shots == 1 and stage == 5) or (shots == 2 and stage == 10) then
shots = shots + 1
singeScreenshot()
end
if t > GIVE_UP then
debugPrint("ADV3 FAIL gave up at stage " .. stage)
singeQuit()
end
return r
end

55
testScripts/scene66.singe Normal file
View file

@ -0,0 +1,55 @@
-- Forge milestone 4, genre one: racing. The car is driven by held keys, the rival drives the
-- track of points on its own, a checkpoint is a trigger the car enters, and a lap is two of them.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/racing.game", singeGetDataPath() .. "racing.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local topSpeed = 0
local rivalStart = nil
local GIVE_UP = 40
function onOverlayUpdate()
local t = authorTime()
local car = authorEntity("car")
local rival = authorEntity("rival")
local cx, cy, cz = authorPosition(car)
local rx, ry, rz = authorPosition(rival)
frames = frames + 1
rivalStart = rivalStart or rz
if frames == 2 then
authorKeyDown(0, SCANCODE.UP.value)
end
topSpeed = math.max(topSpeed, car.vars.speed or 0)
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored racing, %.1fs, car z %.1f speed %.1f, rival z %.1f, gate %d, laps %d", t, cz, car.vars.speed or 0, rz, AUTHOR_VARS.next, AUTHOR_VARS.laps))
if (shots == 0 and t > 1) or (shots == 1 and AUTHOR_VARS.next == 2) or (shots == 2 and AUTHOR_VARS.laps > 0) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.laps > 0 and t > 1) or (t > GIVE_UP) then
debugPrint(string.format("RACE RESULT laps=%d next=%d topSpeed=%.1f carZ=%.1f rivalMoved=%.1f after=%.1fs", AUTHOR_VARS.laps, AUTHOR_VARS.next, topSpeed, cz, rivalStart - rz, t))
if topSpeed < 3 then debugPrint("RACE FAIL the car never got going") end
if AUTHOR_VARS.laps < 1 then debugPrint("RACE FAIL no lap was completed") end
if rivalStart - rz < 5 then debugPrint("RACE FAIL the rival did not drive") end
local hz = authorField(authorEntity("hover"), "z")
debugPrint(string.format("RACE the hovercraft under thrust is at z %.1f (from 10); the hinged bar is at y %.1f", hz, authorField(authorEntity("bar"), "y")))
if hz > 8 then debugPrint("RACE FAIL the thrust did not move the hovercraft") end
singeQuit()
end
return r
end

64
testScripts/scene67.singe Normal file
View file

@ -0,0 +1,64 @@
-- Forge milestone 4, genre two: tower defence. Creeps walk a lane on the navigation mesh in
-- waves, turrets are placed by clicks on the floor -- the pointer put where a lane point projects
-- -- and fire projectiles that collide with them.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/towerdefence.game", singeGetDataPath() .. "towerdefence.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local placed = 0
local maxCreeps = 0
local GIVE_UP = 45
local function place(x, y, z)
local sx, sy, _, inFront = sceneProject(x, y, z)
if inFront then
authorSetPointer(1, sx, sy, true)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
placed = placed + 1
end
end
function onOverlayUpdate()
local t = authorTime()
frames = frames + 1
-- Two turrets beside the lane, once the scene has drawn so projection works.
if frames == 10 then
place(0, 0, -3.5)
elseif frames == 12 then
place(10, 0, 0)
end
maxCreeps = math.max(maxCreeps, authorCount("creep"))
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored tower defence, %.1fs, creeps %d, turrets %d, killed %d, gold %d, lives %d", t, authorCount("creep"), authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives))
if (shots == 0 and t > 3) or (shots == 1 and AUTHOR_VARS.killed > 0) or (shots == 2 and t > 20) then
shots = shots + 1
singeScreenshot()
end
local hive = authorEntity("hive")
if (t > 8 and hive and hive.vars.made >= 10 and authorCount("creep") == 0) or (t > GIVE_UP) then
debugPrint(string.format("TD RESULT turrets=%d killed=%d gold=%d lives=%d maxCreeps=%d after=%.1fs", authorCount("turret"), AUTHOR_VARS.killed, AUTHOR_VARS.gold, AUTHOR_VARS.lives, maxCreeps, t))
if authorCount("turret") < 2 then debugPrint("TD FAIL the clicks did not place two turrets") end
if AUTHOR_VARS.killed == 0 then debugPrint("TD FAIL no creep was killed") end
if maxCreeps < 3 then debugPrint("TD FAIL the waves never had three creeps walking") end
singeQuit()
end
return r
end

75
testScripts/scene68.singe Normal file
View file

@ -0,0 +1,75 @@
-- Forge milestone 5, genre one: branching video. A window on the disc answered in time sends
-- the disc to the success clip; one missed sends it to the death clip and takes a life; lives
-- run out, the score goes to the board, the results card shows, a coin and start continue.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
-- The master service, stubbed: what is sent is what is checked.
local submitted = {}
function masterSubmitScore(gameId, value, board, meta)
submitted[#submitted + 1] = { value = value, board = board }
end
function masterPump() end
dofile(authorBuild("testScripts/author/fmv.game", singeGetDataPath() .. "fmv.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local moved = false
local continued = false
local jumps = {}
local lastFrame = -1
local before = { taken = 0, missed = 0 }
local GIVE_UP = 1500
function onOverlayUpdate()
local disc = discGetFrame()
frames = frames + 1
-- The disc jumping backward or far forward is a branch being taken.
if (lastFrame >= 0) and ((disc < lastFrame) or (disc > lastFrame + 20)) then
jumps[#jumps + 1] = lastFrame .. ">" .. disc
end
lastFrame = disc
-- Answer the first window, in time; miss the second on purpose.
if (disc >= 130) and (disc < 150) and not moved and (AUTHOR_VARS.taken == 0) then
authorKeyDown(0, SCANCODE.RIGHT.value)
authorKeyUp(0, SCANCODE.RIGHT.value)
moved = true
end
-- Once it is over, a coin and a start continue. What the run counted is kept first, since
-- the restart begins the vars again.
if AUTHOR_VARS.lives <= 0 and not continued and disc >= 385 then
before = { taken = AUTHOR_VARS.taken, missed = AUTHOR_VARS.missed }
authorSwitchDown(SWITCH_COIN1)
authorSwitchUp(SWITCH_COIN1)
authorSwitchDown(SWITCH_START1)
authorSwitchUp(SWITCH_START1)
continued = true
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored branching video, frame %d, taken %d, missed %d, lives %d", disc, AUTHOR_VARS.taken, AUTHOR_VARS.missed, AUTHOR_VARS.lives))
if (shots == 0 and disc >= 125) or (shots == 1 and AUTHOR_VARS.taken > 0) or (shots == 2 and AUTHOR_VARS.lives <= 0) then
shots = shots + 1
singeScreenshot()
end
if (continued and frames > 60) or (frames > GIVE_UP) then
debugPrint(string.format("FMV RESULT taken=%d missed=%d lives=%d credits=%d submitted=%d jumps=%s", before.taken, before.missed, AUTHOR_VARS.lives, AUTHOR_VARS.credits, #submitted, table.concat(jumps, " ")))
if before.taken ~= 1 then debugPrint("FMV FAIL the first move was not taken") end
if before.missed ~= 3 then debugPrint("FMV FAIL three misses should have spent the lives") end
if #submitted ~= 1 then debugPrint("FMV FAIL the score was not sent once") end
if not continued or AUTHOR_VARS.lives ~= 3 then debugPrint("FMV FAIL the continue did not restart with three lives") end
singeQuit()
end
return r
end

179
testScripts/scene69.singe Normal file
View file

@ -0,0 +1,179 @@
-- The editor's milestone 6 (FORGE.md): a picker narrowed by typing, a file field offering the
-- game's files, walk areas and hotspot outlines drawn corner by corner, the dialogue outline, and
-- building with the current room first. Driven by keys and canvas presses, as the others are.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "adv.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/adventure2d.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("EDIT FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local function key(code, character)
forgeKey(character or 0, code.value)
end
local function typeIn(text)
for c in string.gmatch(text, ".") do
key(SCANCODE.A, string.byte(c))
end
end
local function step()
if stage == 1 then
-- A rule's condition picked by typing part of its name.
key(SCANCODE.TAB)
key(SCANCODE.TAB)
key(SCANCODE.N)
key(SCANCODE.C)
typeIn("keyH")
local shown = forgePickItems()
key(SCANCODE.RETURN)
local rule = FORGE.game.rules[#FORGE.game.rules]
if #shown == 1 and rule.when[1][1] == "keyHeld" then
debugPrint("EDIT typing keyH left one condition and ENTER took it: " .. rule.when[1][1])
else
debugPrint("EDIT FAIL the narrowed picker showed " .. #shown .. " and took " .. tostring(rule.when[1] and rule.when[1][1]))
end
elseif stage == 2 then
-- A file field offers the files under the game's directory.
key(SCANCODE.TAB) -- tracks
key(SCANCODE.TAB) -- dialogues
key(SCANCODE.TAB) -- entities
key(SCANCODE.TAB) -- kinds
FORGE.kindName = "hero"
forgeKindSet(FORGE.game.kinds.hero, "look", "sprite")
FORGE.lastField = "look.anchor"
key(SCANCODE.RETURN) -- look.file: the picker
if FORGE.picker == nil then
debugPrint("EDIT FAIL a file field did not open the picker")
end
typeIn("testScripts/strip")
key(SCANCODE.RETURN)
debugPrint("EDIT the hero's sprite file is now " .. tostring(FORGE.game.kinds.hero.look.file))
if FORGE.game.kinds.hero.look.file ~= "testScripts/strip.png" then
debugPrint("EDIT FAIL the file picker did not set the file")
end
forgeKindSet(FORGE.game.kinds.hero, "look", "box")
elseif stage == 3 then
-- A walk area drawn corner by corner, then a hotspot's outline.
key(SCANCODE.TAB) -- rules
key(SCANCODE.TAB) -- tracks
key(SCANCODE.TAB) -- dialogues
key(SCANCODE.TAB) -- entities
forgeSelect(nil)
local walks = #(forgeRoom().walk or {})
key(SCANCODE.V)
forgePress(300, 320)
forgePress(500, 320)
forgePress(400, 400)
key(SCANCODE.V)
if #forgeRoom().walk == walks + 1 and #forgeRoom().walk[walks + 1] == 6 then
debugPrint("EDIT a walk area of three corners was drawn")
else
debugPrint("EDIT FAIL the room has " .. #(forgeRoom().walk or {}) .. " walk areas")
end
for index, entry in ipairs(forgeRoom().entities) do
if entry.id == "door" then
forgeSelect(index)
end
end
key(SCANCODE.V)
forgePress(570, 180)
forgePress(630, 180)
forgePress(630, 300)
forgePress(570, 300)
key(SCANCODE.RETURN)
local spot = FORGE.game.kinds.door.behaviours[1]
if spot.polygon and #spot.polygon == 8 then
debugPrint("EDIT the door's outline has four corners")
else
debugPrint("EDIT FAIL the door's outline is " .. tostring(spot.polygon and #spot.polygon))
end
elseif stage == 4 then
-- The dialogue outline: a node added and named, a choice added, an action picked for it.
key(SCANCODE.TAB) -- kinds
key(SCANCODE.TAB) -- rules
key(SCANCODE.TAB) -- tracks
key(SCANCODE.TAB) -- dialogues
debugPrint("EDIT the outline starts on dialogue '" .. tostring(FORGE.dialogue) .. "' with " .. #forgeDialogueRows() .. " rows")
key(SCANCODE.A) -- a node in the guard's dialogue
key(SCANCODE.RETURN) -- its name
typeIn("bye")
key(SCANCODE.RETURN) -- who
key(SCANCODE.ESCAPE)
key(SCANCODE.A) -- a choice in it
key(SCANCODE.T)
typeIn("addSc")
key(SCANCODE.RETURN)
local node = FORGE.game.dialogues.guard.nodes.bye
if node and node.choices[1] and node.choices[1].act[1][1] == "addScore" then
debugPrint("EDIT a node 'bye' with a choice that adds to the score went into the guard's dialogue")
else
debugPrint("EDIT FAIL the node is " .. tostring(node) .. "; rows " .. #forgeDialogueRows())
end
elseif stage == 5 then
-- Saved, and built with the second room first.
key(SCANCODE.S)
local built = forgeBuild(out .. "adv.singe", 2)
local text = assert(io.open(built, "r")):read("a")
local yard = text:find('name = "yard"', 1, true)
local hall = text:find('name = "hall"', 1, true)
debugPrint("EDIT built from the yard: yard before hall " .. tostring(yard ~= nil and hall ~= nil and yard < hall) .. "; the dialogue carries bye: " .. tostring(text:find('"bye"', 1, true) ~= nil))
if not (yard and hall and yard < hall) then
debugPrint("EDIT FAIL the built game does not start in the yard")
end
debugPrint("EDIT RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 6 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 8, "editor, stage " .. stage)
if frames == 30 or frames == 58 or frames == 72 then
singeScreenshot()
end
if frames > 100 then
singeQuit()
end
return OVERLAY_UPDATED
end

82
testScripts/scene70.singe Normal file
View file

@ -0,0 +1,82 @@
-- The long tail (FORGE.md milestone 7), in 2D: a sprite sheet's frames stepped by state, a
-- grid of tiles drawn from a sheet, and MIDI notes reaching the rules as an event.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "tail.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local seen = {}
local leftSeen = {}
local shots = 0
function onOverlayUpdate()
local runner = authorEntity("runner")
local t = authorTime()
frames = frames + 1
if frames == 2 then
authorKeyDown(0, SCANCODE.RIGHT.value)
elseif frames == 45 then
authorKeyUp(0, SCANCODE.RIGHT.value)
authorKeyDown(0, SCANCODE.LEFT.value)
elseif frames == 60 then
authorMidi(0x90, 60, 100)
authorMidi(0x91, 64, 90)
authorMidi(0x80, 60, 0)
end
if frames > 48 then
leftSeen[runner.vars.frame] = true
else
seen[runner.vars.frame] = true
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored long tail, %.1fs, frame %d, %s", t, runner.vars.frame, runner.vars.state))
if (shots == 0 and frames == 20) or (shots == 1 and frames == 70) then
shots = shots + 1
singeScreenshot()
end
if frames > 90 then
local distinct = 0
for _ in pairs(seen) do
distinct = distinct + 1
end
local floor = authorEntity("floor")
local w, h = authorSize(floor)
debugPrint(string.format("TAIL RESULT frames=%d state=%s score=%d notes=%d grid=%dx%d", distinct, runner.vars.state, AUTHOR_VARS.score, AUTHOR_VARS.notes, w, h))
if distinct < 3 then debugPrint("TAIL FAIL the walk cycle did not step through its frames") end
local leftOnly = true
for frame in pairs(leftSeen) do
if frame > 2 then
leftOnly = false
end
end
debugPrint("TAIL facing left used only the walkLeft frames: " .. tostring(leftOnly))
local spinner = authorEntity("spinner")
debugPrint(string.format("TAIL the spinner began at 45 and is at %.0f degrees; the fire has an emitter: %s", spinner.vars.angle, tostring(authorEntity("fire").emitter ~= nil)))
if spinner.vars.angle < 100 then debugPrint("TAIL FAIL the spinner did not turn") end
local problems = authorCheck({ kinds = { thing = { look = { kind = "box", widthh = 3 }, behaviours = { { kind = "drift", vxx = 1 } } } }, rooms = { { name = "r", entities = {} } }, rules = { { on = "pressed", keyy = "A" } } })
debugPrint("TAIL the checker names " .. #problems .. " misspelt fields: " .. table.concat(problems, " | "))
if #problems ~= 3 then debugPrint("TAIL FAIL expected three misspellings to be named") end
if not leftOnly then debugPrint("TAIL FAIL facing left drew frames outside walkLeft") end
if AUTHOR_VARS.score ~= 10 or AUTHOR_VARS.notes ~= 2 then debugPrint("TAIL FAIL the notes were not counted: score " .. AUTHOR_VARS.score .. " notes " .. AUTHOR_VARS.notes) end
if w ~= 160 or h ~= 96 then debugPrint("TAIL FAIL the grid is not 5 by 3 tiles of 32") end
singeQuit()
end
return r
end

49
testScripts/scene71.singe Normal file
View file

@ -0,0 +1,49 @@
-- The long tail (FORGE.md milestone 7), in 3D: a body floating in water, a boat under its
-- propeller, and a soft body.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/pool.game", singeGetDataPath() .. "pool.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local boatStart = nil
local lowest = math.huge
function onOverlayUpdate()
local t = authorTime()
local ball = authorEntity("ball")
local boat = authorEntity("boat")
local by = authorY(ball)
frames = frames + 1
boatStart = boatStart or authorField(boat, "z")
if t > 2 then
lowest = math.min(lowest, by)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored pool, %.1fs, ball y %.2f, boat z %.1f", t, by, authorField(boat, "z")))
if (shots == 0 and t > 1) or (shots == 1 and t > 4) then
shots = shots + 1
singeScreenshot()
end
if t > 6 then
local moved = boatStart - authorField(boat, "z")
debugPrint(string.format("POOL RESULT ballLowest=%.2f ballNow=%.2f boatMoved=%.1f balloon=%s", lowest, by, moved, tostring(authorEntity("balloon") ~= nil)))
if lowest < -1.5 then debugPrint("POOL FAIL the ball sank to the bottom") end
if moved < 2 then debugPrint("POOL FAIL the boat did not move") end
singeQuit()
end
return r
end

96
testScripts/scene72.singe Normal file
View file

@ -0,0 +1,96 @@
-- The first-person shooter from FORGE.md's catalogue: a camera on the hero's eyes turned by mouse
-- motion, a gun firing from the middle of the picture, keys moving relative to where the camera
-- looks, and enemies walking the mesh after the hero. The mouse is injected to turn toward the
-- nearest zombie's head before each shot.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/fps.game", singeGetDataPath() .. "fps.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local hits = 0
local kills = 0
local lastShot = 0
local walked = false
local startZ = nil
local GIVE_UP = 45
local LOOK_SPEED = 0.2
local realEvent = authorEvent
function authorEvent(name, event)
if name == "hit" then hits = hits + 1 elseif name == "death" then kills = kills + 1 end
return realEvent(name, event)
end
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local eyes = authorEntity("eyes")
local hx, hy, hz = authorPosition(hero)
frames = frames + 1
startZ = startZ or hz
-- A step forward first, to show the keys move relative to the view.
if frames == 5 then
authorKeyDown(0, SCANCODE.W.value)
elseif frames == 40 then
authorKeyUp(0, SCANCODE.W.value)
walked = true
end
-- Turn toward the nearest zombie's head and fire.
if walked and (t - lastShot > 0.3) then
local nearest, best = nil, math.huge
for _, zombie in ipairs(authorEach("zombie")) do
if zombie.vars.state ~= "dead" then
local far = authorDistance3D(zombie, hero)
if far < best then
nearest, best = zombie, far
end
end
end
if nearest then
local head = nodeFind("b_Head_05", nearest.model)
local tx, ty, tz = nodeGetWorldPosition(head)
local ex, ey, ez = authorPosition(eyes)
local wantYaw = math.deg(math.atan(-(tx - ex), -(tz - ez)))
local wantPitch = math.deg(math.atan(ty - ey, math.sqrt((tx - ex) ^ 2 + (tz - ez) ^ 2)))
local dYaw = (eyes.yaw - wantYaw + 540) % 360 - 180
local dPitch = eyes.pitch - wantPitch
authorMouseMoved(0, 0, dYaw / LOOK_SPEED, dPitch / LOOK_SPEED)
if math.abs(dYaw) < 2 then
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
lastShot = t
end
end
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored first person, %.1fs, yaw %.0f, hits %d, kills %d, zombies %d", t, eyes.yaw, hits, kills, authorCount("zombie")))
if (shots == 0 and t > 1) or (shots == 1 and kills > 0) or (shots == 2 and kills >= 4) then
shots = shots + 1
singeScreenshot()
end
local hive = authorEntity("hive")
if (hive.vars.made >= 6 and authorCount("zombie") == 0 and t > 5) or (t > GIVE_UP) then
debugPrint(string.format("FPS RESULT kills=%d hits=%d health=%d bites=%d movedForward=%.1f after=%.1fs", kills, hits, AUTHOR_VARS.health, AUTHOR_VARS.bites, startZ - hz, t))
if kills < 4 then debugPrint("FPS FAIL fewer than four zombies were killed") end
if startZ - hz < 1 then debugPrint("FPS FAIL W did not move the hero forward") end
singeQuit()
end
return r
end

53
testScripts/scene73.singe Normal file
View file

@ -0,0 +1,53 @@
-- Arcade physics (FORGE.md's catalogue): a ball on a 2D body bouncing off static bricks and a
-- kinematic paddle the keys move; bricks broken by collision rules.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
dofile(authorBuild("testScripts/author/breakout.game", singeGetDataPath() .. "breakout.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local paddleStart = nil
local GIVE_UP = 30
function onOverlayUpdate()
local t = authorTime()
local ball = authorEntity("ball")
local paddle = authorEntity("paddle")
local bx, by = authorPosition(ball)
local px = authorX(paddle)
frames = frames + 1
paddleStart = paddleStart or px
-- The paddle chases the ball, which is what keeps it in play.
if bx < px - 10 then
authorKeyUp(0, SCANCODE.RIGHT.value)
authorKeyDown(0, SCANCODE.LEFT.value)
elseif bx > px + 10 then
authorKeyUp(0, SCANCODE.LEFT.value)
authorKeyDown(0, SCANCODE.RIGHT.value)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored breakout, %.1fs, ball %.0f,%.0f, broken %d, lives %d", t, bx, by, AUTHOR_VARS.broken, AUTHOR_VARS.lives))
if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.broken > 0) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.broken >= 2) or (t > GIVE_UP) then
debugPrint(string.format("BREAKOUT RESULT broken=%d lives=%d paddleMoved=%s after=%.1fs", AUTHOR_VARS.broken, AUTHOR_VARS.lives, tostring(math.abs(px - paddleStart) > 5), t))
if AUTHOR_VARS.broken < 1 then debugPrint("BREAKOUT FAIL no brick was broken") end
singeQuit()
end
return r
end

51
testScripts/scene74.singe Normal file
View file

@ -0,0 +1,51 @@
-- A maze (FORGE.md's catalogue): a tile map for the walls, corridors that are a walk area, a
-- hero sent by a click, dots eaten on touch, and a ghost that follows the hero across the mesh.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
dofile(authorBuild("testScripts/author/maze.game", singeGetDataPath() .. "maze.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local gapStart = nil
local GIVE_UP = 25
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local ghost = authorEntity("ghost")
local gap = authorDistance(hero, ghost)
frames = frames + 1
gapStart = gapStart or gap
if frames == 10 then
-- A click at the far end of the top corridor.
authorSetPointer(1, 340, 180, true)
authorSwitchDown(SWITCH_BUTTON3)
authorSwitchUp(SWITCH_BUTTON3)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored maze, %.1fs, hero %.0f,%.0f, ghost gap %.0f, eaten %d", t, authorX(hero), authorY(hero), gap, AUTHOR_VARS.eaten))
if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.eaten > 0) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.eaten >= 2 and gap < gapStart - 50) or (t > GIVE_UP) then
debugPrint(string.format("MAZE RESULT eaten=%d gap=%.0f (from %.0f) caught=%s after=%.1fs", AUTHOR_VARS.eaten, gap, gapStart, tostring(AUTHOR_VARS.caught), t))
if AUTHOR_VARS.eaten < 2 then debugPrint("MAZE FAIL the hero did not eat two dots on its way") end
if gap >= gapStart - 50 then debugPrint("MAZE FAIL the ghost did not close on the hero") end
singeQuit()
end
return r
end

77
testScripts/scene75.singe Normal file
View file

@ -0,0 +1,77 @@
-- Third-person action (FORGE.md's catalogue): a camera orbiting the hero under injected mouse
-- motion, keys moving the hero relative to it, enemies following across the mesh, and a sword
-- swung by a key at what is in reach.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/thirdperson.game", singeGetDataPath() .. "thirdperson.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local kills = 0
local yawStart = nil
local startX = nil
local lastSwing = 0
local GIVE_UP = 40
local realEvent = authorEvent
function authorEvent(name, event)
if name == "death" then kills = kills + 1 end
return realEvent(name, event)
end
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local camera = authorEntity("camera")
local hx, hy, hz = authorPosition(hero)
frames = frames + 1
yawStart = yawStart or camera.yaw
startX = startX or hx
-- Orbit the camera a quarter turn over the first second, then walk forward for two.
if frames >= 5 and frames < 35 then
authorMouseMoved(0, 0, 15, 0)
elseif frames == 35 then
authorKeyDown(0, SCANCODE.W.value)
elseif frames == 155 then
authorKeyUp(0, SCANCODE.W.value)
end
-- Swing at anything close.
if (t - lastSwing > 0.4) then
for _, enemy in ipairs(authorEach("enemy")) do
if (enemy.vars.state ~= "dead") and (authorDistance3D(enemy, hero) < 2.2) then
authorKeyDown(0, SCANCODE.SPACE.value)
authorKeyUp(0, SCANCODE.SPACE.value)
lastSwing = t
break
end
end
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored third person, %.1fs, yaw %.0f, hero %.1f,%.1f, kills %d, swings %d", t, camera.yaw, hx, hz, kills, AUTHOR_VARS.swings))
if (shots == 0 and frames == 40) or (shots == 1 and kills > 0) then
shots = shots + 1
singeScreenshot()
end
local hive = authorEntity("hive")
if (hive.vars.made >= 4 and authorCount("enemy") == 0 and t > 5) or (t > GIVE_UP) then
debugPrint(string.format("TPS RESULT kills=%d swings=%d health=%d yawTurned=%.0f movedX=%.1f after=%.1fs", kills, AUTHOR_VARS.swings, AUTHOR_VARS.health, camera.yaw - yawStart, hx - startX, t))
if kills < 3 then debugPrint("TPS FAIL fewer than three enemies were cut down") end
if math.abs(camera.yaw - yawStart) < 60 then debugPrint("TPS FAIL the camera did not orbit") end
if math.abs(hx - startX) < 1 then debugPrint("TPS FAIL walking forward after the orbit did not move the hero sideways in the world") end
singeQuit()
end
return r
end

49
testScripts/scene76.singe Normal file
View file

@ -0,0 +1,49 @@
-- A space shooter (FORGE.md's catalogue): a ship under thrust with no gravity, bolts fired
-- forward, rocks coming at it broken by collision rules, and a camera behind.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/space.game", singeGetDataPath() .. "space.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local startZ = nil
local maxBolts = 0
local GIVE_UP = 40
function onOverlayUpdate()
local t = authorTime()
local ship = authorEntity("ship")
local sx, sy, sz = authorPosition(ship)
frames = frames + 1
startZ = startZ or sz
if frames == 3 then
authorKeyDown(0, SCANCODE.UP.value)
authorKeyDown(0, SCANCODE.SPACE.value)
end
maxBolts = math.max(maxBolts, authorCount("bolt"))
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored space, %.1fs, ship z %.1f, bolts %d, rocks %d, broken %d", t, sz, authorCount("bolt"), authorCount("rock"), AUTHOR_VARS.rocks))
if (shots == 0 and t > 2) or (shots == 1 and AUTHOR_VARS.rocks > 0) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.rocks >= 3) or (t > GIVE_UP) then
debugPrint(string.format("SPACE RESULT broken=%d movedForward=%.1f maxBolts=%d after=%.1fs", AUTHOR_VARS.rocks, startZ - sz, maxBolts, t))
if AUTHOR_VARS.rocks < 1 then debugPrint("SPACE FAIL no rock was broken") end
if startZ - sz < 3 then debugPrint("SPACE FAIL the thrust did not move the ship") end
singeQuit()
end
return r
end

52
testScripts/scene77.singe Normal file
View file

@ -0,0 +1,52 @@
-- A rhythm game (FORGE.md's catalogue): notes that fall to a marker, a press inside the window
-- scoring and one outside missing, over a music layer.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
dofile(authorBuild("testScripts/author/rhythm.game", singeGetDataPath() .. "rhythm.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local pressed = {}
local GIVE_UP = 8
function onOverlayUpdate()
local t = authorTime()
frames = frames + 1
-- Press when a note is at the marker, once per note, and once at nothing.
for _, note in ipairs(authorEach("note")) do
if (math.abs(authorY(note) - 400) < 12) and not pressed[note.id] then
pressed[note.id] = true
authorKeyDown(0, SCANCODE.SPACE.value)
authorKeyUp(0, SCANCODE.SPACE.value)
end
end
if frames == 10 then
authorKeyDown(0, SCANCODE.SPACE.value)
authorKeyUp(0, SCANCODE.SPACE.value)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored rhythm, %.1fs, notes %d, hits %d, misses %d, music %s", t, authorCount("note"), AUTHOR_VARS.hits, AUTHOR_VARS.misses, tostring(musicIsPlaying())))
if (shots == 0 and t > 1.5) or (shots == 1 and AUTHOR_VARS.hits > 0) then
shots = shots + 1
singeScreenshot()
end
if t > GIVE_UP then
debugPrint(string.format("RHYTHM RESULT hits=%d misses=%d score=%d", AUTHOR_VARS.hits, AUTHOR_VARS.misses, AUTHOR_VARS.score))
if AUTHOR_VARS.hits < 3 then debugPrint("RHYTHM FAIL fewer than three notes were hit") end
singeQuit()
end
return r
end

43
testScripts/scene78.singe Normal file
View file

@ -0,0 +1,43 @@
-- Bowling (FORGE.md's catalogue): a ball shoved down a lane by a press, pins that are bodies
-- counted as they fall, and a trigger at the end of the lane.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/bowling.game", singeGetDataPath() .. "bowling.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local GIVE_UP = 20
function onOverlayUpdate()
local t = authorTime()
local ball = authorEntity("ball")
frames = frames + 1
if frames == 5 then
authorKeyDown(0, SCANCODE.SPACE.value)
authorKeyUp(0, SCANCODE.SPACE.value)
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored bowling, %.1fs, ball z %.1f, pins down %d", t, authorField(ball, "z"), AUTHOR_VARS.down))
if (shots == 0 and t > 0.5) or (shots == 1 and AUTHOR_VARS.down > 0) then
shots = shots + 1
singeScreenshot()
end
if (AUTHOR_VARS.done and t > 3) or (t > GIVE_UP) then
debugPrint(string.format("BOWL RESULT down=%d done=%s ballZ=%.1f after=%.1fs", AUTHOR_VARS.down, tostring(AUTHOR_VARS.done), authorField(ball, "z"), t))
if AUTHOR_VARS.down < 1 then debugPrint("BOWL FAIL no pin fell") end
singeQuit()
end
return r
end

51
testScripts/scene79.singe Normal file
View file

@ -0,0 +1,51 @@
-- Occluders (FORGE.md): a painted room in 3D with a pillar that is only in the picture. The
-- pillar is an invisible box that writes depth alone, so the picture shows through it and the hero
-- disappears behind it on the way across. Three shots: before, behind, after.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/painted.game", singeGetDataPath() .. "painted.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
local GIVE_UP = 12
function onOverlayUpdate()
local t = authorTime()
local hero = authorEntity("hero")
local hx = authorX(hero)
frames = frames + 1
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("authored painted room, %.1fs, hero x %.1f, pillar %s", t, hx, hero.material and "" or "is an occluder"))
if (shots == 0 and hx > -3.5) or (shots == 1 and hx > -0.3) or (shots == 2 and hx > 3) then
shots = shots + 1
singeScreenshot()
end
if AUTHOR_VARS.across or (t > GIVE_UP) then
local pillar = authorEntity("pillar")
local _, ry, _ = nodeGetRotation(authorEntity("far").node)
debugPrint(string.format("PAINTED RESULT across=%s heroX=%.1f occluderCall=%s pillarMaterial=%s markerYaw=%.0f", tostring(AUTHOR_VARS.across), hx, tostring(type(materialSetOccluder) == "function"), tostring(pillar.material ~= nil), ry))
if math.abs(ry - 90) > 1 then debugPrint("PAINTED FAIL the marker's ry did not reach its node") end
local sx = nodeGetScale(authorEntity("far").node)
local w = authorSize(authorEntity("far"))
debugPrint(string.format("PAINTED the marker's scale of 2 gives node scale %.1f and a counted width of %.1f", sx, w))
if math.abs(sx - 2) > 0.01 then debugPrint("PAINTED FAIL the scale did not reach the node") end
if not AUTHOR_VARS.across then debugPrint("PAINTED FAIL the hero did not cross") end
singeQuit()
end
return r
end

131
testScripts/scene80.singe Normal file
View file

@ -0,0 +1,131 @@
-- Gizmos in the 3D viewport (FORGE.md): the selected entity carries three arms along the world
-- axes, and dragging an arm's handle moves the entity along that axis alone -- the pointer's
-- travel along the arm, as a fraction of the arm, is the distance moved.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "gizmo.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/railshooter.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("GIZMO FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local function handle(axis)
for _, item in ipairs(forgeGizmoHandles()) do
if item.axis == axis then
return item
end
end
return nil
end
local function step()
local room = forgeRoom()
if stage == 1 then
for index, entry in ipairs(room.entities) do
if entry.id == "wallL" then
forgeSelect(index)
end
end
local arms = forgeGizmoHandles()
debugPrint("GIZMO the selected wall shows " .. #arms .. " arms")
if #arms ~= 4 then
debugPrint("GIZMO FAIL expected three axis arms and the turn handle")
end
elseif stage == 2 then
-- Drag the Y handle the length of its own arm: the wall rises one arm's length (1.5) and
-- nothing else about it changes.
local entry = room.entities[FORGE.selected]
local was = { x = entry.x, y = entry.y, z = entry.z }
local arm = handle("y")
forgePress(arm.tx, arm.ty)
forgeDrag(arm.tx + (arm.tx - arm.sx), arm.ty + (arm.ty - arm.sy))
forgeRelease()
debugPrint(string.format("GIZMO the Y arm took the wall from %.1f to %.1f; x %.1f to %.1f, z %.1f to %.1f", was.y, entry.y, was.x, entry.x, was.z, entry.z))
if math.abs(entry.y - (was.y + 1.5)) > 0.2 or entry.x ~= was.x or entry.z ~= was.z then
debugPrint("GIZMO FAIL the Y drag did not move along Y alone")
end
elseif stage == 3 then
-- And the X handle, dragged half an arm back.
local entry = room.entities[FORGE.selected]
local was = entry.x
local arm = handle("x")
forgePress(arm.tx, arm.ty)
forgeDrag(arm.tx - (arm.tx - arm.sx) / 2, arm.ty - (arm.ty - arm.sy) / 2)
forgeRelease()
debugPrint(string.format("GIZMO the X arm took the wall from %.1f to %.1f", was, entry.x))
if math.abs(entry.x - (was - 0.75)) > 0.2 then
debugPrint("GIZMO FAIL the X drag did not move half an arm back")
end
forgeKey(0, SCANCODE.U.value)
forgeKey(0, SCANCODE.U.value)
-- Undo puts a copy of the description in place, so the room is looked up again.
local back = forgeRoom().entities[FORGE.selected]
debugPrint(string.format("GIZMO two undos: the wall is back at x %.1f, y %.1f", back.x, back.y))
if back.x ~= -6 or back.y ~= 1.5 then
debugPrint("GIZMO FAIL undo did not put the wall back")
end
elseif stage == 4 then
-- The turn handle: dragged a quarter turn round the entity on screen.
local entry = forgeRoom().entities[FORGE.selected]
local arm = handle("ry")
local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy
forgePress(arm.tx, arm.ty)
forgeDrag(arm.sx - dy, arm.sy + dx)
forgeRelease()
debugPrint(string.format("GIZMO the turn handle set ry to %s", tostring(entry.ry)))
if (entry.ry == nil) or (math.abs(((entry.ry - 270 + 180) % 360) - 180) > 15) then
debugPrint("GIZMO FAIL a quarter turn clockwise on screen should read about 270")
end
debugPrint("GIZMO RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 5 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 8, "gizmos, stage " .. stage)
if frames == 20 or frames == 34 then
singeScreenshot()
end
if frames > 80 then
singeQuit()
end
return OVERLAY_UPDATED
end

73
testScripts/scene81.singe Normal file
View file

@ -0,0 +1,73 @@
-- Facing and turning sprites in a Forge game: a sheet drawn facing right only is mirrored by
-- the engine while its instance faces left (the runner beside it has frames drawn both ways and
-- is not), a sheet entity with an rz draws its frame turned, and clips play at a volume of their
-- own under the game's.
dofile("Forge/AuthorCompile.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
dofile(authorBuild("testScripts/author/tail.game", singeGetDataPath() .. "facing.singe"))
local gameUpdate = onOverlayUpdate
local frames = 0
local shots = 0
function onOverlayUpdate()
local runner = authorEntity("runner")
local mirror = authorEntity("mirror")
local tilted = authorEntity("tilted")
frames = frames + 1
if frames == 2 then
authorKeyDown(0, SCANCODE.RIGHT.value)
elseif frames == 30 then
authorKeyUp(0, SCANCODE.RIGHT.value)
authorKeyDown(0, SCANCODE.LEFT.value)
elseif frames == 40 then
-- The playSound action with a volume, and the sound behaviour's, both under the master.
authorMidi(0x90, 60, 100)
authorBehaviourEvent(mirror, "hit")
local channel = soundPlay(soundLoad("testScripts/crackle.wav"), 0, 20)
soundSetChannelVolume(channel, 50)
soundSetVolume(40)
debugPrint("FACING a clip on channel " .. channel .. " at its own volume under a master of " .. soundGetVolume())
end
local r = gameUpdate()
colorForeground(255, 255, 255, 255)
fontPrint(12, 450, string.format("facing: runner %s, mirror %s%s, tilted %d degrees", tostring(runner.vars.facing), tostring(mirror.vars.facing), authorMirrored(mirror) and " (mirrored)" or "", tilted.vars.angle or 0))
if (shots == 0 and frames == 20) or (shots == 1 and frames == 55) then
shots = shots + 1
singeScreenshot()
end
if frames == 20 then
debugPrint(string.format("FACING going right: runner mirrored %s, mirror mirrored %s", tostring(authorMirrored(runner)), tostring(authorMirrored(mirror))))
if authorMirrored(runner) or authorMirrored(mirror) then
debugPrint("FACING FAIL nothing should be mirrored while facing right")
end
debugPrint(string.format("FACING the tilted sheet has an image of its own: %s, turned %d", tostring(tilted.ownSprite ~= nil), tilted.ownAngle or -1))
if (tilted.ownSprite == nil) or (tilted.ownAngle ~= 30) then
debugPrint("FACING FAIL the sheet entity with rz 30 should draw a turned frame")
end
elseif frames == 55 then
debugPrint(string.format("FACING going left: runner mirrored %s (drawn %s), mirror mirrored %s", tostring(authorMirrored(runner)), tostring(runner.drawnFacing), tostring(authorMirrored(mirror))))
if authorMirrored(runner) or not authorMirrored(mirror) then
debugPrint("FACING FAIL the sheet without left frames should be mirrored, the one with them not")
end
if mirror.ownSprite == nil then
debugPrint("FACING FAIL the mirrored instance should draw from an image of its own")
end
debugPrint("FACING RESULT done")
end
if frames > 70 then
singeQuit()
end
return r
end

111
testScripts/scene82.singe Normal file
View file

@ -0,0 +1,111 @@
-- The 2D canvas's turn handle and the kinds panel's thumbnails (FORGE.md): the selected entity
-- carries a yellow arm the way its rz points, dragged round it to turn it, as the scene's ry arm
-- does; and each kind in the panel shows what it looks like beside its name.
FORGE_LIBRARY = true
dofile("Forge/Forge.singe")
local font = fontLoad("Singe/FreeSansBold.ttf", 15)
local out = singeGetDataPath()
local work = out .. "turn.game"
fontSelect(font)
fontQuality(FONT_QUALITY_BLENDED)
overlaySetResolution(720, 480)
local source = assert(io.open("testScripts/author/tail.game", "r"))
local copy = assert(io.open(work, "w"))
copy:write(source:read("a"))
source:close()
copy:close()
if not forgeBegin(work) then
debugPrint("TURN FAIL could not open " .. work)
singeQuit()
end
local frames = 0
local stage = 0
local function selectEntity(id)
for index, entry in ipairs(forgeRoom().entities) do
if entry.id == id then
forgeSelect(index)
end
end
end
local function step()
if stage == 1 then
selectEntity("spinner")
local arm = forgeTurnHandle()
debugPrint(string.format("TURN the spinner, rz 45, shows its handle at %.0f, %.0f from %.0f, %.0f", arm and arm.tx or -1, arm and arm.ty or -1, arm and arm.sx or -1, arm and arm.sy or -1))
if (arm == nil) or (arm.tx <= arm.sx) or (arm.ty <= arm.sy) then
debugPrint("TURN FAIL a handle at 45 degrees lies down and right of the entity")
end
elseif stage == 2 then
-- A quarter turn clockwise round the entity: the handle's offset turned 90 degrees.
local entry = forgeRoom().entities[FORGE.selected]
local arm = forgeTurnHandle()
local dx, dy = arm.tx - arm.sx, arm.ty - arm.sy
forgePress(arm.tx, arm.ty)
forgeDrag(arm.sx - dy, arm.sy + dx)
forgeRelease()
debugPrint(string.format("TURN a quarter turn clockwise took rz from 45 to %s", tostring(entry.rz)))
if (entry.rz == nil) or (math.abs(entry.rz - 135) > 3) then
debugPrint("TURN FAIL expected about 135")
end
elseif stage == 3 then
-- Undo puts it back, and a sheet entity with an rz draws its first frame turned.
forgeKey(0, SCANCODE.U.value)
local back = forgeRoom().entities[FORGE.selected]
debugPrint("TURN undone, rz " .. tostring(back.rz))
if back.rz ~= 45 then
debugPrint("TURN FAIL undo should give 45 back")
end
selectEntity("tilted")
elseif stage == 4 then
-- The kinds panel: a thumbnail in front of every name.
while FORGE.mode ~= "kinds" do
forgeKey(0, SCANCODE.TAB.value)
end
forgeRefresh()
local rml = rmlui.contexts["gui" .. FORGE.gui].documents["forge"]:GetElementById("list").inner_rml
local images = select(2, rml:gsub("<img", ""))
local swatch = select(2, rml:gsub("swatch", ""))
debugPrint(string.format("TURN the kinds panel shows %d sprite thumbnails and %d swatches", images, swatch))
if (images < 2) or (swatch < 1) then
debugPrint("TURN FAIL the sprite kinds want images and the fire wants a swatch")
end
debugPrint("TURN RESULT done")
end
end
function onOverlayUpdate()
frames = frames + 1
if frames % 14 == 0 and stage < 5 then
stage = stage + 1
step()
end
forgeDraw(nil, nil)
colorForeground(255, 255, 255, 255)
fontPrint(210, 8, "2D turn handle, stage " .. stage)
if frames == 20 or frames == 62 then
singeScreenshot()
end
if frames > 80 then
singeQuit()
end
return OVERLAY_UPDATED
end

68
util/forgeVocabulary.lua Normal file
View file

@ -0,0 +1,68 @@
-- Writes the Forge vocabulary -- every layer, look, behaviour, event, condition, and action the
-- manifest in assets/Forge/Author.singe declares -- as AsciiDoc, for docs/Forge.adoc to include.
-- The manifest is the one source of truth; this keeps the manual from drifting from it.
--
-- lua5.4 util/forgeVocabulary.lua > docs/ForgeVocabulary.adoc
--
-- Author.singe touches nothing of the engine when it is loaded, only when a game begins, so plain
-- Lua can read it.
local function keysOf(t)
local keys = {}
for key in pairs(t or {}) do
keys[#keys + 1] = key
end
table.sort(keys)
return keys
end
local function paramsText(params)
local parts = {}
for _, key in ipairs(keysOf(params)) do
parts[#parts + 1] = "`" .. key .. "` (" .. params[key] .. ")"
end
if #parts == 0 then
return "--"
end
return table.concat(parts, ", ")
end
local function section(title, set, extra)
print("=== " .. title)
print("")
print('[cols="1,3,2' .. (extra and ',1' or '') .. '"]')
print("|===")
print("| Name | What it does | Parameters" .. (extra and (" | " .. extra) or ""))
print("")
for _, name in ipairs(keysOf(set)) do
local entry = set[name]
print("| `" .. name .. "`")
print("| " .. (entry.help or ""))
print("| " .. paramsText(entry.params or entry.filter))
if extra then
print("| " .. (entry.waits and "yes" or ""))
end
print("")
end
print("|===")
print("")
end
dofile("assets/Forge/Author.singe")
print("// Generated by util/forgeVocabulary.lua from assets/Forge/Author.singe. Do not edit.")
print("")
section("Layers", AUTHOR.layers)
section("Looks", AUTHOR.looks)
section("Behaviours", AUTHOR.behaviours)
section("Events", AUTHOR.events)
section("Conditions", AUTHOR.conditions)
section("Actions", AUTHOR.actions, "Waits")