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 +19 -0
- flyplays/__main__.py +109 -0
- flyplays/fly.py +185 -0
- flyplays/game.py +83 -0
- flyplays/games/__init__.py +23 -0
- flyplays/games/doom.py +137 -0
- flyplays/games/flappy.py +78 -0
- flyplays/games/fruit.py +116 -0
- flyplays/games/web.py +255 -0
- flyplays/record.py +139 -0
- flyplays/runner.py +83 -0
- flyplays-0.1.0.dist-info/METADATA +169 -0
- flyplays-0.1.0.dist-info/RECORD +17 -0
- flyplays-0.1.0.dist-info/WHEEL +5 -0
- flyplays-0.1.0.dist-info/entry_points.txt +2 -0
- flyplays-0.1.0.dist-info/licenses/LICENSE +21 -0
- flyplays-0.1.0.dist-info/top_level.txt +1 -0
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
|
flyplays/games/flappy.py
ADDED
|
@@ -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")
|