diff --git a/badge/apps/pig_vs_goose/README.md b/badge/apps/pig_vs_goose/README.md new file mode 100755 index 00000000..63a954fc --- /dev/null +++ b/badge/apps/pig_vs_goose/README.md @@ -0,0 +1,135 @@ +# Pig vs Goose + +Pig vs Goose is a turn-based water-balloon duel with destructible terrain, splashy craters, and a mischievous computer opponent. + +## Play + +Open **Pig Vs Goose** from the badge menu. Choose **VS COMPUTER** (you are +Pig) or **PASS & PLAY** (two people share the badge) using Left/Right, then +press Select. Pig throws first. The marker above a character indicates whose +turn it is. + +## Badge vs Badge + +Install the same version of Pig vs Goose on **two Universe 2026 badges**. +Choose **BADGE VS BADGE** on each, bring the badges close together, and press +**Select on both** when the opponent is found. This reuses Pong's +`badgeware.link.Link` and `Proximity` discovery/pairing helpers; it does not +need Wi-Fi credentials or an external server. + +The link's central badge plays **Pig** and the other plays **Goose**. The +lobby tells you your character, and an asterisk marks your soak meter. Both +screens show the same board. Each player drives, aims, and throws using their +own badge **only on their character's turn**; the other screen says it is +waiting for the opponent. + +One badge runs the authoritative game and sends acknowledged, fragmented +snapshots; the other displays those snapshots rather than running a second +independent simulation. This keeps craters (including overhangs), rolling +carts, wind, balloons, soak, butterflies, and hairdryer bonuses consistent. +The viewing badge updates at the link's delivered snapshot rate, not its own +physics rate. Missing packets are retried; held controls expire if updates stop. + +- **Menu on either badge:** pause both. Select on either resumes. +- **Back while paused or at a result:** leave the link and return to the title. +- **Rematch:** Select on **both** badges starts a fresh shared landscape. +- **Home:** exit and close the link. +- If a badge leaves or stops responding, the other shows a connection error. + Return to the title and enter Badge vs Badge again to pair for a new match. + A disconnected match is not resumed. + +Badge vs Badge requires firmware supplying `badgeware.link`, just like Pong. +The browser simulator cannot verify the real badge radio or pair two physical +badges. Its link stub stays on the searching screen and logs that badge +communication is unavailable; if the API is absent entirely, the title shows +a firmware requirement. Computer and pass-and-play modes do not load or +require the link. + +## Turn controls + +Each turn has two stages: + +1. **Drive:** Left/Right drives your little red cart up to 12 terrain pixels, + including small steps. Select enters aiming once both carts have stopped. + You can stay put. +2. **Aim:** Left/Right decreases/increases elevation relative to your character's + forward direction; 90 degrees is straight up. Up/Down raises/lowers power. + Select throws once the carts are still. Back returns to driving without + replenishing your allowance. + +The board is a larger 200x150 terrain map, with smaller riders, rolling hills, +and a randomized sheer cliff near the middle. Both carts tilt to match their +wheel contacts, their wheels spin as they move, and gravity rolls them downhill +until they settle. Driving and a splash removing a ledge can start a roll; +gravity does not consume your driving allowance. A cart can carry momentum +across the bottom of a dip. Wait for rolling to finish before aiming or throwing. + +The dotted guide previews only the beginning of a throw, not its landing. +Wind changes each turn; the HUD shows its direction and strength. Balloons +can fly above the screen, but leaving the left or right edge counts as a miss. +The computer searches possible throws incrementally and adds a small aiming +error. It does not deliberately drive during its own turns, but its cart still +rolls with gravity. + +Splashes carve genuine circular holes, including overhangs, into the terrain. +Nearby characters gain soak, including the thrower if caught in their own +splash. Characters fall when the ground beneath their feet is removed; long +falls add soak. Falling into the water immediately soaks a character completely. +The first character to reach **100% soaked** loses. Simultaneous soak-outs draw. +The result panel explains who reached 100% and whether the finishing damage +came from a balloon splash, a hard landing, or falling into the water. +You can lose from accumulated splash or fall damage without touching the water. +The soak bars blend from blue through yellow and orange to critical red. + +## Butterfly bonus + +Occasionally a small butterfly flies across the sky in a sine wave, with a +random starting height, direction, amplitude, speed, and frequency. Hit it +**directly with your balloon** (not a splash) to summon the gigantic TURBO +hairdryer. It descends, shakes, blows animated gusts, and flaps your rider about +before retreating. It removes **25 percentage points** from the thrower's soak: +70% becomes 45%, and anything below 25% becomes 0%. It works for Pig or Goose +and never dries the opponent instead. + +The balloon is spent on the bonus, without making a crater. Carts and butterfly +spawning pause during the dryer animation, then gravity settles and the turn +passes normally. Menu pauses the entire bonus, including the drying timer. + +- **Menu:** pause and show controls; Select resumes, Back returns to the title. +- **Select after a round:** rematch with a fresh landscape. +- **Back after a round:** return to the title and choose a mode. +- **Home:** return to the badge launcher. + +## Requirements + +GitHub Universe 2026 firmware with the runtime-provided graphics and logical +button APIs. No network, external hardware, credentials, or saved state are +needed. Uses HIRES mode with a cached 200x150 terrain map. Input is +orientation-aware and physics uses elapsed time with fixed substeps. + +## Development checks + +From the repository root: + +```sh +python3 .github/skills/hyb26-skill/scripts/validate_app.py badge/apps/pig_vs_goose +python3 .github/scripts/validate_app_readmes.py badge/apps +``` + +There are currently no app-specific automated tests in this checkout. + +In the Badge Simulator, check both modes, driving and aiming, rolling and +tilting carts, splash craters, falls, butterfly hits, the complete dryer +animation, soak-bar colors, pause/resume, rematches, and upside-down controls. +The simulator is an +approximation: after deployment, confirm the menu icon, readability, input, +animation speed, Home/reopen, and both orientations on a physical badge. + +**Two-badge hardware verification remains required:** pair two updated badges, play +several turns from each, verify matching craters and soak, hit a butterfly, +pause/resume from both sides, rematch with both votes, and exit/re-pair from +each side. Confirm link range and update smoothness on the actual firmware. + +## Author + +[Morten Rand-Hendriksen (@mor10)](https://github.com/mor10) diff --git a/badge/apps/pig_vs_goose/__init__.py b/badge/apps/pig_vs_goose/__init__.py new file mode 100755 index 00000000..ba6fa9be --- /dev/null +++ b/badge/apps/pig_vs_goose/__init__.py @@ -0,0 +1,526 @@ +"""Pig vs Goose: a tiny, splashy artillery duel.""" + +import math +import os +import sys + +APP_DIR = "/system/apps/pig_vs_goose" +os.chdir(APP_DIR) +sys.path.insert(0, APP_DIR) + +from game import Game, WIDTH, HEIGHT, FLOOR, BLAST_RADIUS, DRYER_DURATION, soak_rgb + +badge.mode(HIRES | VSYNC) +screen.font = font.nope +screen.antialias = image.OFF +screen.fill_rule = image.EVEN_ODD + +SKY = color.rgb(176, 228, 230) +INK = color.rgb(36, 47, 61) +WHITE = color.rgb(255, 252, 235) +PINK = color.rgb(247, 146, 171) +BLUE = color.rgb(45, 154, 216) +GRASS = color.rgb(101, 153, 82) +DIRT = color.rgb(151, 100, 67) +ORANGE = color.rgb(249, 167, 65) +RED = color.rgb(221, 55, 59) +YELLOW = color.rgb(250, 225, 75) +terrain_image = image(WIDTH, HEIGHT) +terrain_revision = -1 +game = None +mode = 0 +MODES = ("VS COMPUTER", "PASS & PLAY", "BADGE VS BADGE") +multiplayer = None +link_error = "" +linked_round = 0 +paused = False +clock = 0.0 +names = ("PIG", "GOOSE") + + +def box(x, y, w, h, pen): + screen.pen = pen + screen.rectangle(int(x), int(y), int(w), int(h)) + + +def circle(x, y, radius, pen): + screen.pen = pen + screen.shape(shape.circle(x, y, radius)) + + +def text(label, x, y, pen=INK): + screen.pen = pen + screen.text(label, x, y) + + +def center(label, y, pen=INK): + w, _ = screen.measure_text(label) + text(label, (screen.width - w) / 2, y, pen) + + +def character(index, x, foot, scale=1, wet=0, tilt=0, wheel=0, cart=False, blow=0): + # Pixel-art silhouettes drawn from primitives stay crisp at badge scale. + cosine, sine = math.cos(tilt), math.sin(tilt) + + def point(dx, dy): + return vec2(x + (dx * cosine - dy * sine) * scale, + foot + (dx * sine + dy * cosine) * scale) + + def panel(dx, dy, w, h, pen): + screen.pen = pen + screen.shape(shape.custom([point(dx, dy), point(dx + w, dy), + point(dx + w, dy + h), point(dx, dy + h)])) + + if cart: + panel(-5, -5, 10, 3.5, INK) + panel(-4.5, -4.8, 9, 2.5, RED) + for wx in (-3, 3): + position = point(wx, -1.5) + circle(position.x, position.y, 1.6 * scale, INK) + circle(position.x, position.y, 0.9 * scale, WHITE) + screen.pen = INK + screen.line(position.x, position.y, + position.x + math.cos(wheel + tilt) * 1.3 * scale, + position.y + math.sin(wheel + tilt) * 1.3 * scale) + + def r(dx, dy, w, h, pen): + if cart: + dx = dx * 0.85 + blow * (-dy / 14) + dy = dy * 0.85 - 4.5 + w, h = w * 0.85, h * 0.85 + panel(dx, dy, w, h, pen) + + if index == 0: + r(-6, -9, 12, 7, INK) + r(-5, -10, 10, 8, PINK) + r(-5, -13, 3, 4, PINK) + r(2, -13, 3, 4, PINK) + r(3, -7, 5, 4, color.rgb(234, 107, 145)) + r(5, -6, 1, 1, INK) + r(3, -9, 1, 2, INK) + r(-4, -2, 2, 2, INK) + r(2, -2, 2, 2, INK) + r(-8, -7, 2, 3, PINK) + else: + r(-6, -7, 10, 5, INK) + r(-6, -8, 10, 5, WHITE) + r(-5, -12, 4, 8, WHITE) + r(-6, -14, 6, 5, WHITE) + r(-9, -11, 4, 2, ORANGE) + r(-5, -12, 1, 1, INK) + r(3, -10, 3, 4, WHITE) + r(-4, -2, 2, 2, ORANGE) + r(1, -2, 2, 2, ORANGE) + if wet: + r(-3, -5, 3, 2, BLUE) + + +def rebuild_terrain(): + global terrain_revision + terrain_image.pen = SKY + terrain_image.clear() + for x in range(WIDTH): + y = 0 + while y < FLOOR: + if not game.terrain[y][x]: + y += 1 + continue + start = y + while y < FLOOR and game.terrain[y][x]: + y += 1 + terrain_image.pen = DIRT + terrain_image.rectangle(x, start, 1, y - start) + terrain_image.pen = GRASS + terrain_image.rectangle(x, start, 1, min(2, y - start)) + terrain_revision = game.revision + + +def landscape(): + if terrain_revision != game.revision: + rebuild_terrain() + screen.blit(terrain_image, rect(0, 0, screen.width, screen.height)) + sx, sy = screen.width / WIDTH, screen.height / HEIGHT + for x, y in ((18, 30), (82, 22), (137, 34)): + circle(x * sx, y * sy, 5 * sx, WHITE) + circle((x + 5) * sx, (y - 2) * sy, 6 * sx, WHITE) + circle((x + 10) * sx, y * sy, 4 * sx, WHITE) + box(0, FLOOR * sy, screen.width, (HEIGHT - FLOOR) * sy, BLUE) + for x in range(0, WIDTH, 16): + box((x + int(clock * 5) % 8) * sx, (FLOOR + 2) * sy, 7 * sx, sy, WHITE) + for i, player in enumerate(game.players): + blowing = (game.phase == "dryer" and game.active == i + and 0.8 < game.dryer_time < 3.0) + blow = math.sin(game.dryer_time * 39) * 3 if blowing else 0 + character(i, player["x"] * sx, player["y"] * sy, sx, player["soak"] > 30, + player["tilt"], player["wheel"], True, blow) + if game.phase in ("move", "aim", "think"): + player = game.players[game.active] + x, y = player["x"] * sx, (player["y"] - 19) * sy + circle(x, y + math.sin(clock * 5) * 2, 2.5 * sx, PINK if game.active == 0 else INK) + for x, y in game.trail[::3]: + circle(x * sx, y * sy, 0.6 * sx, WHITE) + if game.projectile: + x, y = game.projectile[:2] + circle(x * sx, y * sy, 2.5 * sx, BLUE) + circle((x - 0.7) * sx, (y - 0.8) * sy, 0.7 * sx, WHITE) + if y < 15: + text("^", max(4, min(screen.width - 12, x * sx)), 34) + if game.phase == "splash": + x, y = game.splash + progress = 1 - max(0, game.timer) / 0.55 + radius = BLAST_RADIUS * min(1, progress * 3) + screen.pen = BLUE + screen.shape(shape.circle(x * sx, y * sy, radius * sx).stroke(2)) + for i in range(12): + angle = i * math.pi / 6 + reach = radius + progress * 9 + circle((x + math.cos(angle) * reach) * sx, + (y + math.sin(angle) * reach + progress * progress * 9) * sy, + max(0.6, (1 - progress) * 2) * sx, BLUE) + text("SPLASH!", max(3, min(screen.width - 66, x * sx - 25)), + max(34, y * sy - 45), BLUE) + if game.butterfly: + butterfly() + if game.phase == "dryer": + hairdryer() + + +def butterfly(): + b = game.butterfly + x = b["x"] * screen.width / WIDTH + y = b["y"] * screen.height / HEIGHT + spread = 1.5 + abs(math.sin(b["time"] * 15)) * 4 + for direction in (-1, 1): + circle(x + direction * spread, y - 2, spread, ORANGE) + circle(x + direction * spread, y + 3, spread * 0.75, PINK) + circle(x + direction * spread, y - 2, spread * 0.45, YELLOW) + box(x - 1, y - 4, 2, 10, INK) + screen.pen = INK + screen.line(x, y - 3, x - 3, y - 7) + screen.line(x, y - 3, x + 3, y - 7) + + +def hairdryer(): + t = game.dryer_time + player = game.players[game.active] + px = player["x"] * screen.width / WIDTH + py = player["y"] * screen.height / HEIGHT + x = max(49, min(screen.width - 63, px)) + target_y = max(45, py - 100) + descent = min(1, t / 0.8) + ascent = max(0, (t - 3) / (DRYER_DURATION - 3)) + y = -100 + (target_y + 100) * (descent - ascent) + shake = math.sin(t * 45) * 2 if 0.8 < t < 3 else 0 + x += shake + # An absurdly oversized salon dryer, nozzle down, with a dangling power cord. + screen.pen = INK + screen.line(x + 48, y + 46, x + 55, y + 65) + screen.line(x + 55, y + 65, x + 42, y + 75) + screen.line(x + 42, y + 75, x + 56, y + 87) + box(x + 29, y - 3, 22, 56, INK) + box(x + 33, y, 14, 48, PINK) + box(x + 35, y + 17, 7, 9, INK) + circle(x - 18, y, 28, INK) + box(x - 18, y - 28, 53, 56, INK) + circle(x - 18, y, 24, RED) + box(x - 18, y - 24, 49, 48, RED) + box(x - 13, y + 19, 32, 18, INK) + box(x - 9, y + 22, 24, 12, ORANGE) + for vent in range(4): + box(x - 29 + vent * 8, y - 15, 3, 24, INK) + text("TURBO", x - 11, y - 21, WHITE) + if 0.8 < t < 3: + for stream in range(7): + start_x = x - 10 + stream * 4 + screen.pen = WHITE if stream % 2 else YELLOW + for segment in range(5): + u = segment / 5 + v = (segment + 1) / 5 + wiggle = math.sin(t * 22 + stream + segment) * 4 + ax = start_x * (1 - u) + (px + (stream - 3) * 4) * u + bx = start_x * (1 - v) + (px + (stream - 3) * 4) * v + ay = (y + 38) * (1 - u) + (py - 12) * u + by = (y + 38) * (1 - v) + (py - 12) * v + screen.line(ax + wiggle, ay, bx - wiggle, by) + for i in range(6): + progress = (t * 2 + i / 6) % 1 + circle(px + (i - 3) * 6 + math.sin(t * 17 + i) * 4, + py - 12 - progress * 24, 1.5 * (1 - progress), BLUE) + box(46, 31, screen.width - 92, 15, INK) + center("BUTTERFLY BONUS! -25 SOAK", 32, YELLOW) + if t >= 1.6: + box(86, screen.height - 47, screen.width - 172, 16, INK) + center("%d%% -> %d%%" % (game.dryer_before, game.dryer_after), + screen.height - 46, WHITE) + + +def hud(): + box(0, 0, screen.width, 30, INK) + for i, x in enumerate((7, screen.width - 103)): + player = game.players[i] + name = names[i] + if multiplayer and multiplayer.local_player == i: + name = "*" + name + text("%s %d%%" % (name, player["soak"]), x, 2, WHITE) + box(x, 19, 94, 4, SKY) + box(x, 19, max(0, int(94 * player["soak"] / 100)), 4, + color.rgb(*soak_rgb(player["soak"]))) + label = "WIND %s%d" % (">" if game.wind >= 0 else "<", abs(game.wind)) + w, _ = screen.measure_text(label) + text(label, (screen.width - w) / 2, 8, WHITE) + box(0, screen.height - 27, screen.width, 27, INK) + player = game.players[game.active] + if multiplayer and game.phase in ("move", "aim") and game.active != multiplayer.local_player: + center("YOU: %s Waiting for %s" % + (names[multiplayer.local_player], names[game.active]), + screen.height - 26, WHITE) + center("Opponent's turn MENU pause", screen.height - 13, WHITE) + return + if game.phase == "move": + center("%s DRIVE %d left" % (names[game.active], math.ceil(game.walk)), + screen.height - 26, WHITE) + center("L/R drive SELECT aim" if game.carts_still() else + "Rolling downhill! Hold on...", screen.height - 13, WHITE) + elif game.phase == "aim": + center("%s ANGLE %d POWER %d" % + (names[game.active], player["angle"], player["power"]), + screen.height - 26, WHITE) + center("L/R angle U/D power SELECT throw", screen.height - 13, WHITE) + else: + label = {"think": "GOOSE is plotting...", + "flight": "Water balloon away!", + "splash": "A proper soaking!", + "dryer": "FWOOSH! Emergency fluff cycle!", + "settle": "Mind the drop!", + "over": "SELECT rematch BACK title"}[game.phase] + center(label, screen.height - 21, WHITE) + + +def aiming(): + player = game.players[game.active] + sx, sy = screen.width / WIDTH, screen.height / HEIGHT + ball = game.launch_values(player["angle"], player["power"]) + for _ in range(9): + for _ in range(4): + game.advance_ball(ball, 1 / 120) + if game.impact(ball): + break + circle(ball[0] * sx, ball[1] * sy, 0.7 * sx, INK) + + +def start(): + global game, terrain_revision, paused + game = Game(mode == 0) + terrain_revision = -1 + paused = False + + +def start_link(): + global multiplayer, link_error, game, linked_round + try: + from badgeware import link as badgelink + except ImportError as error: + link_error = "Badge link requires physical firmware" + print("Pig vs Goose:", link_error, error) + return + from multiplayer import Multiplayer + try: + connection = badgelink.Link("pig_vs_goose") + except (OSError, RuntimeError) as error: + link_error = "Cannot start badge link" + print("Pig vs Goose:", link_error, error) + return + multiplayer = Multiplayer(connection, badgelink.Proximity(connection)) + game = None + linked_round = 0 + link_error = "" + + +def stop_link(): + global multiplayer, game, paused + if multiplayer: + multiplayer.send(b"X") + multiplayer.close() + multiplayer = None + game = None + paused = False + + +def link_lobby(): + box(0, 0, screen.width, screen.height, SKY) + center("BADGE vs BADGE", 22) + if multiplayer.status == "error": + center("LINK INTERRUPTED", 67) + center(multiplayer.message, 93) + center("BACK: return and pair again", 180) + elif multiplayer.status == "search": + center("Bring two badges close together", 64) + center("Open this mode on both badges", 86) + center("Finding another Pig vs Goose...", 119) + center("BACK cancel", 190) + elif multiplayer.status == "offer": + partner = multiplayer.link.partner + center("Opponent found!", 57) + if partner: + center(str(partner.name)[:28], 79) + center("You will be %s" % names[multiplayer.local_player], 108) + center("Waiting for their SELECT..." if multiplayer.accepted + else "SELECT: ready to play", 142) + center("Both players must accept", 165) + center("BACK cancel", 198) + else: + center("Syncing landscape...", 100) + center("BACK cancel", 190) + + +def linked_frame(dt): + global game, paused, terrain_revision, linked_round + if badge.pressed(BUTTON_BACK) and ( + multiplayer.status != "play" or multiplayer.paused + or (game and game.phase == "over")): + stop_link() + title() + return + horizontal = int(bool(badge.held(BUTTON_RIGHT))) - int(bool(badge.held(BUTTON_LEFT))) + vertical = int(bool(badge.held(BUTTON_UP))) - int(bool(badge.held(BUTTON_DOWN))) + buttons = (int(bool(badge.pressed(BUTTON_SELECT))) | + (int(bool(badge.pressed(BUTTON_BACK))) << 1) | + (int(bool(badge.pressed(BUTTON_MENU))) << 2)) + multiplayer.update(badge.ticks, dt, horizontal, vertical, buttons) + game = multiplayer.game + paused = multiplayer.paused + if multiplayer.status != "play" or game is None: + link_lobby() + return + if linked_round != multiplayer.round: + terrain_revision = -1 + linked_round = multiplayer.round + landscape() + if game.phase == "aim": + aiming() + hud() + if multiplayer.message: + box(0, screen.height - 14, screen.width, 14, INK) + center(multiplayer.message, screen.height - 13, WHITE) + if paused: + help_panel() + center("Both badges paused; BACK leaves", 201, INK) + elif game.phase == "over": + result_panel() + box(0, screen.height - 27, screen.width, 27, INK) + voted = multiplayer.rematch[multiplayer.local_player] + center("Waiting for opponent's rematch" if voted else + "SELECT rematch BACK leave", screen.height - 21, WHITE) + + +def title(): + box(0, 0, screen.width, screen.height, SKY) + circle(screen.width - 30, 44, 24, WHITE) + box(0, screen.height - 32, screen.width, 32, GRASS) + center("PIG vs GOOSE", 22) + center("THE GREAT WATER-BALLOON DUEL", 42) + character(0, screen.width * 0.25, 117, 3) + character(1, screen.width * 0.75, 117, 3) + circle(screen.width / 2, 80 + math.sin(clock * 3) * 6, 7, BLUE) + center("< %s >" % MODES[mode], 139) + center("Left / Right choose SELECT start", 158) + center("Roll. Aim. Splash. Catch a butterfly!", 187) + center("MENU: controls / pause HOME: exit", 218, WHITE) + if link_error: + box(0, 175, screen.width, 32, INK) + center(link_error, 178, WHITE) + center("Simulator: solo / pass & play only", 192, WHITE) + + +def help_panel(): + box(13, 37, screen.width - 26, 160, INK) + center("SPLASH SCHOOL", 46, WHITE) + center("Drive: Left / Right, then Select", 65, WHITE) + center("Aim: L/R angle, Up/Down power", 84, WHITE) + center("Select throws. Back returns to drive.", 103, WHITE) + center("Hit butterfly: dryer removes 25 soak", 122, WHITE) + center("Carts roll! Wait for them to stop.", 141, WHITE) + center("100% soaked loses. Falls soak too!", 160, WHITE) + center("SELECT resume BACK title", 176, WHITE) + + +def update(): + global game, mode, paused, clock, link_error + dt = max(0, min(badge.ticks_delta / 1000, 0.1)) + if multiplayer: + linked_frame(dt) + if not paused: + clock += dt + return + if badge.pressed(BUTTON_MENU): + paused = not paused + if paused: + if game: + landscape() + hud() + else: + title() + help_panel() + if badge.pressed(BUTTON_SELECT): + paused = False + elif badge.pressed(BUTTON_BACK): + game = None + paused = False + return + clock += dt + if game is None: + if badge.pressed(BUTTON_LEFT) or badge.pressed(BUTTON_RIGHT): + mode = (mode + (1 if badge.pressed(BUTTON_RIGHT) else -1)) % len(MODES) + link_error = "" + title() + if badge.pressed(BUTTON_SELECT): + if mode == 2: + start_link() + else: + start() + return + if game.phase == "over": + if badge.pressed(BUTTON_SELECT): + start() + elif badge.pressed(BUTTON_BACK): + game = None + return + elif not (game.computer and game.active == 1): + if game.phase == "move": + direction = int(bool(badge.held(BUTTON_RIGHT))) - int(bool(badge.held(BUTTON_LEFT))) + game.move(direction * 12 * dt) + if badge.pressed(BUTTON_SELECT): + game.aim() + elif game.phase == "aim": + player = game.players[game.active] + direction = int(bool(badge.held(BUTTON_RIGHT))) - int(bool(badge.held(BUTTON_LEFT))) + power = int(bool(badge.held(BUTTON_UP))) - int(bool(badge.held(BUTTON_DOWN))) + player["angle"] = max(10, min(170, player["angle"] + direction * 38 * dt)) + player["power"] = max(20, min(100, player["power"] + power * 35 * dt)) + if badge.pressed(BUTTON_BACK): + game.phase = "move" + elif badge.pressed(BUTTON_SELECT): + game.fire() + game.update(dt) + landscape() + if game.phase == "aim": + aiming() + hud() + if game.phase == "over": + result_panel() + + +def result_panel(): + box(20, 66, screen.width - 40, 86, INK) + center("BOTH SOAKED!" if game.winner == -1 else + "%s WINS!" % names[game.winner], 76, WHITE) + for i, line in enumerate(game.result_lines()): + center(line, 102 + i * 19, WHITE) + + +def on_exit(): + if multiplayer: + multiplayer.close() + + +run(update) diff --git a/badge/apps/pig_vs_goose/game.py b/badge/apps/pig_vs_goose/game.py new file mode 100755 index 00000000..308d4794 --- /dev/null +++ b/badge/apps/pig_vs_goose/game.py @@ -0,0 +1,449 @@ +"""Hardware-independent water-balloon physics, terrain, and turn rules.""" + +import math +import random + + +WIDTH = 200 +HEIGHT = 150 +FLOOR = 126 +GRAVITY = 42 +STEP = 1 / 120 +BLAST_RADIUS = 17 +WALK_DISTANCE = 12 +WHEEL_BASE = 6 +DRYER_DURATION = 3.8 +RIDER_HEIGHT = 9 + + +def soak_rgb(soak): + stops = ((45, 154, 216), (250, 225, 75), (250, 174, 45), (235, 55, 55)) + position = max(0, min(100, soak)) / 100 * 3 + index = min(2, int(position)) + blend = position - index + return tuple(int(a + (b - a) * blend) + for a, b in zip(stops[index], stops[index + 1])) + + +class Game: + def __init__(self, computer=True): + self.computer = computer + self.terrain = [bytearray(WIDTH) for _ in range(HEIGHT)] + phase = random.random() * 6 + self.cliff = random.randint(80, 120) + cliff_direction = random.choice((-1, 1)) + cliff_height = int(86 + 16 * math.sin(self.cliff / 16 + phase) + + 8 * math.sin(self.cliff / 7 + phase)) + if not 45 <= cliff_height + 23 * cliff_direction <= FLOOR - 8: + cliff_direction = -cliff_direction + for x in range(WIDTH): + top = int(86 + 16 * math.sin(x / 16 + phase) + + 8 * math.sin(x / 7 + phase) + + (23 * cliff_direction if x >= self.cliff else 0)) + top = max(45, min(FLOOR - 8, top)) + for y in range(top, FLOOR): + self.terrain[y][x] = 1 + self.initial_surface = [self.surface(x) for x in range(WIDTH)] + self.craters = [] + self.players = [ + {"x": 24.0, "y": 0.0, "vx": 0.0, "vy": 0.0, "fall": 0.0, + "tilt": 0.0, "wheel": 0.0, + "soak": 0, "cause": None, "angle": 47.0, "power": 66.0}, + {"x": float(WIDTH - 24), "y": 0.0, "vx": 0.0, "vy": 0.0, "fall": 0.0, + "tilt": 0.0, "wheel": 0.0, + "soak": 0, "cause": None, "angle": 47.0, "power": 66.0}, + ] + for player in self.players: + player["y"] = float(self.surface(int(player["x"]))) + player["y"], player["tilt"] = self.ground_pose(player["x"], player["y"]) + player["fall"] = player["y"] + self.active = 0 + self.phase = "move" + self.walk = float(WALK_DISTANCE) + self.wind = random.randint(-5, 5) + self.projectile = None + self.trail = [] + self.splash = None + self.timer = 0.0 + self.revision = 0 + self.winner = None + self.turn = 1 + self.ai_index = 0 + self.ai_best = None + self.ai_wait = 0.0 + self.accumulator = 0.0 + self.cart_accumulator = 0.0 + self.butterfly = None + self.butterfly_delay = random.uniform(4, 9) + self.dryer_time = 0.0 + self.dryer_before = 0 + self.dryer_after = 0 + self.dryer_applied = False + + def solid(self, x, y): + x, y = int(x), int(y) + return 0 <= x < WIDTH and 0 <= y < HEIGHT and self.terrain[y][x] + + def surface(self, x, start=0): + x = max(0, min(WIDTH - 1, int(x))) + for y in range(max(0, int(start)), FLOOR): + if self.terrain[y][x]: + return y + return FLOOR + + def ground_pose(self, x, y): + half = WHEEL_BASE / 2 + left = self.surface(x - half, y - 4) + right = self.surface(x + half, y - 4) + tilt = max(-0.95, min(0.95, math.atan2(right - left, WHEEL_BASE))) + # Keep both wheel contacts above solid pixels, even at a cliff lip. + left = self.surface(x - half * math.cos(tilt), y - 4) + right = self.surface(x + half * math.cos(tilt), y - 4) + ground = min(left + half * math.sin(tilt), + right - half * math.sin(tilt)) + if left == FLOOR and right == FLOOR: + ground = FLOOR + return ground, tilt + + def supported(self, player): + ground, _ = self.ground_pose(player["x"], player["y"]) + return abs(ground - player["y"]) < 0.15 and ground < FLOOR + + def move(self, distance): + if self.phase != "move" or self.walk <= 0: + return + player = self.players[self.active] + if not self.supported(player): + return + distance = max(-self.walk, min(self.walk, distance)) + target = max(5, min(WIDTH - 6, player["x"] + distance)) + other = self.players[1 - self.active] + if abs(target - other["x"]) < 11: + return + # Walk in subpixel steps so slopes cannot be skipped at low frame rates. + steps = max(1, int(abs(target - player["x"]) / 0.4) + 1) + step = (target - player["x"]) / steps + for _ in range(steps): + nx = player["x"] + step + foot, tilt = self.ground_pose(nx, player["y"]) + if foot < player["y"] - 2: + break + player["x"] = nx + player["wheel"] += step / 1.5 + if foot <= player["y"] + 2: + player["y"] = foot + player["tilt"] = tilt + player["fall"] = player["y"] + self.walk = max(0, self.walk - abs(step)) + if not self.supported(player): + break + + def aim(self): + if self.phase == "move" and self.carts_still(): + self.phase = "aim" + + def carts_still(self): + return all(p["soak"] >= 100 or + (self.supported(p) and abs(p["vx"]) < 0.05 and p["vy"] == 0) + for p in self.players) + + def launch_values(self, angle, power, index=None): + if index is None: + index = self.active + player = self.players[index] + radians = angle * math.pi / 180 + direction = 1 if index == 0 else -1 + dx = math.cos(radians) * direction + dy = -math.sin(radians) + speed = 18 + power * 0.84 + return [player["x"] + dx * 6, player["y"] - RIDER_HEIGHT + dy * 6, + dx * speed, dy * speed, 0.0] + + def fire(self): + if self.phase != "aim" or not self.carts_still(): + return + player = self.players[self.active] + self.projectile = self.launch_values(player["angle"], player["power"]) + self.trail = [] + self.accumulator = 0.0 + self.phase = "flight" + + def advance_ball(self, ball, dt): + ball[2] += self.wind * dt + ball[3] += GRAVITY * dt + ball[0] += ball[2] * dt + ball[1] += ball[3] * dt + ball[4] += dt + + def impact(self, ball, bonuses=True): + x, y = ball[0], ball[1] + if x < 0 or x >= WIDTH or y >= FLOOR or ball[4] >= 7: + return "miss" + if self.solid(x, y): + return "hit" + if bonuses and self.butterfly: + if (x - self.butterfly["x"]) ** 2 + (y - self.butterfly["y"]) ** 2 <= 25: + return "butterfly" + for player in self.players: + if (x - player["x"]) ** 2 + (y - (player["y"] - RIDER_HEIGHT)) ** 2 < 16: + return "hit" + return None + + def add_soak(self, player, amount, cause): + if amount > 0 and player["soak"] < 100: + player["soak"] = min(100, player["soak"] + amount) + player["cause"] = cause + + def result_lines(self): + names = ("PIG", "GOOSE") + causes = {"splash": "balloon splash", "fall": "hard landing", + "water": "fell into water"} + if self.winner == -1: + return tuple("%s: %s" % (names[i], causes[p["cause"]]) + for i, p in enumerate(self.players)) + loser = 1 - self.winner + player = self.players[loser] + return ("%s reached 100%% soaked" % names[loser], + "Cause: %s" % causes[player["cause"]]) + + def explode(self, x, y): + self.splash = (x, y) + self.carve_crater(x, y) + self.craters.append((x, y)) + for player in self.players: + distance = math.sqrt((x - player["x"]) ** 2 + + (y - (player["y"] - RIDER_HEIGHT)) ** 2) + damage = max(0, int(56 * (1 - distance / (BLAST_RADIUS + 7)))) + self.add_soak(player, damage, "splash") + player["fall"] = player["y"] + self.revision += 1 + self.projectile = None + self.phase = "splash" + self.timer = 0.55 + + def carve_crater(self, x, y): + radius = BLAST_RADIUS + for yy in range(max(0, int(y - radius)), min(FLOOR, int(y + radius) + 1)): + for xx in range(max(0, int(x - radius)), min(WIDTH, int(x + radius) + 1)): + if (xx - x) ** 2 + (yy - y) ** 2 <= radius * radius: + self.terrain[yy][xx] = 0 + + def roll_step(self, player, dt): + ground, tilt = self.ground_pose(player["x"], player["y"]) + if ground >= FLOOR and player["y"] >= FLOOR - 0.15: + player["y"] = float(FLOOR) + player["vx"] = player["vy"] = 0.0 + self.add_soak(player, 100, "water") + return + grounded = ground <= player["y"] + 0.15 + if grounded: + player["y"] = ground + player["tilt"] = tilt + player["vy"] = 0 + player["fall"] = ground + force = 42 * math.sin(tilt) + friction = 7 + if abs(force) <= friction and abs(player["vx"]) < 1: + player["vx"] = 0.0 + else: + direction = 1 if player["vx"] > 0 else -1 + if abs(player["vx"]) < 0.1: + direction = 1 if force > 0 else -1 + velocity = player["vx"] + (force - direction * friction) * dt + if player["vx"] * velocity < 0 and abs(force) <= friction: + velocity = 0.0 + player["vx"] = max(-26, min(26, velocity)) + else: + player["vy"] += 75 * dt + dx = player["vx"] * dt + nx = max(4, min(WIDTH - 5, player["x"] + dx)) + other = self.players[1] if player is self.players[0] else self.players[0] + if abs(nx - other["x"]) < 9 and abs(player["y"] - other["y"]) < 10: + player["vx"] = 0.0 + nx = player["x"] + next_ground, next_tilt = self.ground_pose(nx, player["y"]) + if next_ground < player["y"] - 2: + player["vx"] = 0.0 + else: + player["wheel"] += (nx - player["x"]) / 1.5 + player["x"] = nx + if grounded and abs(next_ground - player["y"]) <= 2: + player["y"] = next_ground + player["tilt"] = next_tilt + player["fall"] = next_ground + if nx in (4, WIDTH - 5): + player["vx"] = 0.0 + target = player["y"] + player["vy"] * dt + while player["y"] < target: + player["y"] = min(target, player["y"] + 0.5) + ground, tilt = self.ground_pose(player["x"], player["y"]) + if player["y"] >= FLOOR or ground <= player["y"]: + player["y"] = min(FLOOR, ground) + drop = player["y"] - player["fall"] + player["vy"] = 0.0 + player["tilt"] = tilt + if player["y"] >= FLOOR: + self.add_soak(player, 100, "water") + else: + self.add_soak(player, max(0, int(drop - 9)), "fall") + player["fall"] = player["y"] + break + + def settle(self, dt): + self.cart_accumulator += dt + while self.cart_accumulator >= STEP: + self.cart_accumulator -= STEP + for player in self.players: + if player["soak"] < 100: + self.roll_step(player, STEP) + return not self.carts_still() + + def spawn_butterfly(self): + direction = random.choice((-1, 1)) + amplitude = random.uniform(4, 9) + self.butterfly = { + "x": -7.0 if direction == 1 else WIDTH + 7.0, + "y": 0.0, "base": random.uniform(22 + amplitude, 43 - amplitude), + "amplitude": amplitude, "direction": direction, + "speed": random.uniform(12, 21), "phase": random.random() * math.pi * 2, + "frequency": random.uniform(1.3, 2.7), "time": 0.0, + } + b = self.butterfly + b["y"] = b["base"] + b["amplitude"] * math.sin(b["phase"]) + + def update_butterfly(self, dt): + if self.butterfly: + b = self.butterfly + b["time"] += dt + b["x"] += b["direction"] * b["speed"] * dt + b["y"] = b["base"] + b["amplitude"] * math.sin( + b["phase"] + b["time"] * b["frequency"]) + if b["x"] < -10 or b["x"] > WIDTH + 10: + self.butterfly = None + self.butterfly_delay = random.uniform(9, 17) + else: + self.butterfly_delay -= dt + if self.butterfly_delay <= 0: + self.spawn_butterfly() + + def start_dryer(self): + self.butterfly = None + self.butterfly_delay = random.uniform(10, 18) + self.projectile = None + self.trail = [] + self.phase = "dryer" + self.dryer_time = 0.0 + self.dryer_applied = False + self.dryer_before = self.players[self.active]["soak"] + self.dryer_after = max(0, self.dryer_before - 25) + + def update_dryer(self, dt): + self.dryer_time += dt + if not self.dryer_applied and self.dryer_time >= 1.6: + self.players[self.active]["soak"] = self.dryer_after + self.dryer_applied = True + if self.dryer_time >= DRYER_DURATION: + self.phase = "settle" + self.timer = 0.4 + + def check_winner(self): + wet = [p["soak"] >= 100 for p in self.players] + if any(wet): + self.phase = "over" + self.winner = -1 if all(wet) else (1 if wet[0] else 0) + return True + return False + + def finish_turn(self): + if self.check_winner(): + return + self.active = 1 - self.active + self.turn += 1 + self.walk = float(WALK_DISTANCE) + self.wind = random.randint(-5, 5) + self.trail = [] + self.splash = None + if self.computer and self.active == 1: + self.phase = "think" + self.ai_index = 0 + self.ai_best = None + self.ai_wait = 0.0 + else: + self.phase = "move" + + def evaluate_shot(self, angle, power): + ball = self.launch_values(angle, power) + # The same integrator/collision rules as real throws; doubled time step + # keeps each incremental search batch short on MicroPython. + while True: + self.advance_ball(ball, STEP * 2) + hit = self.impact(ball, bonuses=False) + if hit: + break + target = self.players[1 - self.active] + own = self.players[self.active] + distance = math.sqrt((ball[0] - target["x"]) ** 2 + + (ball[1] - (target["y"] - RIDER_HEIGHT)) ** 2) + self_distance = math.sqrt((ball[0] - own["x"]) ** 2 + + (ball[1] - (own["y"] - RIDER_HEIGHT)) ** 2) + return distance + max(0, 24 - self_distance) * 2 + + def think(self, dt): + self.ai_wait += dt + if self.ai_index < 120: + angle = 20 + (self.ai_index % 12) * 6 + power = 28 + (self.ai_index // 12) * 8 + score = self.evaluate_shot(angle, power) + if self.ai_best is None or score < self.ai_best[0]: + self.ai_best = (score, angle, power) + self.ai_index += 1 + elif self.ai_wait >= 1.0: + player = self.players[self.active] + player["angle"] = max(10, min(170, self.ai_best[1] + random.randint(-3, 3))) + player["power"] = max(20, min(100, self.ai_best[2] + random.randint(-3, 3))) + self.phase = "aim" + self.fire() + + def update(self, dt): + dt = max(0, min(dt, 0.1)) + if self.phase == "over": + return + if self.phase == "dryer": + self.update_dryer(dt) + return + self.update_butterfly(dt) + if self.phase in ("move", "aim", "think", "flight"): + self.settle(dt) + if self.check_winner(): + return + if self.phase == "think": + if self.carts_still(): + self.think(dt) + elif self.phase == "flight": + self.accumulator += dt + while self.accumulator >= STEP and self.phase == "flight": + self.accumulator -= STEP + self.advance_ball(self.projectile, STEP) + hit = self.impact(self.projectile) + if hit == "butterfly": + self.start_dryer() + elif hit == "hit": + self.explode(self.projectile[0], self.projectile[1]) + elif hit == "miss": + self.projectile = None + self.phase = "settle" + self.timer = 0.3 + if self.projectile: + self.trail.append((self.projectile[0], self.projectile[1])) + if len(self.trail) > 24: + self.trail.pop(0) + elif self.phase == "splash": + self.timer -= dt + if self.timer <= 0: + self.phase = "settle" + self.timer = 0.4 + elif self.phase == "settle": + if not self.settle(dt): + self.timer -= dt + if self.timer <= 0: + self.finish_turn() diff --git a/badge/apps/pig_vs_goose/icon.png b/badge/apps/pig_vs_goose/icon.png new file mode 100755 index 00000000..40c4f556 Binary files /dev/null and b/badge/apps/pig_vs_goose/icon.png differ diff --git a/badge/apps/pig_vs_goose/multiplayer.py b/badge/apps/pig_vs_goose/multiplayer.py new file mode 100755 index 00000000..a4460b0d --- /dev/null +++ b/badge/apps/pig_vs_goose/multiplayer.py @@ -0,0 +1,384 @@ +"""Host-authoritative gameplay over the same badgeware link used by Pong.""" + +import json +import struct + +from game import Game, WIDTH, HEIGHT, FLOOR + + +PROTOCOL = b"PVG1" +PHASES = ("move", "aim", "flight", "splash", "settle", "dryer", "over") +PLAYER_KEYS = ("x", "y", "vx", "vy", "fall", "tilt", "wheel", + "soak", "cause", "angle", "power") +STATE_KEYS = ("active", "phase", "walk", "wind", "projectile", "trail", + "splash", "timer", "revision", "winner", "turn", "butterfly", + "dryer_time", "dryer_before", "dryer_after", "dryer_applied") +SELECT = 1 +BACK = 2 +MENU = 4 +MAX_PAYLOAD = 24000 +MAX_PARTS = 2048 +RETRY = 0.7 +TIMEOUT = 12.0 + + +def controls(game, dt, horizontal, vertical, buttons): + if game.phase == "move": + game.move(horizontal * 12 * dt) + if buttons & SELECT: + game.aim() + elif game.phase == "aim": + player = game.players[game.active] + player["angle"] = max(10, min(170, player["angle"] + horizontal * 38 * dt)) + player["power"] = max(20, min(100, player["power"] + vertical * 35 * dt)) + if buttons & BACK: + game.phase = "move" + elif buttons & SELECT: + game.fire() + + +class Multiplayer: + def __init__(self, link, proximity): + self.link = link + self.proximity = proximity + self.game = None + self.status = "search" + self.message = "" + self.linked_before = False + self.accepted = False + self.peer_accepted = False + self.host = False + self.local_player = 0 + self.paused = False + self.round = 0 + self.round_seen = 0 + self.rematch = [False, False] + self.time = 0.0 + self.heard_at = 0.0 + self.hello_at = -1.0 + self.ping_at = -1.0 + self.snapshot_at = -1.0 + self.sequence = 0 + self.received_sequence = 0 + self.outgoing = None + self.outgoing_part = 0 + self.outgoing_at = 0.0 + self.outgoing_started = 0.0 + self.incoming_sequence = 0 + self.incoming_parts = {} + self.incoming_total = 0 + self.input_sequence = 0 + self.input_received = 0 + self.input_pending = None + self.input_at = -1.0 + self.input_started = 0.0 + self.remote_axes = (0, 0) + self.remote_until = 0.0 + self.terrain_acked = None + self.terrain_sending = None + self.snapshot_tag = None + + def send(self, payload): + return self.link.send(payload) + + def fail(self, reason): + print("Pig vs Goose link:", reason) + self.message = reason + self.status = "error" + self.remote_axes = (0, 0) + self.link.leave() + + def close(self): + self.link.close() + + def new_round(self): + self.round += 1 + self.game = Game(False) + self.paused = False + self.rematch = [False, False] + self.remote_axes = (0, 0) + self.remote_until = 0.0 + self.terrain_acked = None + self.status = "play" + + def snapshot(self): + game = self.game + packet = { + "r": self.round, + "p": [[p[key] for key in PLAYER_KEYS] for p in game.players], + "s": [getattr(game, key) for key in STATE_KEYS], + "z": self.paused, + "v": self.rematch, + } + tag = (self.round, game.revision) + if self.terrain_acked != tag: + packet["t"] = [game.initial_surface, game.craters] + self.terrain_sending = tag + return json.dumps(packet).encode() + + def apply_snapshot(self, payload): + data = json.loads(payload) + if (not isinstance(data, dict) or not isinstance(data.get("r"), int) + or not isinstance(data.get("p"), list) or len(data["p"]) != 2 + or any(not isinstance(p, list) or len(p) != len(PLAYER_KEYS) + for p in data["p"]) + or not isinstance(data.get("s"), list) + or len(data["s"]) != len(STATE_KEYS) + or data["s"][1] not in PHASES or data["s"][0] not in (0, 1) + or data.get("z") not in (True, False) + or not isinstance(data.get("v"), list) or len(data["v"]) != 2): + raise ValueError("Invalid game snapshot") + round_id = data["r"] + if round_id < self.round_seen: + return + terrain = data.get("t") + if self.game is None or round_id != self.round_seen: + if terrain is None: + raise ValueError("Missing new-round landscape") + self.game = Game(False) + self.game.revision = -1 + game = self.game + revision = data["s"][8] + if terrain is not None and (game.revision != revision or round_id != self.round_seen): + tops, craters = terrain + if (len(tops) != WIDTH or any(type(y) is not int or not 0 <= y <= FLOOR for y in tops) + or not isinstance(craters, list) + or any(not isinstance(c, list) or len(c) != 2 for c in craters)): + raise ValueError("Invalid landscape") + game.terrain = [bytearray(WIDTH) for _ in range(HEIGHT)] + for x, top in enumerate(tops): + for y in range(top, FLOOR): + game.terrain[y][x] = 1 + for x, y in craters: + game.carve_crater(x, y) + game.initial_surface = tops + game.craters = craters + elif game.revision != revision: + raise ValueError("Missing changed landscape") + game.players = [dict(zip(PLAYER_KEYS, p)) for p in data["p"]] + for key, value in zip(STATE_KEYS, data["s"]): + setattr(game, key, value) + self.round_seen = round_id + self.round = round_id + self.paused = data["z"] + self.rematch = data["v"] + self.status = "play" + + def queue_snapshot(self): + payload = self.snapshot() + size = min(180, self.link.capacity() - 9) + if size < 1: + self.fail("Badge link packet capacity too small") + return + if len(payload) > MAX_PAYLOAD: + self.fail("Match exceeds link snapshot limit") + return + if (len(payload) + size - 1) // size > MAX_PARTS: + self.fail("Landscape exceeds link fragment limit") + return + self.sequence += 1 + self.outgoing = [b"S" + struct.pack(" TIMEOUT: + self.fail("Game sync timed out; pair again") + return + if self.time - self.outgoing_at >= RETRY: + self.outgoing_part = 0 + self.outgoing_at = self.time + for _ in range(4): + if self.outgoing_part >= len(self.outgoing): + break + if not self.send(self.outgoing[self.outgoing_part]): + break + self.outgoing_part += 1 + self.outgoing_at = self.time + + def receive_snapshot(self, message): + if len(message) <= 9: + self.fail("Invalid snapshot packet") + return + sequence, index, total = struct.unpack("= total: + self.fail("Invalid snapshot fragment") + return + if sequence <= self.received_sequence: + self.send(b"K" + struct.pack(" MAX_PAYLOAD: + self.fail("Snapshot too large") + return + if len(self.incoming_parts) == total: + payload = b"".join(self.incoming_parts[i] for i in range(total)) + try: + self.apply_snapshot(payload) + except (ValueError, KeyError, TypeError, IndexError) as error: + self.fail("Invalid peer state: %s" % error) + return + self.received_sequence = sequence + self.incoming_parts = {} + self.send(b"K" + struct.pack(" 7: + self.fail("Invalid controller values") + return + self.send(b"J" + struct.pack(" TIMEOUT: + self.fail("Other badge stopped responding") + return + if self.time - self.ping_at >= 0.7: + self.send(b"P") + self.ping_at = self.time + if self.status == "offer" and buttons & SELECT: + self.accepted = True + buttons = 0 + if self.accepted and self.time - self.hello_at >= 0.4: + self.send(b"A" + PROTOCOL) + self.hello_at = self.time + if self.accepted and self.peer_accepted and self.status == "offer": + self.status = "sync" + if self.host: + self.new_round() + if self.host and self.game: + self.button_action(0, buttons if self.status == "play" else 0) + if not self.paused: + if self.game.active == 0: + controls(self.game, dt, horizontal, vertical, 0) + elif self.time < self.remote_until: + controls(self.game, dt, *self.remote_axes, 0) + self.game.update(dt) + tag = (self.round, self.game.turn, self.game.phase, self.paused) + if not self.outgoing and (self.time - self.snapshot_at >= 0.12 + or tag != self.snapshot_tag): + self.queue_snapshot() + self.stream() + elif not self.host and self.status == "play": + # Retry edge-triggered actions until acknowledged. Axis-only + # updates are disposable; their lease expires if the link stalls. + if buttons and self.input_pending: + self.message = "Waiting for previous control" + elif not self.input_pending and (buttons or self.time - self.input_at >= 0.1): + self.input_sequence += 1 + packet = b"I" + struct.pack("= 0.2: + if self.time - self.input_started > TIMEOUT: + self.fail("Control sync timed out; pair again") + return + self.send(self.input_pending) + self.input_at = self.time + self.link.update()