singe/util/forgePortActionMax.py
2026-09-22 18:32:07 -05:00

294 lines
18 KiB
Python

#!/usr/bin/env python3
# Writes a Forge description for each ActionMax game beside its original script, from the
# original's own numbers: the parameter file (video lengths, the sensor spot, the thresholds)
# and the sprites' sizes, laid out as Emulator.singe lays them out. Text the emulator renders
# to sprites is measured with its font here, which can sit a pixel from what SDL_ttf measures.
#
# python3 util/forgePortActionMax.py ~/claude/singetest/ActionMax
#
# The descriptions are data: types, rooms (one per state of the emulator), and rules. Run one
# with the port scene in testScripts/ports/, or open it in Forge.
import os
import re
import sys
from PIL import Image, ImageFont
WIDTH = 360 # The overlay the emulator lays itself out on: the engine's default over its
HEIGHT = 240 # video, which every number in it -- the sensor spot included -- assumes.
GAMES = ["38AmbushAlley", "BlueThunder", "Hydrosub2021", "PopsGhostly", "SonicFury"]
TITLES = {
"38AmbushAlley": ".38 Ambush Alley",
"BlueThunder": "Blue Thunder",
"Hydrosub2021": "Hydrosub: 2021",
"PopsGhostly": "Rescue of Pops Ghostly, The",
"SonicFury": "Sonic Fury",
}
AMMO = 5 # Misses allowed in limited ammo mode.
SCORE_SHOWN = 4 # Seconds the floating score stays, as the emulator's heartbeat counts.
LIGHT_SHOWN = 2 # Seconds the light stays lit after a good hit.
SCORE_HEIGHT = 13 # The emulator's line height for the last game's figures.
def parameters(path):
values = {}
for line in open(path):
m = re.match(r"^\s*(\w+)\s*=\s*(\"?)([^\"\s]+)\2\s*$", line)
if m:
key, value = m.group(1), m.group(3)
values[key] = value if m.group(2) else int(value)
return values
def size(folder, name):
with Image.open(os.path.join(folder, name)) as im:
return im.size
def textSize(folder, font, points, text):
face = ImageFont.truetype(os.path.join(folder, font), points)
left, top, right, bottom = face.getbbox(text)
ascent, descent = face.getmetrics()
return right - left, ascent + descent
def lua(value):
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return repr(value) if isinstance(value, float) else str(value)
return '"' + value.replace('"', '\\"') + '"'
def describe(folder, game):
p = parameters(os.path.join(folder, game + ".singe"))
half = WIDTH / 2
quarter = half / 2
intro = p["lengthIntro"]
length = p["lengthGame"]
menu = p["lengthMenu"]
logoW, logoH = size(folder, "sprite_ActionMax.png")
boxW, boxH = size(folder, "sprite_" + game + ".png")
lightW, lightH = size(folder, "sprite_LightOff.png")
bulletW, bulletH = size(folder, "sprite_Bullet.png")
crossW, crossH = size(folder, "sprite_Crosshair.png")
pullW, pullH = textSize(folder, "font_BlueStone.ttf", 20, "Pull Trigger to Start!")
lastW, lastH = textSize(folder, "font_chemrea.ttf", 16, "LAST GAME SCORE")
# Emulator.singe's setup state, number for number.
logoTop = 5
logoHeight = logoTop + logoH
boxTop = (HEIGHT - (logoHeight + pullH)) / 2 - boxH / 2 + logoHeight
lastGameLeft = quarter - lastW / 2
lastGameTop = logoHeight + logoTop
scoreTop = lastGameTop + SCORE_HEIGHT + 10
menuVideo = intro + length + 2
menuEnd = intro + length + menu
def entity(kind, ident, x, y):
return "\t\t\t{ type = %s, id = %s, x = %s, y = %s }" % (lua(kind), lua(ident), lua(x), lua(y))
def text(name, words, r, g, b, font=None, points=None, anchor=None):
look = "look = { kind = \"text\", text = %s, r = %d, g = %d, b = %d" % (lua(words), r, g, b)
if font:
look += ", font = %s, size = %d" % (lua(font), points)
if anchor:
look += ", anchor = %s" % lua(anchor)
return "\t\t%s = { %s } }," % (name, look)
types = [
"\t\tlogo = { look = { kind = \"sprite\", file = \"sprite_ActionMax.png\" } },",
"\t\tboxArt = { look = { kind = \"sprite\", file = %s } }," % lua("sprite_" + game + ".png"),
"\t\tlightOff = { look = { kind = \"sprite\", file = \"sprite_LightOff.png\" } },",
"\t\tlightOn = { look = { kind = \"sprite\", file = \"sprite_LightOn.png\" } },",
"\t\tbullets = { look = { kind = \"sprite\", file = \"sprite_Bullet.png\", step = %d }, vars = { copies = 0 } }," % (-bulletW),
"\t\tcross = { look = { kind = \"sprite\", file = \"sprite_Crosshair.png\" }, behaviours = { { kind = \"pointer\", player = 1 } } },",
"\t\tsensor = { look = { kind = \"none\" },",
"\t\t behaviours = { { kind = \"lightSensor\", x = %d, y = %d, left = %d, top = %d, high = %d, low = %d, trigger = \"SWITCH_BUTTON3\", player = 1 },"
% (p["sensorX"], p["sensorY"], p["sensorLeft"], p["sensorTop"], p["highThreshold"], p["lowThreshold"]),
"\t\t { kind = \"sound\", fire = \"sound_Gunshot.wav\", hit = \"sound_GoodHit.wav\", miss = \"sound_BadHit.wav\" } } },",
"\t\tjingle = { look = { kind = \"none\" }, behaviours = { { kind = \"sound\", spawn = \"sound_ActionMax.wav\" } } },",
text("pull", "Pull Trigger to Start!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"),
text("ready", "Get Ready!", 255, 255, 0, "font_BlueStone.ttf", 20, "feet"),
text("lastGame", "LAST GAME SCORE", 255, 255, 255, "font_chemrea.ttf", 16, "top"),
text("figure", "", 200, 200, 200, "font_chemrea.ttf", 16),
text("choose", "Select Game Type", 255, 255, 0, "font_chemrea.ttf", 32, "top"),
text("standard1", "Standard", 255, 255, 0, "font_chemrea.ttf", 32, "feet"),
text("standard2", "Game", 255, 255, 0, "font_chemrea.ttf", 32, "top"),
text("limited1", "Limited", 255, 255, 0, "font_chemrea.ttf", 32, "feet"),
text("limited2", "Ammo", 255, 255, 0, "font_chemrea.ttf", 32, "top"),
text("over", "GAME OVER", 255, 0, 0, "font_chemrea.ttf", 48, "feet"),
text("floating", "", 255, 0, 0, "font_LED_Real.ttf", 32),
]
# The last game's figures, five lines from the emulator's title state.
figures = [
("fired", " Shots Fired: ", "shots", 0),
("good", " Good Hits: ", "good", 1),
("bad", " Bad Hits: ", "bad", 2),
("shot", " Shot Score: ", "good - bad", 3),
("percent", " Game Score: ", "percent", 5),
]
titleEntities = [
entity("logo", "logo", half, logoTop + logoH / 2),
entity("boxArt", "boxArt", quarter + half, boxTop + boxH / 2),
entity("lastGame", "lastGame", quarter, lastGameTop),
entity("pull", "pull", half, HEIGHT),
entity("cross", "cross", 0, 0),
entity("sensor", "sensor", 0, 0),
]
for ident, _, _, line in figures:
titleEntities.append(entity("figure", ident, lastGameLeft, scoreTop + SCORE_HEIGHT * line))
figureRules = []
for ident, label, expr, _ in figures:
if expr == "percent":
expr = "shots > 0 and floor((good - bad) / shots * 100) or 0"
words = "%s .. (%s) .. \"%%\"" % (lua(label), expr)
else:
words = "%s .. (%s)" % (lua(label), expr.replace("fired", "shots"))
figureRules.append("\t\t{ note = %s, on = \"frame\", room = \"title\", act = { { \"setText\", entity = %s, text = %s } } },"
% (lua("The last game's " + ident), lua(ident), lua(words)))
out = []
out.append("-- %s, ported to Forge from ActionMax/Emulator.singe by util/forgePortActionMax.py: the" % TITLES[game])
out.append("-- emulator's states are rooms, its numbers are these, and a shot is judged by the light sensor.")
out.append("return {")
out.append("\ttitle = %s," % lua(TITLES[game]))
out.append("\tplayers = 1,")
out.append("\tsize = { w = %d, h = %d }," % (WIDTH, HEIGHT))
out.append("\tlayers = { { kind = \"disc\", file = %s }, { kind = \"overlay\" } }," % lua("frame_" + game + ".txt"))
out.append("\tvocabulary = \"Vocabulary.singe\", -- The light sensor, these games' own (beside them).")
out.append("\tvars = { shots = 0, good = 0, bad = 0, ammo = 0, limited = false, beat = 0, scoreShown = 0, lightShown = 0 },")
out.append("")
out.append("\ttypes = {")
out.extend(types)
out.append("\t},")
out.append("")
out.append("\trooms = {")
out.append("\t\t{ name = \"startup\", reset = true,")
out.append("\t\t entities = {")
out.append(entity("jingle", "jingle", 0, 0))
out.append("\t\t } },")
out.append("\t\t{ name = \"title\", reset = true,")
out.append("\t\t entities = {")
out.append(",\n".join(titleEntities))
out.append("\t\t } },")
out.append("\t\t{ name = \"menu\", reset = true,")
out.append("\t\t entities = {")
out.append(",\n".join([
entity("choose", "choose", half, 25),
entity("standard1", "standard1", quarter, HEIGHT / 2 + 2),
entity("standard2", "standard2", quarter, HEIGHT / 2 + 2),
entity("limited1", "limited1", half + quarter, HEIGHT / 2 + 2),
entity("limited2", "limited2", half + quarter, HEIGHT / 2 + 2),
entity("cross", "cross", 0, 0),
entity("sensor", "sensor", 0, 0),
]))
out.append("\t\t } },")
out.append("\t\t{ name = \"intro\", reset = true,")
out.append("\t\t entities = {")
out.append(",\n".join([
entity("ready", "ready", half, HEIGHT),
entity("cross", "cross", 0, 0),
entity("sensor", "sensor", 0, 0),
]))
out.append("\t\t } },")
out.append("\t\t{ name = \"playing\", reset = true,")
out.append("\t\t entities = {")
out.append(",\n".join([
entity("floating", "floating", 5, 5),
entity("lightOff", "lightOff", WIDTH - lightW / 2, HEIGHT - lightH / 2),
entity("lightOn", "lightOn", WIDTH - lightW / 2, HEIGHT - lightH / 2),
entity("bullets", "bullets", WIDTH - bulletW - 5 + bulletW / 2, bulletH / 2),
entity("cross", "cross", 0, 0),
entity("sensor", "sensor", 0, 0),
]))
out.append("\t\t } },")
out.append("\t\t{ name = \"gameOver\", reset = true,")
out.append("\t\t entities = {")
out.append(",\n".join([
entity("over", "over", half, HEIGHT / 2),
entity("cross", "cross", 0, 0),
entity("sensor", "sensor", 0, 0),
]))
out.append("\t\t } }")
out.append("\t},")
out.append("")
rules = [
# Startup: the jingle, then "A steady aim is critical", and the title behind a still.
"\t\t{ note = \"The jingle plays over the title's still\", on = \"roomStart\", room = \"startup\",",
"\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"],
"\t\t{ note = \"A steady aim is critical, once the jingle is done\", on = \"soundDone\", file = \"sound_ActionMax.wav\",",
"\t\t act = { { \"playSound\", file = \"sound_ASteadyAimIsCritical.wav\" } } },",
# The heartbeat: a second's tick, as the emulator counts its displays.
"\t\t{ note = \"The heartbeat, once a second\", on = \"frame\", when = { { \"every\", seconds = 1, tag = \"beat\" } },",
"\t\t act = { { \"addVar\", name = \"beat\", amount = 1 }, { \"addVar\", name = \"scoreShown\", amount = -1 }, { \"addVar\", name = \"lightShown\", amount = -1 } } },",
"\t\t{ note = \"Pull to start blinks with the heartbeat\", on = \"frame\", room = \"title\", when = { { \"test\", expr = \"beat % 2 == 1\" } },",
"\t\t act = { { \"show\", entity = \"pull\", visible = true } } },",
"\t\t{ note = \"Pull to start blinks off\", on = \"frame\", room = \"title\", when = { { \"test\", expr = \"beat % 2 == 0\" } },",
"\t\t act = { { \"show\", entity = \"pull\", visible = false } } },",
]
rules.extend(figureRules)
rules.extend([
# Title -> menu.
"\t\t{ note = \"The trigger on the title starts the menu video\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"title\",",
"\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"goTo\", room = \"menu\" } } }," % menuVideo,
"\t\t{ note = \"The menu video loops\", on = \"frame\", room = \"menu\", when = { { \"test\", expr = \"frame >= %d\" } }," % menuEnd,
"\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" } } }," % menuVideo,
# Menu -> intro: the half of the picture the gun points at chooses the mode.
"\t\t{ note = \"A standard game, from the left half\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"menu\", when = { { \"test\", expr = \"pointerX(1) < %d\" } }," % int(half),
"\t\t act = { { \"setVar\", name = \"limited\", value = \"false\" }, { \"setVar\", name = \"shots\", value = \"0\" }, { \"setVar\", name = \"good\", value = \"0\" }, { \"setVar\", name = \"bad\", value = \"0\" },",
"\t\t { \"discTo\", frame = 1 }, { \"discPlay\" }, { \"playSound\", file = \"sound_GetReadyForAction.wav\" }, { \"goTo\", room = \"intro\" } } },",
"\t\t{ note = \"Limited ammo, from the right half\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"menu\", when = { { \"test\", expr = \"pointerX(1) >= %d\" } }," % int(half),
"\t\t act = { { \"setVar\", name = \"limited\", value = \"true\" }, { \"setVar\", name = \"ammo\", value = \"%d\" }, { \"setVar\", name = \"shots\", value = \"0\" }, { \"setVar\", name = \"good\", value = \"0\" }, { \"setVar\", name = \"bad\", value = \"0\" }," % AMMO,
"\t\t { \"discTo\", frame = 1 }, { \"discPlay\" }, { \"playSound\", file = \"sound_GetReadyForAction.wav\" }, { \"goTo\", room = \"intro\" } } },",
# Intro: get ready blinks; the game begins when the intro's frames are done.
"\t\t{ note = \"Get ready blinks with the heartbeat\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"beat % 2 == 1\" } },",
"\t\t act = { { \"show\", entity = \"ready\", visible = true } } },",
"\t\t{ note = \"Get ready blinks off\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"beat % 2 == 0\" } },",
"\t\t act = { { \"show\", entity = \"ready\", visible = false } } },",
"\t\t{ note = \"The intro is over: the game\", on = \"frame\", room = \"intro\", when = { { \"test\", expr = \"frame >= %d\" } }," % intro,
"\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"goTo\", room = \"playing\" } } }," % (intro + 1),
# Playing: shots, hits, the light, the score, the ammo.
"\t\t{ note = \"A shot is fired\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"playing\",",
"\t\t act = { { \"addVar\", name = \"shots\", amount = 1 } } },",
"\t\t{ note = \"A shot is fired with limited ammo: one fewer, given back on a hit\", on = \"pressed\", switch = \"SWITCH_BUTTON3\", room = \"playing\", when = { { \"test\", expr = \"limited\" } },",
"\t\t act = { { \"addVar\", name = \"ammo\", amount = -1 } } },",
"\t\t{ note = \"A good hit\", on = \"hit\", room = \"playing\",",
"\t\t act = { { \"addVar\", name = \"good\", amount = 1 }, { \"setVar\", name = \"lightShown\", value = \"%d\" }, { \"setVar\", name = \"scoreShown\", value = \"%d\" } } }," % (LIGHT_SHOWN, SCORE_SHOWN),
"\t\t{ note = \"A good hit with limited ammo gives the round back\", on = \"hit\", room = \"playing\", when = { { \"test\", expr = \"limited\" } },",
"\t\t act = { { \"addVar\", name = \"ammo\", amount = 1 } } },",
"\t\t{ note = \"A bad hit: a good guy\", on = \"miss\", room = \"playing\",",
"\t\t act = { { \"addVar\", name = \"bad\", amount = 1 }, { \"setVar\", name = \"scoreShown\", value = \"%d\" } } }," % SCORE_SHOWN,
"\t\t{ note = \"The score floats while it is fresh\", on = \"frame\", room = \"playing\",",
"\t\t act = { { \"setText\", entity = \"floating\", text = \"scoreShown > 0 and ('SCORE: ' .. (good - bad)) or ''\" } } },",
"\t\t{ note = \"The light is lit after a good hit\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"lightShown > 0\" } },",
"\t\t act = { { \"show\", entity = \"lightOn\", visible = true }, { \"show\", entity = \"lightOff\", visible = false } } },",
"\t\t{ note = \"The light is off\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"lightShown <= 0\" } },",
"\t\t act = { { \"show\", entity = \"lightOn\", visible = false }, { \"show\", entity = \"lightOff\", visible = true } } },",
"\t\t{ note = \"The rounds left, with limited ammo\", on = \"frame\", room = \"playing\",",
"\t\t act = { { \"setVar\", entity = \"bullets\", name = \"copies\", value = \"limited and max(ammo, 0) or 0\" } } },",
"\t\t{ note = \"Out of ammo: game over\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"limited and ammo < 0\" } },",
"\t\t act = { { \"discTo\", frame = %d }, { \"discPlay\" }, { \"playSound\", file = \"sound_GameOver.wav\" }, { \"goTo\", room = \"gameOver\" } } }," % menuVideo,
"\t\t{ note = \"The game video is over: the title\", on = \"frame\", room = \"playing\", when = { { \"test\", expr = \"frame >= %d\" } }," % (intro + length),
"\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"],
# Game over -> title.
"\t\t{ note = \"Game over is shown until the menu video ends\", on = \"frame\", room = \"gameOver\", when = { { \"test\", expr = \"frame >= %d\" } }," % menuEnd,
"\t\t act = { { \"discPause\" }, { \"discTo\", frame = %d }, { \"goTo\", room = \"title\" } } }," % p["backgroundFrame"],
])
out.append("\trules = {")
out.append("\n".join(rules).rstrip(","))
out.append("\t}")
out.append("}")
return "\n".join(out) + "\n"
def main():
folder = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/claude/singetest/ported/ActionMax")
for game in GAMES:
path = os.path.join(folder, game + ".forge")
with open(path, "w") as out:
out.write(describe(folder, game))
print("wrote " + path)
if __name__ == "__main__":
main()