294 lines
18 KiB
Python
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()
|