flyplays 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flyplays/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """flyplays: a real fruit fly brain plays games.
2
+
3
+ The complete fruit fly connectome (166,700 neurons, from the `flybrain` package) sees the game and presses the
4
+ keys. Nothing is trained and nothing is scripted.
5
+
6
+ from flyplays import play
7
+ from flyplays.games.fruit import FruitFall
8
+ print(play(FruitFall(), seed=1, seconds=30, clip="fruit.mp4"))
9
+
10
+ Command line: `flyplays list`, `flyplays play fruit --clip fruit.mp4`, `flyplays web <url> --keys ...`.
11
+ Write your own game: see GUIDE.md (a game is a class with six methods).
12
+ """
13
+ from .fly import DEFAULT, Decision, Fly, Thing
14
+ from .game import Game, State, Tap
15
+ from .runner import Result, play
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = ["DEFAULT", "Decision", "Fly", "Thing", "Game", "State", "Tap", "Result", "play"]
flyplays/__main__.py ADDED
@@ -0,0 +1,109 @@
1
+ """flyplays command line.
2
+
3
+ flyplays list
4
+ flyplays play fruit [--seed 3] [--seconds 60] [--clip fruit.mp4]
5
+ flyplays play flappy --flies 8 --dt 0.01 --clip flappy.mp4
6
+ flyplays play doom --opt level=e1m1
7
+ flyplays play my_game:MyGame # your own game class
8
+ flyplays web https://example.com/game --keys left=ArrowLeft,right=ArrowRight,escape=Space \\
9
+ --player 0.5,0.85 --facing up [--target-color "#ff3030"] [--clip web.mp4]
10
+ flyplays install-browser # once, for web games
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import subprocess
16
+ import sys
17
+
18
+
19
+ def seeds(text: str) -> list[int]:
20
+ out = []
21
+ for part in str(text).split(","):
22
+ a, _, b = part.partition("-")
23
+ out += list(range(int(a), int(b or a) + 1))
24
+ return out
25
+
26
+
27
+ def pairs(items, cast=float) -> dict:
28
+ out = {}
29
+ for kv in items or []:
30
+ for one in kv.split(","):
31
+ k, _, v = one.partition("=")
32
+ out[k.strip()] = cast(v) if cast else v
33
+ return out
34
+
35
+
36
+ def value(v: str):
37
+ for f in (int, float):
38
+ try:
39
+ return f(v)
40
+ except ValueError:
41
+ pass
42
+ return {"true": True, "false": False}.get(v.lower(), v)
43
+
44
+
45
+ def common(ap):
46
+ ap.add_argument("--seed", default="1", help="a seed, or a range like 1-10")
47
+ ap.add_argument("--seconds", type=float, default=60.0, help="stop after this much game time")
48
+ ap.add_argument("--until", type=float, help="stop shortly after reaching this score")
49
+ ap.add_argument("--clip", help="film to this .mp4 (9:16); with several seeds, {seed} is replaced")
50
+ ap.add_argument("--flies", type=int, default=1, help="flies voting (a brain batch; best on a GPU)")
51
+ ap.add_argument("--device", default="auto", help="auto | cpu | cuda")
52
+ ap.add_argument("--dt", type=float, help="brain step in seconds (default 0.02)")
53
+ ap.add_argument("--set", nargs="*", help="interface settings, e.g. threshold=8 escape=60 (see flyplays.fly.DEFAULT)")
54
+
55
+
56
+ def run(game_factory, a, game_dt=None) -> None:
57
+ from .fly import Fly
58
+ from .runner import play
59
+ fly = Fly(flies=a.flies, device=a.device, dt=a.dt or game_dt)
60
+ for sd in seeds(a.seed):
61
+ clip = a.clip.replace("{seed}", str(sd)) if a.clip else None
62
+ r = play(game_factory(), fly, seed=sd, seconds=a.seconds, clip=clip, until=a.until, settings=pairs(a.set))
63
+ print(r, f"-> {r.clip}" if r.clip else "", flush=True)
64
+
65
+
66
+ def main(argv=None) -> None:
67
+ ap = argparse.ArgumentParser(prog="flyplays", description="A real fruit fly brain plays games.")
68
+ sub = ap.add_subparsers(dest="cmd", required=True)
69
+ sub.add_parser("list", help="the built-in games")
70
+ p = sub.add_parser("play", help="play a built-in game or your own (module:Class)")
71
+ p.add_argument("game")
72
+ p.add_argument("--opt", nargs="*", help="game options, e.g. level=e1m1 easy=true")
73
+ common(p)
74
+ w = sub.add_parser("web", help="play any web game, seen through its pixels")
75
+ w.add_argument("url")
76
+ w.add_argument("--keys", required=True, help="action=key pairs: left=ArrowLeft,right=ArrowRight,escape=Space "
77
+ "(add :tap or :hold; escape taps, left/right hold by default)")
78
+ w.add_argument("--player", default="0.5,0.5", help="where the player is, as x,y fractions of the screen")
79
+ w.add_argument("--facing", default="up", choices=["up", "right"], help="up = top-down, right = side-scroller")
80
+ w.add_argument("--target-color", help="colour of things to go to, e.g. #ff3030")
81
+ w.add_argument("--start-keys", default="Space", help="keys pressed once to start the game (comma-separated)")
82
+ w.add_argument("--size", default="640,480", help="browser window, width,height")
83
+ common(w)
84
+ sub.add_parser("install-browser", help="download Chromium for web games (once)")
85
+ a = ap.parse_args(argv)
86
+
87
+ if a.cmd == "list":
88
+ from .games import GAMES
89
+ for name, (_, about) in GAMES.items():
90
+ print(f" {name:8} {about}")
91
+ print(" web any web game by URL: flyplays web <url> --keys ...")
92
+ elif a.cmd == "install-browser":
93
+ sys.exit(subprocess.call([sys.executable, "-m", "playwright", "install", "chromium"]))
94
+ elif a.cmd == "play":
95
+ from .games import load
96
+ cls = load(a.game)
97
+ opts = {k: value(v) for k, v in pairs(a.opt, cast=None).items()}
98
+ run(lambda: cls(**opts), a, getattr(cls, "dt", None))
99
+ elif a.cmd == "web":
100
+ from .games.web import PixelGame
101
+ px, py = (float(x) for x in a.player.split(","))
102
+ size = tuple(int(x) for x in a.size.split(","))
103
+ keys = pairs([a.keys], cast=None)
104
+ run(lambda: PixelGame(a.url, keys, (px, py), a.facing, a.target_color,
105
+ tuple(k for k in a.start_keys.split(",") if k), size), a)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
flyplays/fly.py ADDED
@@ -0,0 +1,185 @@
1
+ """The fly: what goes into the brain and what comes out.
2
+
3
+ Everything between IN and OUT is the real fruit fly connectome (MaleCNS v1.0, 166,700 neurons) from
4
+ `flybrain`, run as a spiking network. Nothing is trained and nothing is scripted: the only knobs are how
5
+ strongly the game's objects drive the eye neurons and how many spikes count as a key press.
6
+
7
+ IN - a game describes what is around the player as `Thing`s (where it is, how big it looks). Each one drives
8
+ the fly's own identified visual neurons on the side it is on (a fly sees almost all the way round):
9
+
10
+ target LC10a the neurons a male fly uses to chase a target
11
+ creature LC10a chase it while it's in view...
12
+ LPLC2 ...looming neurons when it grows (it's coming at you)
13
+ LC4 ...fast-looming neurons when it's big (close)
14
+ shot LPLC1 small objects coming at you
15
+
16
+ OUT - two groups of descending neurons (brain -> body), read as firing rates:
17
+
18
+ steering DNa02, DNg111, DNae002 left minus right turns the fly (LC10a drives them on its own side)
19
+ escape DNp01 (the giant fiber), DNp02, DNp04, DNp11 the escape jump (LPLC2/LC4 drive them)
20
+
21
+ A `Decision` says which of left / right / escape is over its threshold this step; the game turns that into keys.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ from dataclasses import dataclass, field
27
+
28
+ import numpy as np
29
+
30
+ SEE = {"chase": ["LC10a"], "loom": ["LPLC2"], "threat": ["LC4"], "shot": ["LPLC1"]}
31
+ STEER = ["DNa02", "DNg111", "DNae002"]
32
+ ESCAPE = ["DNp01", "DNp02", "DNp04", "DNp11"]
33
+
34
+ # Interface settings: how the game reaches the eye neurons and how spikes become keys. The brain itself is
35
+ # never changed. A game can ship its own values (Game.settings) and you can override any of them.
36
+ DEFAULT = {
37
+ "target": 1.0, # LC10a volts for a `target` dead ahead (split between the eyes by bearing)
38
+ "chase": 0.6, # LC10a volts while a `creature` is in view...
39
+ "chase_gain": 0.8, # ...plus this much per radian of its size
40
+ "loom_gain": 25.0, # LPLC2 volts per radian a `creature` grew since the last step
41
+ "threat": 0.25, # radians: a `creature` this big (close) drives LC4 fully...
42
+ "threat_max": 0.8, # ...with this many volts
43
+ "shot_gain": 25.0, # LPLC1 volts per radian a `shot` grew since the last step
44
+ "cap": 0.8, # most volts any input adds per step (a `target` may go up to `target`)
45
+ "spread": 0.35, # radians off dead ahead at which one eye gets all of a thing
46
+ "rate_tau": 0.05, # seconds: smoothing of the output firing rates
47
+ "threshold": 6.0, # Hz of left-minus-right steering that counts as "left" (or right-minus-left: "right")
48
+ "escape": 90.0, # Hz on the escape neurons (either side) that counts as "escape"
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class Thing:
54
+ """Something the fly can see, relative to the player.
55
+
56
+ bearing: radians from straight ahead, + = to the right, - = to the left. A fly sees about +-165 degrees.
57
+ size: how big it looks, in radians (for an object of radius r at distance d: 2 * atan(r / d)).
58
+ kind: "target" (something to go to), "creature" (something that can come at you) or "shot".
59
+ key: a stable id across steps (any hashable), so the fly can tell it is growing (looming).
60
+ """
61
+ bearing: float
62
+ size: float = 0.1
63
+ kind: str = "creature"
64
+ key: object = None
65
+
66
+
67
+ @dataclass
68
+ class Decision:
69
+ """What the fly's descending neurons said this step."""
70
+ left: bool = False
71
+ right: bool = False
72
+ escape: bool = False
73
+ steer_hz: tuple = (0.0, 0.0) # smoothed steering rates, left and right
74
+ escape_hz: tuple = (0.0, 0.0) # smoothed escape rates, left and right
75
+
76
+ @property
77
+ def turn_hz(self) -> float:
78
+ """Right-minus-left steering rate: + turns right. For games with analog turning."""
79
+ return self.steer_hz[1] - self.steer_hz[0]
80
+
81
+ @property
82
+ def escape_side(self) -> str:
83
+ """The side whose escape neurons fire more ("L"/"R"): a real fly jumps away from that side."""
84
+ return "L" if self.escape_hz[0] >= self.escape_hz[1] else "R"
85
+
86
+ @property
87
+ def actions(self) -> set[str]:
88
+ return {a for a in ("left", "right", "escape") if getattr(self, a)}
89
+
90
+
91
+ @dataclass
92
+ class Fly:
93
+ """A fly brain that plays. `brain` is a flybrain.FlyBrain (made for you when left out).
94
+
95
+ fly = Fly() # CPU or GPU, picked for you
96
+ fly = Fly(flies=8, device="cuda") # 8 flies see the same screen and vote
97
+ d = fly.step([Thing(bearing=-0.2, size=0.1, kind="target")])
98
+ """
99
+ brain: object = None
100
+ settings: dict = field(default_factory=dict)
101
+ flies: int = 1
102
+ device: str = "auto"
103
+ dt: float | None = None
104
+
105
+ def __post_init__(self):
106
+ if self.brain is None:
107
+ from flybrain import FlyBrain
108
+ kw = {"device": self.device, "batch": self.flies}
109
+ if self.dt:
110
+ kw["dt"] = self.dt
111
+ self.brain = FlyBrain(**kw)
112
+ unknown = set(self.settings) - set(DEFAULT)
113
+ if unknown:
114
+ raise ValueError(f"unknown settings {sorted(unknown)}; known: {', '.join(DEFAULT)}")
115
+ self.p = {**DEFAULT, **self.settings}
116
+ b = self.brain
117
+ self.see = {ch: {s: b.cells(t, s) for s in "LR"} for ch, t in SEE.items()}
118
+ self.steer = {s: b.cells(STEER, s) for s in "LR"}
119
+ self.escape_cells = {s: b.cells(ESCAPE, s) for s in "LR"}
120
+ self.dt = float(b.dt)
121
+ self.reset()
122
+
123
+ def reset(self, seed: int | None = None, settle: float = 0.5) -> None:
124
+ """Fresh voltages, then `settle` seconds with nothing to see so the network is calm at the start."""
125
+ self.brain.reset(seed)
126
+ for _ in range(int(round(settle / self.dt))):
127
+ self.brain.step()
128
+ self.L = self.R = self.EL = self.ER = 0.0
129
+ self.prev_size: dict = {}
130
+ self.drive = {f"{ch}{s}": 0.0 for ch in SEE for s in "LR"}
131
+ self.fired = np.empty(0, np.int64)
132
+ self.last = Decision()
133
+
134
+ def configure(self, **settings) -> None:
135
+ unknown = set(settings) - set(DEFAULT)
136
+ if unknown:
137
+ raise ValueError(f"unknown settings {sorted(unknown)}; known: {', '.join(DEFAULT)}")
138
+ self.p.update(settings)
139
+
140
+ def inputs(self, things) -> list:
141
+ """The volts each eye-neuron group gets from what's in view (and remembers sizes for looming)."""
142
+ p = self.p
143
+ d = {k: 0.0 for k in self.drive}
144
+ seen = {}
145
+ for i, t in enumerate(things):
146
+ key = t.key if t.key is not None else ("_", i)
147
+ left = min(1.0, max(0.0, 0.5 - t.bearing / p["spread"])) # share going to the left eye
148
+ growth = max(0.0, t.size - self.prev_size.get(key, t.size))
149
+ seen[key] = t.size
150
+ for s, share in (("L", left), ("R", 1.0 - left)):
151
+ if share <= 0:
152
+ continue
153
+ if t.kind == "target":
154
+ d[f"chase{s}"] = max(d[f"chase{s}"], share * p["target"])
155
+ elif t.kind == "shot":
156
+ d[f"shot{s}"] = max(d[f"shot{s}"], min(p["cap"], share * growth * p["shot_gain"]))
157
+ else:
158
+ d[f"chase{s}"] = max(d[f"chase{s}"], min(p["cap"], share * (p["chase"] + p["chase_gain"] * t.size)))
159
+ d[f"loom{s}"] = max(d[f"loom{s}"], min(p["cap"], share * growth * p["loom_gain"]))
160
+ d[f"threat{s}"] = max(d[f"threat{s}"],
161
+ min(p["cap"], share * p["threat_max"] * min(1.0, t.size / p["threat"]) ** 3))
162
+ self.prev_size = seen
163
+ self.drive = d
164
+ return [(self.see[k[:-1]][k[-1]], v) for k, v in d.items() if v > 0]
165
+
166
+ def step(self, things=()) -> Decision:
167
+ """One brain step (dt seconds of fly time) with these things in view."""
168
+ p = self.p
169
+ fired = self.brain.step(inject=self.inputs(things))
170
+ flies = fired if isinstance(fired, list) else [fired] # several flies vote by averaging their spikes
171
+ self.fired = flies[0]
172
+
173
+ def count(cells):
174
+ return sum(int(np.isin(f, cells).sum()) for f in flies) / len(flies)
175
+
176
+ a = math.exp(-self.dt / p["rate_tau"])
177
+ rate = lambda old, n: a * old + (1 - a) * n / self.dt # noqa: E731
178
+ self.L, self.R = rate(self.L, count(self.steer["L"])), rate(self.R, count(self.steer["R"]))
179
+ self.EL = rate(self.EL, count(self.escape_cells["L"]))
180
+ self.ER = rate(self.ER, count(self.escape_cells["R"]))
181
+ diff = self.L - self.R
182
+ self.last = Decision(left=diff > p["threshold"], right=-diff > p["threshold"],
183
+ escape=max(self.EL, self.ER) > p["escape"],
184
+ steer_hz=(self.L, self.R), escape_hz=(self.EL, self.ER))
185
+ return self.last
flyplays/game.py ADDED
@@ -0,0 +1,83 @@
1
+ """What a game has to do so a fly can play it. See GUIDE.md for a walk-through.
2
+
3
+ A game is a class with six methods. flyplays calls them in this loop, once per brain step (20 ms by default):
4
+
5
+ game.start(seed)
6
+ while alive:
7
+ state = game.tick(dt) # move the game on by dt seconds
8
+ things = game.sense() # what is around the player, as Things
9
+ decision = fly.step(things) # one step of the real connectome
10
+ game.act(decision) # turn left / right / escape into keys
11
+ frame = game.frame() # (only when filming) the screen, an RGB numpy array
12
+ game.close()
13
+
14
+ The game must not run on its own between calls: time only moves in tick(). That's how a fly brain that thinks
15
+ slower than real time still plays at the game's real speed, and why a seed replays the same game.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+
23
+ from .fly import Decision, Thing
24
+
25
+
26
+ @dataclass
27
+ class State:
28
+ alive: bool = True
29
+ score: float = 0.0
30
+
31
+
32
+ class Game:
33
+ """Base class. Override what you need; see games/fruit.py for a complete small example."""
34
+
35
+ name = "game"
36
+ title = "A REAL FLY BRAIN PLAYS" # the banner on clips
37
+ score_name = "score"
38
+ settings: dict = {} # interface settings tuned for this game (see fly.DEFAULT)
39
+ dt: float | None = None # brain step this game plays best at (None = the brain's 20 ms)
40
+ labels = {"left": "LEFT", "right": "RIGHT", "escape": "ESCAPE"} # what each action shows on clips
41
+
42
+ def start(self, seed: int) -> None:
43
+ raise NotImplementedError
44
+
45
+ def tick(self, seconds: float) -> State:
46
+ raise NotImplementedError
47
+
48
+ def sense(self) -> list[Thing]:
49
+ return []
50
+
51
+ def act(self, decision: Decision) -> None:
52
+ pass
53
+
54
+ def frame(self) -> np.ndarray | None:
55
+ return None
56
+
57
+ def close(self) -> None:
58
+ pass
59
+
60
+ # ---- helpers for games -------------------------------------------------------------------------------
61
+
62
+ @staticmethod
63
+ def thing_at(dx: float, ahead: float, radius: float, kind: str = "creature", key=None) -> Thing:
64
+ """A Thing from screen geometry: `dx` to the player's right, `ahead` in front of it, both in the same
65
+ units as `radius`. For a game seen from above where the player faces up, ahead = player_y - y."""
66
+ dist = max(1e-6, float(np.hypot(dx, ahead)))
67
+ return Thing(bearing=float(np.arctan2(dx, ahead)), size=2 * float(np.arctan(radius / dist)),
68
+ kind=kind, key=key)
69
+
70
+
71
+ class Tap:
72
+ """A key that is pressed once per decision, then rests for `refractory` seconds (a flap, a jump, a shot)."""
73
+
74
+ def __init__(self, refractory: float = 0.25):
75
+ self.refractory = refractory
76
+ self.since = 1e9
77
+
78
+ def __call__(self, want: bool, dt: float) -> bool:
79
+ self.since += dt
80
+ if want and self.since >= self.refractory:
81
+ self.since = 0.0
82
+ return True
83
+ return False
@@ -0,0 +1,23 @@
1
+ """Built-in games, by name. Your own game can be played by its import path: `flyplays play my_module:MyGame`."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+
6
+ GAMES = {
7
+ "fruit": ("flyplays.games.fruit:FruitFall", "Fruit Fall: catch fruit, dodge the swatter (pure Python, no browser)"),
8
+ "flappy": ("flyplays.games.flappy:Flappy", "Flappy Bird, on the open-source Floppy Bird site (needs [web])"),
9
+ "doom": ("flyplays.games.doom:Doom", "Freedoom through ViZDoom; options: level=center|corridor|map01|e1m1 (needs [doom])"),
10
+ }
11
+
12
+
13
+ def load(name: str):
14
+ """A game class from a built-in name or "package.module:ClassName"."""
15
+ path = GAMES[name][0] if name in GAMES else name
16
+ if ":" not in path:
17
+ raise SystemExit(f"unknown game {name!r}: one of {', '.join(GAMES)}, or module:Class")
18
+ mod, _, cls = path.partition(":")
19
+ import os
20
+ import sys
21
+ if os.getcwd() not in sys.path: # so `flyplays play my_game:MyGame` finds my_game.py in this folder
22
+ sys.path.insert(0, os.getcwd())
23
+ return getattr(importlib.import_module(mod), cls)
flyplays/games/doom.py ADDED
@@ -0,0 +1,137 @@
1
+ """Doom: the fly plays Freedoom (the free Doom-engine game) through ViZDoom, headless and silent.
2
+ Needs `pip install "flyplays[doom]"` (ViZDoom ships Freedoom, nothing else to download).
3
+
4
+ The fly sees nearly all the way round: every monster that isn't in the blind spot straight behind is a
5
+ `creature` on its side (LC10a while in view, LPLC2 as it comes closer, LC4 when it's close), fireballs are
6
+ `shot`s (LPLC1). Out: left-minus-right steering turns the player; a burst on the escape neurons - in a real fly,
7
+ the escape jump - pulls the trigger and backs away from that side. Every shot is the fly trying to get away.
8
+
9
+ Levels: "center" (ViZDoom's arena: monsters come from all sides, turn and shoot), "corridor",
10
+ "map01" (Freedoom: Phase 2), "e1m1" (Freedoom: Phase 1).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ import numpy as np
17
+
18
+ from ..fly import Decision, Thing
19
+ from ..game import Game, State
20
+
21
+ MONSTERS = {"Zombieman", "ShotgunGuy", "ChaingunGuy", "DoomImp", "Demon", "Spectre", "LostSoul", "Cacodemon",
22
+ "HellKnight", "BaronOfHell", "Arachnotron", "PainElemental", "Revenant", "Fatso", "Archvile",
23
+ "Cyberdemon", "SpiderMastermind", "WolfensteinSS", "MarineChainsawVzd", "ZombieMan"}
24
+ SHOTS = {"DoomImpBall", "CacodemonBall", "BaronBall", "FatShot", "RevenantTracer", "ArachnotronPlasma",
25
+ "Rocket", "PlasmaBall"}
26
+ TIC = 1 / 35
27
+
28
+
29
+ class Doom(Game):
30
+ name = "doom"
31
+ title = "A REAL FLY BRAIN PLAYS DOOM"
32
+ score_name = "kills"
33
+ labels = {"left": "", "right": "", "escape": "BANG!"}
34
+ settings = {"chase": 0.6, "chase_gain": 0.8, "loom_gain": 25.0, "threat": 0.25, "threat_max": 0.8,
35
+ "shot_gain": 25.0, "rate_tau": 0.08, "escape": 90.0}
36
+ dt = 1 / 70 # two brain steps per Doom tic
37
+ TURN = 0.35 # degrees per tic per Hz of right-minus-left steering
38
+ BACK = 6.0 # units per tic backed away while the escape neurons fire (levels you can walk)
39
+
40
+ def __init__(self, level: str = "center", skill: int = 3):
41
+ try:
42
+ import vizdoom as vzd
43
+ except ImportError as e:
44
+ raise SystemExit('Doom needs ViZDoom: pip install "flyplays[doom]"') from e
45
+ self.vzd, self.level, self.skill = vzd, level, skill
46
+
47
+ def start(self, seed: int) -> None:
48
+ vzd = self.vzd
49
+ vz = os.path.dirname(vzd.__file__)
50
+ scen = os.path.join(vz, "scenarios")
51
+ maps = {"center": (os.path.join(scen, "defend_the_center.wad"), "map01", False, "defend_the_center.cfg"),
52
+ "corridor": (os.path.join(scen, "deadly_corridor.wad"), "map01", True, "deadly_corridor.cfg"),
53
+ "map01": (os.path.join(vz, "freedoom2.wad"), "map01", True, None),
54
+ "e1m1": (os.path.join(vz, "freedoom1.wad"), "e1m1", True, None)}
55
+ if self.level not in maps:
56
+ raise SystemExit(f"unknown level {self.level}; one of {', '.join(maps)}")
57
+ wad, mp, self.can_walk, cfg = maps[self.level]
58
+ g = vzd.DoomGame()
59
+ if cfg:
60
+ g.load_config(os.path.join(scen, cfg))
61
+ (g.set_doom_game_path if "freedoom" in wad else g.set_doom_scenario_path)(wad)
62
+ g.set_doom_map(mp)
63
+ g.set_doom_skill(self.skill)
64
+ g.set_screen_resolution(vzd.ScreenResolution.RES_320X240)
65
+ g.set_screen_format(vzd.ScreenFormat.RGB24)
66
+ g.set_labels_buffer_enabled(True)
67
+ g.set_objects_info_enabled(True)
68
+ g.set_render_hud(True)
69
+ g.set_render_messages(False)
70
+ g.set_window_visible(False)
71
+ g.set_sound_enabled(False)
72
+ g.set_mode(vzd.Mode.PLAYER)
73
+ g.set_episode_timeout(0)
74
+ g.set_seed(seed)
75
+ g.set_available_buttons([vzd.Button.TURN_LEFT_RIGHT_DELTA, vzd.Button.MOVE_FORWARD_BACKWARD_DELTA,
76
+ vzd.Button.MOVE_LEFT_RIGHT_DELTA, vzd.Button.ATTACK])
77
+ g.set_available_game_variables([vzd.GameVariable.HEALTH, vzd.GameVariable.KILLCOUNT, vzd.GameVariable.DEAD,
78
+ vzd.GameVariable.POSITION_X, vzd.GameVariable.POSITION_Y,
79
+ vzd.GameVariable.ANGLE])
80
+ g.init()
81
+ g.new_episode()
82
+ self.g = g
83
+ self.img = np.zeros((240, 320, 3), np.uint8)
84
+ self.things: list[Thing] = []
85
+ self.kills, self.dead, self.clock = 0, False, 0.0
86
+ self.action = [0.0, 0.0, 0.0, 0.0]
87
+ self._read()
88
+
89
+ def _read(self) -> None:
90
+ s = self.g.get_state()
91
+ if s is None:
92
+ return
93
+ self.img = s.screen_buffer
94
+ hp, k, dead, px, py, ang = s.game_variables
95
+ self.kills, self.dead = int(k), bool(dead)
96
+ things = []
97
+ for o in s.objects:
98
+ shot = o.name in SHOTS
99
+ if o.name not in MONSTERS and not shot:
100
+ continue
101
+ dx, dy = o.position_x - px, o.position_y - py
102
+ dist = max(16.0, float(np.hypot(dx, dy)))
103
+ rel = -((np.degrees(np.arctan2(dy, dx)) - ang + 180) % 360 - 180) # + = to the player's right
104
+ if abs(rel) > 165: # the fly's blind spot
105
+ continue
106
+ height = 16.0 if shot else 56.0
107
+ things.append(Thing(float(np.radians(rel)), 2 * float(np.arctan(height / 2 / dist)),
108
+ "shot" if shot else "creature", int(o.id)))
109
+ self.things = things
110
+
111
+ def tick(self, seconds: float) -> State:
112
+ self.clock += seconds
113
+ while self.clock >= TIC and not self.dead:
114
+ self.clock -= TIC
115
+ self.g.make_action(self.action, 1)
116
+ if self.g.is_episode_finished():
117
+ self.dead = True
118
+ break
119
+ self._read()
120
+ return State(not self.dead, self.kills)
121
+
122
+ def sense(self):
123
+ return self.things
124
+
125
+ def act(self, d: Decision) -> None:
126
+ back = self.BACK if (d.escape and self.can_walk) else 0.0
127
+ away = (1.0 if d.escape_side == "L" else -1.0) * back # scared on the left -> step right
128
+ self.action = [self.TURN * d.turn_hz, -back, away, float(d.escape)]
129
+
130
+ def frame(self) -> np.ndarray:
131
+ return self.img
132
+
133
+ def close(self) -> None:
134
+ try:
135
+ self.g.close()
136
+ except Exception:
137
+ pass
@@ -0,0 +1,78 @@
1
+ """Flappy Bird: the fly plays Floppy Bird (https://nebezb.com/floppybird/, Nebez Briefkani's open-source remake)
2
+ on its own site, in headless Chrome. The game isn't changed. Needs `pip install "flyplays[web]"`.
3
+
4
+ The fly rides the bird, looking right at the pipes. The opening ahead (the gap in the next pipe) is a `target`:
5
+ looking right across the screen, the fly's left eye covers the top of the screen and its right eye the bottom,
6
+ so a gap above drives LC10a on the left. "Turn left" = up = flap: when the left steering neurons out-fire the
7
+ right ones by `threshold` Hz, we press Space. Nothing says "flap when you're under the gap".
8
+
9
+ Best results: 8 flies voting on a GPU (`--flies 8 --dt 0.01`). One fly on a CPU usually gets a pipe or two.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+
15
+ from ..fly import Decision, Thing
16
+ from ..game import Tap
17
+ from .web import WebGame
18
+
19
+ READ_JS = """
20
+ () => {
21
+ const r = (el) => el.getBoundingClientRect();
22
+ const bird = r(document.getElementById('player'));
23
+ const fly = r(document.getElementById('flyarea'));
24
+ const bx = bird.left + bird.width / 2, by = bird.top + bird.height / 2;
25
+ let gap = null;
26
+ for (const p of document.querySelectorAll('.pipe')) {
27
+ const u = r(p.querySelector('.pipe_upper')), l = r(p.querySelector('.pipe_lower'));
28
+ if (u.right < bird.left || u.left > fly.right) continue;
29
+ if (!gap || u.left < gap.x) gap = { x: u.left, top: u.bottom, bottom: l.top };
30
+ }
31
+ const ceil = r(document.getElementById('ceiling')).bottom, land = r(document.getElementById('land')).top;
32
+ const top = gap ? gap.top : ceil, bottom = gap ? gap.bottom : land;
33
+ return { bx, by, gy: (top + bottom) / 2, gdx: gap ? gap.x - bird.right : 200,
34
+ alive: window.currentstate !== 2, score: window.score || 0 };
35
+ }
36
+ """
37
+
38
+
39
+ class Flappy(WebGame):
40
+ name = "flappy"
41
+ title = "A REAL FLY BRAIN PLAYS FLAPPY BIRD"
42
+ score_name = "pipes"
43
+ labels = {"left": "FLAP!", "right": "", "escape": ""}
44
+ url = "https://nebezb.com/floppybird/"
45
+ viewport = {"width": 480, "height": 720}
46
+ clip_box = (0, 0, 480, 640)
47
+ dt = 0.01 # 10 ms brain steps steer in time better than 20 ms
48
+ settings = {"target": 1.0, "spread": 0.35, "rate_tau": 0.05, "threshold": 6.0, "escape": 1e9}
49
+
50
+ def __init__(self, easy: bool = False, gap: int = 0):
51
+ self.easy, self.gap = easy, gap
52
+ if easy:
53
+ self.url += "?easy"
54
+
55
+ def ready(self, seed: int) -> dict:
56
+ self.page.clock.run_for(1500) # the splash settles
57
+ if self.gap: # the game's own pipe gap (90 px normal, 200 in ?easy)
58
+ self.page.evaluate("g => { pipeheight = g; }", self.gap)
59
+ self.page.keyboard.press("Space") # start (the game gives one jump at start)
60
+ self.flap = Tap(0.25)
61
+ self.dt = 0.02
62
+ return self.read()
63
+
64
+ def read(self) -> dict:
65
+ return self.page.evaluate(READ_JS)
66
+
67
+ def tick(self, seconds: float):
68
+ self.dt = seconds
69
+ return super().tick(seconds)
70
+
71
+ def sense(self):
72
+ s = self.state
73
+ up = math.atan2(s["by"] - s["gy"], max(s["gdx"], 60.0)) # + = the opening is above = the fly's left
74
+ return [Thing(bearing=-up, size=0.1, kind="target", key="gap")]
75
+
76
+ def act(self, d: Decision) -> None:
77
+ if self.flap(d.left, self.dt):
78
+ self.page.keyboard.press("Space")