#!/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()