magmacrunch 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.
- magmacrunch/__init__.py +20 -0
- magmacrunch/__main__.py +101 -0
- magmacrunch/app.py +100 -0
- magmacrunch/banner.py +172 -0
- magmacrunch/cards.py +174 -0
- magmacrunch/scenes.py +284 -0
- magmacrunch/theme.py +96 -0
- magmacrunch-0.1.0.dist-info/METADATA +140 -0
- magmacrunch-0.1.0.dist-info/RECORD +13 -0
- magmacrunch-0.1.0.dist-info/WHEEL +4 -0
- magmacrunch-0.1.0.dist-info/entry_points.txt +2 -0
- magmacrunch-0.1.0.dist-info/licenses/LICENSE +75 -0
- magmacrunch-0.1.0.dist-info/licenses/NOTICE +33 -0
magmacrunch/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The magmacrunch terminal arcade.
|
|
2
|
+
|
|
3
|
+
A menu of every installed cabinet, and the wiring that seats one on the
|
|
4
|
+
terminal it is already holding.
|
|
5
|
+
|
|
6
|
+
Games are found by enumerating the ``magmacrunch.games`` entry point group -
|
|
7
|
+
see :mod:`texastoast.arcade` for the contract. **Nothing in this package
|
|
8
|
+
imports a game.** Installing one makes it appear here; uninstalling makes it
|
|
9
|
+
vanish; neither needs a release of the arcade.
|
|
10
|
+
|
|
11
|
+
Imports are kept out of this module so that ``import magmacrunch`` costs
|
|
12
|
+
nothing: the launcher's own screens pull in the engine's terminal backend, and
|
|
13
|
+
``magmacrunch --help`` should not pay for it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["__version__"]
|
magmacrunch/__main__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""``magmacrunch`` - open the arcade.
|
|
2
|
+
|
|
3
|
+
magmacrunch play
|
|
4
|
+
magmacrunch --list print the installed cabinets and exit
|
|
5
|
+
|
|
6
|
+
Discovery happens here, before the terminal is taken, and that placement is
|
|
7
|
+
load-bearing. :func:`texastoast.arcade.discover` reports a game it could not
|
|
8
|
+
load with :func:`warnings.warn`, and a warning written onto a live Textual
|
|
9
|
+
screen corrupts it. Enumerating first puts anything it has to say on an
|
|
10
|
+
ordinary terminal, where it can be read.
|
|
11
|
+
|
|
12
|
+
``--list`` is the debugging surface for the same thing: it answers "why is my
|
|
13
|
+
game not in the menu" without taking the screen over at all.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main() -> None:
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="magmacrunch",
|
|
24
|
+
description="The magmacrunch terminal arcade.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--list", action="store_true", dest="list_only",
|
|
28
|
+
help="print the installed cabinets and exit, without opening the arcade",
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
# Imported here, not at module scope, so --help works without the engine
|
|
33
|
+
# or its terminal extra installed.
|
|
34
|
+
from texastoast.arcade import discover
|
|
35
|
+
|
|
36
|
+
games = discover()
|
|
37
|
+
|
|
38
|
+
if args.list_only:
|
|
39
|
+
_print(games)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
from texastoast.core.tui_host import TuiHost
|
|
43
|
+
|
|
44
|
+
from magmacrunch.app import ARCADE_INFO, ArcadeApp
|
|
45
|
+
|
|
46
|
+
host = TuiHost(title=ARCADE_INFO.title, fps=ARCADE_INFO.fps,
|
|
47
|
+
hold_ms=ARCADE_INFO.hold_ms)
|
|
48
|
+
host.push_scene(ArcadeApp(host, games).root_scene)
|
|
49
|
+
host.run()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _print(games: list) -> None:
|
|
53
|
+
"""What ``--list`` shows: what was found, and what it wants.
|
|
54
|
+
|
|
55
|
+
Titles and blurbs are written by whoever wrote the game, so they can hold
|
|
56
|
+
anything Unicode holds - a suit glyph, a card, an emoji. Redirected to a
|
|
57
|
+
file on a legacy Windows codepage that is a ``UnicodeEncodeError``, and a
|
|
58
|
+
diagnostic that dies on the thing it is diagnosing is worse than useless.
|
|
59
|
+
Degrade the characters instead.
|
|
60
|
+
"""
|
|
61
|
+
_degrade_gracefully()
|
|
62
|
+
|
|
63
|
+
if not games:
|
|
64
|
+
print("No cabinets installed.")
|
|
65
|
+
print()
|
|
66
|
+
print(" pip install magmacrunch-george-boole")
|
|
67
|
+
print(" pip install magmacrunch-thld")
|
|
68
|
+
print()
|
|
69
|
+
print("Any package declaring a magmacrunch.games entry point appears")
|
|
70
|
+
print("here. Nothing above this line is a hardcoded list.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
print(f"{len(games)} cabinet{'s' if len(games) != 1 else ''} installed:")
|
|
74
|
+
print()
|
|
75
|
+
for game in games:
|
|
76
|
+
info = game.info
|
|
77
|
+
print(f" {info.key}")
|
|
78
|
+
print(f" {info.title}")
|
|
79
|
+
print(f" {info.blurb}")
|
|
80
|
+
print(f" wants {info.min_cols}x{info.min_rows}, "
|
|
81
|
+
f"{info.fps} fps, hold {info.hold_ms} ms")
|
|
82
|
+
print()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _degrade_gracefully() -> None:
|
|
86
|
+
"""Let stdout substitute characters it cannot encode rather than raising."""
|
|
87
|
+
import sys
|
|
88
|
+
|
|
89
|
+
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
|
90
|
+
if reconfigure is None:
|
|
91
|
+
return
|
|
92
|
+
try:
|
|
93
|
+
reconfigure(errors="replace")
|
|
94
|
+
except (ValueError, OSError):
|
|
95
|
+
# Already detached, or not a stream that can be reconfigured. Printing
|
|
96
|
+
# is still worth attempting; this was only ever insurance.
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
magmacrunch/app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Wiring - what outlives one visit to a cabinet, and how the screen reaches it.
|
|
2
|
+
|
|
3
|
+
Everything that draws lives in :mod:`magmacrunch.scenes`. This holds the host,
|
|
4
|
+
the cabinets that were found, and the one decision the launcher actually makes:
|
|
5
|
+
seating a game.
|
|
6
|
+
|
|
7
|
+
**The terminal is not owned here.** It belongs to a
|
|
8
|
+
:class:`~texastoast.core.tui_host.TuiHost`, which this is handed - the same
|
|
9
|
+
arrangement the games use, for the same reason. The launcher is not a special
|
|
10
|
+
case of the host; it is another thing seated on one, which is why the menu can
|
|
11
|
+
sit underneath a game and come back when the game leaves.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from texastoast.arcade import ArcadeGame, GameInfo
|
|
19
|
+
|
|
20
|
+
from magmacrunch import theme
|
|
21
|
+
from magmacrunch.scenes import CabinetScene
|
|
22
|
+
|
|
23
|
+
#: The launcher, described with the same dataclass it reads from cabinets.
|
|
24
|
+
#:
|
|
25
|
+
#: This is not decoration. ``TuiHost.seat()`` applies a seated game's ``fps``
|
|
26
|
+
#: and ``hold_ms`` to the terminal and nothing puts them back, so the menu has
|
|
27
|
+
#: to retune on the way out of a game - and having its own ``GameInfo`` makes
|
|
28
|
+
#: that ``host.apply(ARCADE_INFO)`` rather than a pair of remembered numbers.
|
|
29
|
+
ARCADE_INFO = GameInfo(
|
|
30
|
+
key="arcade",
|
|
31
|
+
title="magmacrunch arcade",
|
|
32
|
+
blurb="Every cabinet installed on this machine.",
|
|
33
|
+
# The menu changes only when a key is pressed, and edge input is what keeps
|
|
34
|
+
# one arrow press from running the selection down the whole list.
|
|
35
|
+
fps=20,
|
|
36
|
+
hold_ms=0,
|
|
37
|
+
min_cols=theme.MIN_COLS,
|
|
38
|
+
min_rows=theme.MIN_ROWS,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ArcadeApp:
|
|
43
|
+
"""A visit to the arcade, drawing on somebody else's terminal."""
|
|
44
|
+
|
|
45
|
+
#: What this asks of a terminal, in the same shape a cabinet declares it.
|
|
46
|
+
#: The menu reads it back through here when it retunes on the way out of a
|
|
47
|
+
#: game, which is why the scene needs no import from this module.
|
|
48
|
+
info = ARCADE_INFO
|
|
49
|
+
|
|
50
|
+
def __init__(self, host: Any, games: list[ArcadeGame] | None = None):
|
|
51
|
+
self.host = host
|
|
52
|
+
#: Sorted by title, the way :func:`texastoast.arcade.discover` returns
|
|
53
|
+
#: them. Discovery happens before the terminal is taken - see
|
|
54
|
+
#: ``__main__`` - so this is handed in rather than looked up.
|
|
55
|
+
self.games: list[ArcadeGame] = list(games or [])
|
|
56
|
+
#: What went wrong last time somebody chose a cabinet, if anything.
|
|
57
|
+
self.error: str = ""
|
|
58
|
+
|
|
59
|
+
#: The menu. The caller pushes it, so that this class does not decide
|
|
60
|
+
#: what the bottom of somebody else's scene stack is.
|
|
61
|
+
self.root_scene = CabinetScene(self)
|
|
62
|
+
|
|
63
|
+
# -- What the scene reaches for ----------------------------------
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def renderer(self):
|
|
67
|
+
return self.host.renderer
|
|
68
|
+
|
|
69
|
+
# -- The one decision --------------------------------------------
|
|
70
|
+
|
|
71
|
+
def play(self, game: ArcadeGame) -> bool:
|
|
72
|
+
"""Seat a cabinet. Returns whether it started.
|
|
73
|
+
|
|
74
|
+
``TuiHost.seat()`` calls ``game.start(host)``, and that is where a
|
|
75
|
+
cabinet's real imports finally happen - its rules, its screens, its
|
|
76
|
+
widgets. It is therefore the first moment a bad install can raise, and
|
|
77
|
+
it raises with the terminal live and a menu underneath.
|
|
78
|
+
|
|
79
|
+
:func:`texastoast.arcade.discover` already refuses to let one broken
|
|
80
|
+
game take down the whole menu. This keeps that promise past the point
|
|
81
|
+
where discovery stops looking: the arcade stays up and says which
|
|
82
|
+
cabinet is out of order.
|
|
83
|
+
"""
|
|
84
|
+
self.error = ""
|
|
85
|
+
try:
|
|
86
|
+
self.host.seat(game)
|
|
87
|
+
except Exception as exc: # noqa: BLE001 - one bad cabinet is not fatal
|
|
88
|
+
self.error = f"{game.info.title} would not start: {exc}"
|
|
89
|
+
return False
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
# -- Introspection, for tests ------------------------------------
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def scene(self):
|
|
96
|
+
return self.host.scene
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def in_menu(self) -> bool:
|
|
100
|
+
return self.host.scene is self.root_scene
|
magmacrunch/banner.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""The title art, and picking the biggest one that fits.
|
|
2
|
+
|
|
3
|
+
``arcade.css`` sets ``Press Start 2P`` on nearly every string on the arcade
|
|
4
|
+
page. None of that can come across: a terminal program does not choose its
|
|
5
|
+
font. What carries the same look instead is art drawn *out of characters*,
|
|
6
|
+
which reads the same in whatever monospace font the terminal is set to - and
|
|
7
|
+
the web page already does exactly this for its own title, in Courier Prime
|
|
8
|
+
rather than Press Start 2P, for the same reason.
|
|
9
|
+
|
|
10
|
+
A terminal is small and resizable, so one piece of art is not enough. The
|
|
11
|
+
variants below are ordered **best first** and :func:`best_fit` returns the
|
|
12
|
+
first the window can actually hold, down to a single line of spaced capitals
|
|
13
|
+
that fits anything. Best rather than widest, because two variants can differ
|
|
14
|
+
on both axes: :data:`ARCADE` is narrower than :data:`WELCOME` and wants twice
|
|
15
|
+
the rows, and on an ordinary 80x24 terminal it is the one worth showing.
|
|
16
|
+
|
|
17
|
+
That ladder is what keeps the floor low: the banner gives up room before the
|
|
18
|
+
cabinets have to, so the smallest window the arcade draws in is set by one
|
|
19
|
+
card and not by the art.
|
|
20
|
+
|
|
21
|
+
**Adding art:** put it in :data:`VARIANTS` at the rung it deserves and nothing
|
|
22
|
+
else changes. Art is measured, not declared - a variant too big for the window
|
|
23
|
+
is simply never chosen, so a wrong guess costs a rung rather than breaking a
|
|
24
|
+
screen. That is why :data:`FULL` can sit at the top at 119 columns without
|
|
25
|
+
hurting anyone running a normal terminal.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from texastoast.ui import bigtext
|
|
31
|
+
|
|
32
|
+
#: The arcade page's complete title art, from ``website/arcade/index.html``.
|
|
33
|
+
#:
|
|
34
|
+
#: 119x19 - it needs about a 121x34 terminal, which is a maximised window on a
|
|
35
|
+
#: large monitor and nothing smaller. It is here because it costs nothing to
|
|
36
|
+
#: offer and looks like the website when there is room for it.
|
|
37
|
+
FULL = r""" /$$
|
|
38
|
+
| $$
|
|
39
|
+
/$$$$$$/$$$$ /$$$$$$ /$$$$$$ /$$$$$$/$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$ /$$ /$$$$$$$ /$$$$$$$| $$$$$$$
|
|
40
|
+
| $$_ $$_ $$ |____ $$ /$$__ $$| $$_ $$_ $$ |____ $$ /$$_____/ /$$__ $$| $$ | $$| $$__ $$ /$$_____/| $$__ $$
|
|
41
|
+
| $$ \ $$ \ $$ /$$$$$$$| $$ \ $$| $$ \ $$ \ $$ /$$$$$$$| $$ | $$ \__/| $$ | $$| $$ \ $$| $$ | $$ \ $$
|
|
42
|
+
| $$ | $$ | $$ /$$__ $$| $$ | $$| $$ | $$ | $$ /$$__ $$| $$ | $$ | $$ | $$| $$ | $$| $$ | $$ | $$
|
|
43
|
+
| $$ | $$ | $$| $$$$$$$| $$$$$$$| $$ | $$ | $$| $$$$$$$| $$$$$$$| $$ | $$$$$$/| $$ | $$| $$$$$$$| $$ | $$
|
|
44
|
+
|__/ |__/ |__/ \_______/ \____ $$|__/ |__/ |__/ \_______/ \_______/|__/ \______/ |__/ |__/ \_______/|__/ |__/
|
|
45
|
+
/$$ \ $$
|
|
46
|
+
| $$$$$$/
|
|
47
|
+
\______/
|
|
48
|
+
/$$
|
|
49
|
+
| $$
|
|
50
|
+
/$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
|
|
51
|
+
|____ $$ /$$__ $$ /$$_____/ |____ $$ /$$__ $$ /$$__ $$
|
|
52
|
+
/$$$$$$$| $$ \__/| $$ /$$$$$$$| $$ | $$| $$$$$$$$
|
|
53
|
+
/$$__ $$| $$ | $$ /$$__ $$| $$ | $$| $$_____/
|
|
54
|
+
| $$$$$$$| $$ | $$$$$$$| $$$$$$$| $$$$$$$| $$$$$$$
|
|
55
|
+
\_______/|__/ \_______/ \_______/ \_______/ \_______/ """
|
|
56
|
+
|
|
57
|
+
#: MAGMACRUNCH at 71x5 - **the rung a standard 80x24 terminal gets**, and the
|
|
58
|
+
#: only wordmark that fits one. :data:`FULL` says the same word in 119 columns
|
|
59
|
+
#: and eleven rows, which no ordinary window has; this says it in five.
|
|
60
|
+
CHUNKY = """\
|
|
61
|
+
__
|
|
62
|
+
.--------.---.-.-----.--------.---.-.----.----.--.--.-----.----.| |--.
|
|
63
|
+
| | _ | _ | | _ | __| _| | | | __|| |
|
|
64
|
+
|__|__|__|___._|___ |__|__|__|___._|____|__| |_____|__|__|____||__|__|
|
|
65
|
+
|_____|"""
|
|
66
|
+
|
|
67
|
+
#: The ARCADE half of :data:`FULL` on its own, 60x8. Narrower than
|
|
68
|
+
#: :data:`CHUNKY`, so it is what a window between about 62 and 72 columns gets
|
|
69
|
+
#: - and it is the half of the big art that was always going to survive the
|
|
70
|
+
#: trip, since the MAGMACRUNCH word above it cannot.
|
|
71
|
+
ARCADE = r""" /$$
|
|
72
|
+
| $$
|
|
73
|
+
/$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
|
|
74
|
+
|____ $$ /$$__ $$ /$$_____/ |____ $$ /$$__ $$ /$$__ $$
|
|
75
|
+
/$$$$$$$| $$ \__/| $$ /$$$$$$$| $$ | $$| $$$$$$$$
|
|
76
|
+
/$$__ $$| $$ | $$ /$$__ $$| $$ | $$| $$_____/
|
|
77
|
+
| $$$$$$$| $$ | $$$$$$$| $$$$$$$| $$$$$$$| $$$$$$$
|
|
78
|
+
\_______/|__/ \_______/ \_______/ \_______/ \_______/"""
|
|
79
|
+
|
|
80
|
+
#: The thin strapline alone, 69x4. Wider than :data:`ARCADE` but a quarter of
|
|
81
|
+
#: the height, which is what makes it the right answer for a short window.
|
|
82
|
+
WELCOME = r""" | | | |
|
|
83
|
+
. . .,---.| ,---.,---.,-.-.,---. |--- ,---. |--- |---.,---.
|
|
84
|
+
| | ||---'| | | || | ||---' | | | | | ||---'
|
|
85
|
+
`-'-'`---'`---'`---'`---'` ' '`---' `---'`---' `---'` '`---'"""
|
|
86
|
+
|
|
87
|
+
#: The name in the engine's block face, 54x3 - the same lettering the two
|
|
88
|
+
#: cabinets set their own titles in, so the family reads as one thing rather
|
|
89
|
+
#: than as three hand-drawn alphabets.
|
|
90
|
+
BLOCK = bigtext.block("MAGMACRUNCH")
|
|
91
|
+
|
|
92
|
+
#: A last piece of art before the plain line: two rows rather than three, and
|
|
93
|
+
#: six columns narrower than :data:`BLOCK`, which is the whole of why it is
|
|
94
|
+
#: still here. Hand-drawn, because a two-row face cannot tell B from D and is
|
|
95
|
+
#: therefore no use as a general alphabet - it only works because this one
|
|
96
|
+
#: word happens not to contain the letters that collide.
|
|
97
|
+
WORDMARK = """█▀▄▀█ ▄▀█ █▀▀ █▀▄▀█ ▄▀█ █▀▀ █▀█ █ █ █▄ █ █▀▀ █ █
|
|
98
|
+
█ ▀ █ █▀█ █▄█ █ ▀ █ █▀█ █▄▄ █▀▄ █▄█ █ ▀█ █▄▄ █▀█"""
|
|
99
|
+
|
|
100
|
+
#: The bottom rung, and the reason the floor is as low as it is. Spaced
|
|
101
|
+
#: capitals are not art, but they fit a 36-column window and still read as a
|
|
102
|
+
#: title rather than a sentence.
|
|
103
|
+
PLAIN = "M A G M A C R U N C H A R C A D E"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def lines(art: str) -> list[str]:
|
|
107
|
+
return art.split("\n")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def size(art: str) -> tuple[int, int]:
|
|
111
|
+
"""``(cols, rows)`` the art occupies."""
|
|
112
|
+
rows = lines(art)
|
|
113
|
+
return max((len(line) for line in rows), default=0), len(rows)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def over(top: str, bottom: str) -> str:
|
|
117
|
+
"""One block above another, each centred on the wider of the two.
|
|
118
|
+
|
|
119
|
+
Composed rather than written out, so that a stacked variant cannot drift
|
|
120
|
+
from the pieces it is made of. Each block is indented as a whole; centring
|
|
121
|
+
the *lines* would ragged a block apart, since art lines are not all the
|
|
122
|
+
same length before padding.
|
|
123
|
+
"""
|
|
124
|
+
width = max(size(top)[0], size(bottom)[0])
|
|
125
|
+
|
|
126
|
+
def block(art: str) -> list[str]:
|
|
127
|
+
indent = " " * ((width - size(art)[0]) // 2)
|
|
128
|
+
return [(indent + line).ljust(width) for line in lines(art)]
|
|
129
|
+
|
|
130
|
+
return "\n".join(block(top) + [" " * width] + block(bottom))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
#: MAGMACRUNCH over ARCADE at 71x14: everything :data:`FULL` says, in the
|
|
134
|
+
#: width an ordinary terminal has. It needs a tall window rather than a wide
|
|
135
|
+
#: one, which is the easier of the two to come by.
|
|
136
|
+
STACK = over(CHUNKY, ARCADE)
|
|
137
|
+
|
|
138
|
+
#: The strapline over the ARCADE wordmark, 69x13.
|
|
139
|
+
HERO = over(WELCOME, ARCADE)
|
|
140
|
+
|
|
141
|
+
#: Best first. :func:`best_fit` walks this in order and takes the first that
|
|
142
|
+
#: fits, so the ordering is the editorial judgement and the measuring is not.
|
|
143
|
+
#:
|
|
144
|
+
#: The order is not by width. :data:`ARCADE` is narrower than :data:`CHUNKY`
|
|
145
|
+
#: and twice as tall, and which of them a window can take depends on both.
|
|
146
|
+
VARIANTS: tuple[str, ...] = (
|
|
147
|
+
FULL, STACK, HERO, CHUNKY, ARCADE, WELCOME, BLOCK, WORDMARK, PLAIN,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def best_fit(cols: int, rows: int) -> list[str]:
|
|
152
|
+
"""The best variant fitting ``cols`` x ``rows``, as lines to draw.
|
|
153
|
+
|
|
154
|
+
Lines come back padded to the art's own width. Art is a block, and a
|
|
155
|
+
caller centring each line by its own length would ragged the block apart -
|
|
156
|
+
the top line of :data:`WELCOME` is mostly leading space, so it would sit a
|
|
157
|
+
dozen cells right of the line beneath it.
|
|
158
|
+
|
|
159
|
+
Returns an empty list when even :data:`PLAIN` will not fit, which the
|
|
160
|
+
caller should treat as "no banner this frame" rather than an error: a
|
|
161
|
+
window that short has better uses for the row, and the cabinets are the
|
|
162
|
+
part worth keeping.
|
|
163
|
+
"""
|
|
164
|
+
for art in VARIANTS:
|
|
165
|
+
w, h = size(art)
|
|
166
|
+
if w <= cols and h <= rows:
|
|
167
|
+
return [line.ljust(w) for line in lines(art)]
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["ARCADE", "BLOCK", "CHUNKY", "FULL", "HERO", "PLAIN", "STACK", "VARIANTS",
|
|
172
|
+
"WELCOME", "WORDMARK", "best_fit", "lines", "over", "size"]
|
magmacrunch/cards.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""The cabinet cards, and where they go.
|
|
2
|
+
|
|
3
|
+
``.game-grid`` on magmacrunch.com is a two-column grid of bordered cards, each
|
|
4
|
+
carrying a title, a description, a ``▶ ENTER`` line in that card's own accent
|
|
5
|
+
colour, and a border that lights up in the same colour under the cursor. This
|
|
6
|
+
is that, in cells.
|
|
7
|
+
|
|
8
|
+
Layout is separated from drawing because layout is the part with arithmetic in
|
|
9
|
+
it: how many columns a window takes, how many rows of cards fit under the
|
|
10
|
+
banner, and which page of cards the selected one is on. All of it is pure and
|
|
11
|
+
none of it needs a terminal, so it is tested directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import textwrap
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from magmacrunch import theme
|
|
20
|
+
|
|
21
|
+
# Box drawing for the card border. Light rather than heavy: the border is
|
|
22
|
+
# chrome, and the accent colour is what makes the selected card loud.
|
|
23
|
+
TL, TR, BL, BR, H, V = "┌", "┐", "└", "┘", "─", "│"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Slot:
|
|
28
|
+
"""Where one card goes, and which cabinet is in it."""
|
|
29
|
+
|
|
30
|
+
index: int
|
|
31
|
+
x: int
|
|
32
|
+
y: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def columns(width: int) -> int:
|
|
36
|
+
"""How many cards fit across ``width``.
|
|
37
|
+
|
|
38
|
+
Two at most - ``.game-grid`` is ``repeat(2, 1fr)`` until 1100px and a
|
|
39
|
+
terminal is never the wide case.
|
|
40
|
+
"""
|
|
41
|
+
usable = width - 2 * theme.MARGIN_X
|
|
42
|
+
if usable >= theme.MAX_COLS * theme.CARD_W + theme.GAP_X:
|
|
43
|
+
return theme.MAX_COLS
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rows(height: int) -> int:
|
|
48
|
+
"""How many rows of cards fit in ``height`` cells."""
|
|
49
|
+
if height < theme.CARD_H:
|
|
50
|
+
return 0
|
|
51
|
+
return 1 + (height - theme.CARD_H) // (theme.CARD_H + theme.GAP_Y)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def page_start(selected: int, capacity: int) -> int:
|
|
55
|
+
"""Index of the first card on the page holding ``selected``.
|
|
56
|
+
|
|
57
|
+
Paged rather than scrolled by one: a grid that slides a row at a time
|
|
58
|
+
makes the cards move under the cursor on almost every keypress, where a
|
|
59
|
+
page turns rarely and lands somewhere predictable.
|
|
60
|
+
"""
|
|
61
|
+
if capacity <= 0:
|
|
62
|
+
return 0
|
|
63
|
+
return (selected // capacity) * capacity
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def layout(count: int, selected: int, width: int, height: int) -> list[Slot]:
|
|
67
|
+
"""Slots for the cards visible in a ``width`` x ``height`` region.
|
|
68
|
+
|
|
69
|
+
The region is what is left after the banner and the chrome have taken
|
|
70
|
+
theirs. Cards that do not fit on the selected card's page are simply not
|
|
71
|
+
in the result - see :func:`page_start`.
|
|
72
|
+
"""
|
|
73
|
+
cols = columns(width)
|
|
74
|
+
row_count = rows(height)
|
|
75
|
+
capacity = cols * row_count
|
|
76
|
+
if capacity <= 0 or count <= 0:
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
first = page_start(selected, capacity)
|
|
80
|
+
shown = min(capacity, count - first)
|
|
81
|
+
|
|
82
|
+
used_rows = -(-shown // cols) # ceil
|
|
83
|
+
grid_w = cols * theme.CARD_W + (cols - 1) * theme.GAP_X
|
|
84
|
+
grid_h = used_rows * theme.CARD_H + (used_rows - 1) * theme.GAP_Y
|
|
85
|
+
origin_x = max(theme.MARGIN_X, (width - grid_w) // 2)
|
|
86
|
+
# Centred vertically as well as horizontally: a part-full last page left
|
|
87
|
+
# hard against the banner leaves a block of dead rows under it, which
|
|
88
|
+
# reads as a screen that failed to finish drawing.
|
|
89
|
+
origin_y = max(0, (height - grid_h) // 2)
|
|
90
|
+
|
|
91
|
+
slots = []
|
|
92
|
+
for offset in range(shown):
|
|
93
|
+
row, col = divmod(offset, cols)
|
|
94
|
+
slots.append(Slot(
|
|
95
|
+
index=first + offset,
|
|
96
|
+
x=origin_x + col * (theme.CARD_W + theme.GAP_X),
|
|
97
|
+
y=origin_y + row * (theme.CARD_H + theme.GAP_Y),
|
|
98
|
+
))
|
|
99
|
+
return slots
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pages(count: int, width: int, height: int) -> int:
|
|
103
|
+
"""How many pages ``count`` cabinets take. One when they all fit."""
|
|
104
|
+
capacity = columns(width) * rows(height)
|
|
105
|
+
if capacity <= 0:
|
|
106
|
+
return 0
|
|
107
|
+
return (count + capacity - 1) // capacity
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wrap(text: str, width: int, limit: int) -> list[str]:
|
|
111
|
+
"""``text`` over at most ``limit`` lines, the last one elided if it runs on."""
|
|
112
|
+
if width <= 0 or limit <= 0:
|
|
113
|
+
return []
|
|
114
|
+
out = textwrap.wrap(text, width) or [""]
|
|
115
|
+
if len(out) <= limit:
|
|
116
|
+
return out
|
|
117
|
+
kept = out[:limit]
|
|
118
|
+
kept[-1] = kept[-1][:max(0, width - 1)].rstrip() + "…"
|
|
119
|
+
return kept
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def draw(renderer, slot: Slot, info, *, accent: str, selected: bool,
|
|
123
|
+
enabled: bool, cursor: bool) -> None:
|
|
124
|
+
"""One card.
|
|
125
|
+
|
|
126
|
+
``cursor`` is the blinking underscore after ENTER, which the web page
|
|
127
|
+
shows only under the pointer (``.card-arrow`` is ``opacity: 0`` until
|
|
128
|
+
``:hover``). Here that is the selected card, blinking on the frame clock.
|
|
129
|
+
"""
|
|
130
|
+
x, y, w, h = slot.x, slot.y, theme.CARD_W, theme.CARD_H
|
|
131
|
+
border = accent if selected else theme.CARD_BORDER
|
|
132
|
+
if not enabled:
|
|
133
|
+
border = theme.CARD_BORDER
|
|
134
|
+
|
|
135
|
+
renderer.draw_rect(x, y, w, h, theme.CARD_BG)
|
|
136
|
+
renderer.ui_text(x, y, TL + H * (w - 2) + TR, fill=border)
|
|
137
|
+
renderer.ui_text(x, y + h - 1, BL + H * (w - 2) + BR, fill=border)
|
|
138
|
+
for row in range(1, h - 1):
|
|
139
|
+
renderer.ui_text(x, y + row, V, fill=border)
|
|
140
|
+
renderer.ui_text(x + w - 1, y + row, V, fill=border)
|
|
141
|
+
|
|
142
|
+
left = x + 1 + theme.CARD_PAD
|
|
143
|
+
inner = theme.CARD_INNER
|
|
144
|
+
title_fill = theme.CARD_TITLE if enabled else theme.MUTED
|
|
145
|
+
|
|
146
|
+
line = y + 1
|
|
147
|
+
for text in _wrap(info.title, inner, 2):
|
|
148
|
+
renderer.ui_text(left, line, text, fill=title_fill)
|
|
149
|
+
line += 1
|
|
150
|
+
|
|
151
|
+
line += 1
|
|
152
|
+
if enabled:
|
|
153
|
+
for text in _wrap(info.blurb, inner, 3):
|
|
154
|
+
renderer.ui_text(left, line, text, fill=theme.MUTED)
|
|
155
|
+
line += 1
|
|
156
|
+
else:
|
|
157
|
+
# A greyed card with no explanation is a bug report waiting to happen.
|
|
158
|
+
for text in _wrap(f"needs {info.min_cols}x{info.min_rows}", inner, 2):
|
|
159
|
+
renderer.ui_text(left, line, text, fill=theme.MUTED)
|
|
160
|
+
line += 1
|
|
161
|
+
|
|
162
|
+
play_y = y + h - 2
|
|
163
|
+
if enabled:
|
|
164
|
+
# The cursor belongs to the selected card alone - ``.card-arrow`` is
|
|
165
|
+
# ``opacity: 0`` until ``:hover``, and a grid where every card blinks
|
|
166
|
+
# says nothing about which one Enter would start.
|
|
167
|
+
play = theme.PLAY + ("_" if cursor and selected else "")
|
|
168
|
+
renderer.ui_text(left, play_y, play,
|
|
169
|
+
fill=accent if selected else theme.MUTED)
|
|
170
|
+
else:
|
|
171
|
+
renderer.ui_text(left, play_y, "TOO BIG", fill=theme.CARD_BORDER)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = ["Slot", "columns", "draw", "layout", "page_start", "pages", "rows"]
|
magmacrunch/scenes.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""The arcade floor, as a scene.
|
|
2
|
+
|
|
3
|
+
One screen: the title art, a grid of every cabinet that was found, and a way
|
|
4
|
+
into one. It draws through the engine's ``Renderer``/``UISurface`` protocols
|
|
5
|
+
and does not know what Textual is.
|
|
6
|
+
|
|
7
|
+
The layout follows magmacrunch.com's arcade page - banner, tagline, a blinking
|
|
8
|
+
INSERT COIN, then the card grid - and gives up its parts in that order as the
|
|
9
|
+
window shrinks, so that the cabinets are the last thing to go. See
|
|
10
|
+
:mod:`magmacrunch.banner` and :mod:`magmacrunch.cards`.
|
|
11
|
+
|
|
12
|
+
Modality is the stack, not a flag - the engine's own rule. This sits at the
|
|
13
|
+
bottom; seating a cabinet pushes its scenes on top, and the cabinet leaving
|
|
14
|
+
pops back down to here. Nothing needs an ``in_game`` boolean, and the floor
|
|
15
|
+
cannot be updated while a game is over it because the stack will not call it.
|
|
16
|
+
|
|
17
|
+
The engine's ``Menu`` widget is deliberately not used. It draws a centred list
|
|
18
|
+
of single-line rows, which is the wrong shape for a grid of bordered cards -
|
|
19
|
+
so selection state lives here instead, in :attr:`CabinetScene.selected`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from magmacrunch import banner, cards, theme
|
|
25
|
+
|
|
26
|
+
MENU_HELP = "↑↓←→ CHOOSE ENTER PLAY Q QUIT"
|
|
27
|
+
|
|
28
|
+
EMPTY_FLOOR = (
|
|
29
|
+
"NO CABINETS INSTALLED",
|
|
30
|
+
"",
|
|
31
|
+
"pip install magmacrunch-george-boole",
|
|
32
|
+
"pip install magmacrunch-thld",
|
|
33
|
+
"",
|
|
34
|
+
"Any package declaring a magmacrunch.games entry point",
|
|
35
|
+
"appears here - no release of the arcade needed.",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fit(text: str, width: int) -> str:
|
|
40
|
+
"""Trim to ``width``, marking the cut so a clipped line reads as clipped."""
|
|
41
|
+
if width <= 1 or len(text) <= width:
|
|
42
|
+
return text
|
|
43
|
+
return text[:width - 1] + "…"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _too_small(renderer, cols: int, rows: int) -> bool:
|
|
47
|
+
"""Say so rather than drawing a clipped screen.
|
|
48
|
+
|
|
49
|
+
Two short lines rather than one long one, because the message is being
|
|
50
|
+
shown precisely when the terminal is narrow: a single line stating both
|
|
51
|
+
sizes runs past 40 columns and gets cut mid-number, which reads as a bug in
|
|
52
|
+
the thing reporting the problem.
|
|
53
|
+
"""
|
|
54
|
+
if renderer.width >= cols and renderer.height >= rows:
|
|
55
|
+
return False
|
|
56
|
+
renderer.ui_text(1, 1, "TERMINAL TOO SMALL", fill=theme.ERROR)
|
|
57
|
+
renderer.ui_text(
|
|
58
|
+
1, 2,
|
|
59
|
+
f"need {cols}x{rows}, have {renderer.width}x{renderer.height}",
|
|
60
|
+
fill=theme.MUTED,
|
|
61
|
+
)
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CabinetScene:
|
|
66
|
+
"""The grid of installed games.
|
|
67
|
+
|
|
68
|
+
``render_below`` is deliberately *not* set: a cabinet seated on top covers
|
|
69
|
+
this completely, so drawing underneath it would be wasted work.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, app):
|
|
73
|
+
self.app = app
|
|
74
|
+
#: Which cabinet the cursor is on. Kept here rather than in a widget
|
|
75
|
+
#: because the grid is drawn here - see the module docstring.
|
|
76
|
+
self.selected = 0
|
|
77
|
+
#: Seconds since the scene started, for the blinking cursor and coin.
|
|
78
|
+
#: A frame clock rather than a wall clock so the blink is the engine's
|
|
79
|
+
#: business and stays put when the loop is stopped in a test.
|
|
80
|
+
self.elapsed = 0.0
|
|
81
|
+
|
|
82
|
+
# -- The grid ----------------------------------------------------
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def highlighted(self):
|
|
86
|
+
if not self.app.games:
|
|
87
|
+
return None
|
|
88
|
+
return self.app.games[self.selected]
|
|
89
|
+
|
|
90
|
+
def _enabled(self, game) -> bool:
|
|
91
|
+
"""Whether this window is big enough to seat ``game``.
|
|
92
|
+
|
|
93
|
+
``GameInfo.fits`` exists for exactly this. Asked at the moment it is
|
|
94
|
+
needed rather than cached, so a resize is reflected in the same frame
|
|
95
|
+
that draws it.
|
|
96
|
+
"""
|
|
97
|
+
r = self.app.renderer
|
|
98
|
+
return game.info.fits(r.width, r.height)
|
|
99
|
+
|
|
100
|
+
def _grid_region(self) -> tuple[int, int]:
|
|
101
|
+
"""``(width, height)`` left for cards after the chrome takes its share."""
|
|
102
|
+
r = self.app.renderer
|
|
103
|
+
used = self._banner_rows() + theme.HEADER_ROWS + theme.FOOTER_ROWS + 2
|
|
104
|
+
return r.width, max(0, r.height - used)
|
|
105
|
+
|
|
106
|
+
def _banner_rows(self) -> int:
|
|
107
|
+
r = self.app.renderer
|
|
108
|
+
return len(banner.best_fit(r.width - 2, self._banner_budget()))
|
|
109
|
+
|
|
110
|
+
def _banner_budget(self) -> int:
|
|
111
|
+
"""Rows the banner may have.
|
|
112
|
+
|
|
113
|
+
It is given whatever is left once one row of cards and the chrome are
|
|
114
|
+
accounted for, so the art stands down before the cabinets do. That is
|
|
115
|
+
what keeps the floor at one card rather than at the widest banner.
|
|
116
|
+
"""
|
|
117
|
+
r = self.app.renderer
|
|
118
|
+
spare = r.height - (theme.HEADER_ROWS + theme.CARD_H + theme.FOOTER_ROWS + 2)
|
|
119
|
+
return max(0, spare)
|
|
120
|
+
|
|
121
|
+
def _move(self, delta: int) -> None:
|
|
122
|
+
if not self.app.games:
|
|
123
|
+
return
|
|
124
|
+
self.selected = (self.selected + delta) % len(self.app.games)
|
|
125
|
+
|
|
126
|
+
def _move_row(self, delta: int) -> None:
|
|
127
|
+
"""Up/down moves by a whole row of the grid, not by one card."""
|
|
128
|
+
if not self.app.games:
|
|
129
|
+
return
|
|
130
|
+
width, height = self._grid_region()
|
|
131
|
+
self._move(delta * cards.columns(width))
|
|
132
|
+
|
|
133
|
+
def choose(self) -> None:
|
|
134
|
+
game = self.highlighted
|
|
135
|
+
if game is None or not self._enabled(game):
|
|
136
|
+
return
|
|
137
|
+
if not self.app.play(game):
|
|
138
|
+
# Nothing was pushed, so nothing will pop and no on_resume will
|
|
139
|
+
# fire. The error is drawn on the floor instead.
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# -- Stack hooks -------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def on_resume(self) -> None:
|
|
145
|
+
"""Take the terminal back from a cabinet that has just left.
|
|
146
|
+
|
|
147
|
+
``TuiHost.seat()`` applies the seated game's declared ``fps`` and
|
|
148
|
+
``hold_ms``, and nothing puts them back - so a real-time cabinet
|
|
149
|
+
handing control back would leave the floor running at its frame rate
|
|
150
|
+
with its held-key decay, and arrows would skate across the grid on
|
|
151
|
+
auto-repeat instead of stepping.
|
|
152
|
+
|
|
153
|
+
Retuning here rather than in the host is deliberate: the host cannot
|
|
154
|
+
know what to revert *to*. Only whatever is underneath does, and that
|
|
155
|
+
is this.
|
|
156
|
+
"""
|
|
157
|
+
self.app.host.apply(self.app.info)
|
|
158
|
+
|
|
159
|
+
# -- Frame -------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def update(self, dt: float) -> None:
|
|
162
|
+
self.elapsed += dt
|
|
163
|
+
|
|
164
|
+
def handle_key(self, key: str) -> bool:
|
|
165
|
+
if key in ("left", "a", "h"):
|
|
166
|
+
self._move(-1)
|
|
167
|
+
elif key in ("right", "d", "l"):
|
|
168
|
+
self._move(1)
|
|
169
|
+
elif key in ("up", "w", "k"):
|
|
170
|
+
self._move_row(-1)
|
|
171
|
+
elif key in ("down", "s", "j"):
|
|
172
|
+
self._move_row(1)
|
|
173
|
+
elif key in ("enter", "space"):
|
|
174
|
+
self.choose()
|
|
175
|
+
elif key == "q":
|
|
176
|
+
self.app.host.quit()
|
|
177
|
+
elif key == "escape":
|
|
178
|
+
# The same call a cabinet makes to leave. This is the bottom of the
|
|
179
|
+
# stack, so it ends the session - see TuiHost.pop_scene.
|
|
180
|
+
self.app.host.pop_scene()
|
|
181
|
+
else:
|
|
182
|
+
return False
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def render(self) -> None:
|
|
186
|
+
r = self.app.renderer
|
|
187
|
+
r.clear()
|
|
188
|
+
r.draw_rect(0, 0, r.width, r.height, theme.BG)
|
|
189
|
+
|
|
190
|
+
if _too_small(r, theme.MIN_COLS, theme.MIN_ROWS):
|
|
191
|
+
r.present()
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
cx = r.width // 2
|
|
195
|
+
y = self._render_header(r, cx)
|
|
196
|
+
|
|
197
|
+
if self.app.games:
|
|
198
|
+
self._render_grid(r, y)
|
|
199
|
+
else:
|
|
200
|
+
self._render_empty_floor(r, cx, y)
|
|
201
|
+
|
|
202
|
+
self._render_footer(r, cx)
|
|
203
|
+
r.present()
|
|
204
|
+
|
|
205
|
+
def _render_header(self, r, cx: int) -> int:
|
|
206
|
+
"""Banner, tagline and coin. Returns the first row below them."""
|
|
207
|
+
y = 0
|
|
208
|
+
art = banner.best_fit(r.width - 2, self._banner_budget())
|
|
209
|
+
for line in art:
|
|
210
|
+
r.ui_text(cx, y, line, fill=theme.CYAN, anchor="n")
|
|
211
|
+
y += 1
|
|
212
|
+
|
|
213
|
+
r.ui_text(cx, y, theme.TAGLINE, fill=theme.PINK, anchor="n")
|
|
214
|
+
y += 1
|
|
215
|
+
if self._blink(theme.COIN_BLINK_SECONDS):
|
|
216
|
+
r.ui_text(cx, y, theme.INSERT_COIN, fill=theme.YELLOW, anchor="n")
|
|
217
|
+
y += 2
|
|
218
|
+
return y
|
|
219
|
+
|
|
220
|
+
def _render_grid(self, r, top: int) -> None:
|
|
221
|
+
width, height = self._grid_region()
|
|
222
|
+
slots = cards.layout(len(self.app.games), self.selected, width, height)
|
|
223
|
+
cursor = self._blink(theme.BLINK_SECONDS)
|
|
224
|
+
|
|
225
|
+
for slot in slots:
|
|
226
|
+
game = self.app.games[slot.index]
|
|
227
|
+
cards.draw(
|
|
228
|
+
r, cards.Slot(slot.index, slot.x, slot.y + top), game.info,
|
|
229
|
+
accent=theme.accent(slot.index),
|
|
230
|
+
selected=slot.index == self.selected,
|
|
231
|
+
enabled=self._enabled(game),
|
|
232
|
+
cursor=cursor,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
total = cards.pages(len(self.app.games), width, height)
|
|
236
|
+
if total > 1 and slots:
|
|
237
|
+
capacity = cards.columns(width) * cards.rows(height)
|
|
238
|
+
here = self.selected // capacity + 1
|
|
239
|
+
# Directly under the grid rather than at a fixed row near the
|
|
240
|
+
# bottom, where it would land on the error line.
|
|
241
|
+
below = top + max(s.y for s in slots) + theme.CARD_H
|
|
242
|
+
r.ui_text(r.width // 2, below, f"PAGE {here}/{total}",
|
|
243
|
+
fill=theme.MUTED, anchor="n")
|
|
244
|
+
|
|
245
|
+
def _render_footer(self, r, cx: int) -> None:
|
|
246
|
+
if self.app.error:
|
|
247
|
+
r.ui_text(cx, r.height - 4, _fit(self.app.error, r.width - 2),
|
|
248
|
+
fill=theme.ERROR, anchor="n")
|
|
249
|
+
r.ui_text(cx, r.height - 3, _fit(MENU_HELP, r.width - 2),
|
|
250
|
+
fill=theme.MUTED, anchor="n")
|
|
251
|
+
self._render_credits(r, cx)
|
|
252
|
+
|
|
253
|
+
def _render_credits(self, r, cx: int) -> None:
|
|
254
|
+
"""The signature, with the domain in the colour the web page links in.
|
|
255
|
+
|
|
256
|
+
Drawn as two runs at computed positions rather than one centred
|
|
257
|
+
string, because ``.credits-row a`` is cyan against muted text and a
|
|
258
|
+
single ``ui_text`` gets one colour. Falls back to the copyright alone
|
|
259
|
+
when the window is too narrow to carry both.
|
|
260
|
+
"""
|
|
261
|
+
y = r.height - 2
|
|
262
|
+
full = theme.COPYRIGHT + theme.CREDITS_SEP + theme.DOMAIN
|
|
263
|
+
if len(full) + 2 > r.width:
|
|
264
|
+
r.ui_text(cx, y, _fit(theme.COPYRIGHT, r.width - 2),
|
|
265
|
+
fill=theme.CARD_BORDER, anchor="n")
|
|
266
|
+
return
|
|
267
|
+
x = cx - len(full) // 2
|
|
268
|
+
r.ui_text(x, y, theme.COPYRIGHT + theme.CREDITS_SEP,
|
|
269
|
+
fill=theme.CARD_BORDER)
|
|
270
|
+
r.ui_text(x + len(theme.COPYRIGHT) + len(theme.CREDITS_SEP), y,
|
|
271
|
+
theme.DOMAIN, fill=theme.CYAN)
|
|
272
|
+
|
|
273
|
+
def _render_empty_floor(self, r, cx: int, top: int) -> None:
|
|
274
|
+
for i, line in enumerate(EMPTY_FLOOR):
|
|
275
|
+
fill = theme.YELLOW if i == 0 else theme.MUTED
|
|
276
|
+
r.ui_text(cx, top + i + 1, _fit(line, r.width - 2),
|
|
277
|
+
fill=fill, anchor="n")
|
|
278
|
+
|
|
279
|
+
def _blink(self, period: float) -> bool:
|
|
280
|
+
"""On for the first half of each period, off for the second.
|
|
281
|
+
|
|
282
|
+
``animation: blink 0.6s step-end infinite`` in ``arcade.css``.
|
|
283
|
+
"""
|
|
284
|
+
return (self.elapsed % period) < period / 2
|
magmacrunch/theme.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Palette and layout for the arcade floor, in character cells.
|
|
2
|
+
|
|
3
|
+
The colours are magmacrunch.com's arcade page, lifted from ``arcade.css`` so
|
|
4
|
+
the terminal and the browser are recognisably the same place. What could not
|
|
5
|
+
come with them is the typography: ``arcade.css`` sets ``Press Start 2P`` on
|
|
6
|
+
almost everything, and **a terminal program does not choose its font** - the
|
|
7
|
+
font is whatever the person running it has their emulator set to. The web page
|
|
8
|
+
already runs into this itself, which is why its own title art is Courier Prime
|
|
9
|
+
rather than Press Start 2P: pixel lettering in a terminal has to be drawn out
|
|
10
|
+
of block glyphs, not asked for. See :mod:`magmacrunch.banner`.
|
|
11
|
+
|
|
12
|
+
Split out so :mod:`magmacrunch.scenes` and :mod:`magmacrunch.app` can share it
|
|
13
|
+
without importing each other. Nothing here imports the engine.
|
|
14
|
+
|
|
15
|
+
Every measurement is **cells**, not pixels.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
TAGLINE = "EVERY CABINET ON THIS MACHINE"
|
|
21
|
+
INSERT_COIN = "INSERT COIN"
|
|
22
|
+
|
|
23
|
+
# ── Palette ─────────────────────────────────────────────────────────
|
|
24
|
+
# arcade.css, near enough verbatim. The names are the web page's roles.
|
|
25
|
+
|
|
26
|
+
#: ``body { background: #0a0612 }``
|
|
27
|
+
BG = "#0a0612"
|
|
28
|
+
#: ``.game-card { background: #150b29 }``
|
|
29
|
+
CARD_BG = "#150b29"
|
|
30
|
+
#: ``.game-card { border: 2px solid #3a2d5c }`` - the resting border.
|
|
31
|
+
CARD_BORDER = "#3a2d5c"
|
|
32
|
+
#: ``.card-title { color: #f0f8ff }``
|
|
33
|
+
CARD_TITLE = "#f0f8ff"
|
|
34
|
+
#: ``.card-desc`` and ``.breadcrumb`` - the muted body colour.
|
|
35
|
+
MUTED = "#8a7fa8"
|
|
36
|
+
|
|
37
|
+
CYAN = "#00f0ff" # .arcade-title-ascii, links
|
|
38
|
+
PINK = "#ff2e9c" # .arcade-subtitle, --nav-accent, the default card colour
|
|
39
|
+
YELLOW = "#fff733" # .insert-coin, the scoreboard header
|
|
40
|
+
GREEN = "#39ff6e" # card-games card
|
|
41
|
+
AMBER = "#ffe03a" # puzzles card
|
|
42
|
+
|
|
43
|
+
ERROR = PINK
|
|
44
|
+
|
|
45
|
+
#: One accent per cabinet, cycled by position, in the order the four category
|
|
46
|
+
#: cards use them on the web page. ``.game-card:hover`` puts this on the
|
|
47
|
+
#: border and ``.card-play`` puts it on the ENTER line, which is exactly what
|
|
48
|
+
#: a selected card does here.
|
|
49
|
+
ACCENTS = (CYAN, GREEN, AMBER, PINK)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def accent(index: int) -> str:
|
|
53
|
+
return ACCENTS[index % len(ACCENTS)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Cards ───────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
#: A card is 32x9 with a two-cell gutter, and the grid is at most two across
|
|
59
|
+
#: - ``.game-grid`` is ``repeat(2, 1fr)`` until 1100px, and a terminal is
|
|
60
|
+
#: never the wide case.
|
|
61
|
+
CARD_W = 32
|
|
62
|
+
CARD_H = 9
|
|
63
|
+
GAP_X = 2
|
|
64
|
+
GAP_Y = 1
|
|
65
|
+
MAX_COLS = 2
|
|
66
|
+
|
|
67
|
+
#: Border on both sides plus a cell of padding inside it.
|
|
68
|
+
CARD_PAD = 1
|
|
69
|
+
#: What a card has left for text.
|
|
70
|
+
CARD_INNER = CARD_W - 2 * (1 + CARD_PAD)
|
|
71
|
+
|
|
72
|
+
MARGIN_X = 2
|
|
73
|
+
#: Rows above the grid that are not the banner: the tagline and INSERT COIN.
|
|
74
|
+
HEADER_ROWS = 2
|
|
75
|
+
#: Rows below it: the error line, the key help and the credits.
|
|
76
|
+
FOOTER_ROWS = 3
|
|
77
|
+
|
|
78
|
+
#: The footer signature, in two parts so the domain can carry the link colour
|
|
79
|
+
#: the web page gives it (``.credits-row a { color: #00f0ff }``).
|
|
80
|
+
COPYRIGHT = "© 2026 magmacrunch media"
|
|
81
|
+
DOMAIN = "magmacrunch.com"
|
|
82
|
+
CREDITS_SEP = " · "
|
|
83
|
+
|
|
84
|
+
PLAY = "▶ ENTER"
|
|
85
|
+
#: ``.card-arrow { animation: blink 0.6s step-end infinite }`` - the cursor
|
|
86
|
+
#: after ENTER on the selected card, on for one half-period and off the next.
|
|
87
|
+
BLINK_SECONDS = 0.6
|
|
88
|
+
#: ``.insert-coin { animation: blink 1s step-end infinite }``
|
|
89
|
+
COIN_BLINK_SECONDS = 1.0
|
|
90
|
+
|
|
91
|
+
#: Smallest terminal the floor is drawn in: one card, one column, plus the
|
|
92
|
+
#: chrome that never goes away. Derived so the floor cannot drift from the
|
|
93
|
+
#: layout. The banner is not in it - it stands down to whatever fits, and at
|
|
94
|
+
#: the bottom rung it is one line. See :func:`magmacrunch.banner.best_fit`.
|
|
95
|
+
MIN_COLS = CARD_W + 2 * MARGIN_X
|
|
96
|
+
MIN_ROWS = 1 + HEADER_ROWS + CARD_H + FOOTER_ROWS + 2
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: magmacrunch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The magmacrunch terminal arcade - a menu of every installed cabinet
|
|
5
|
+
Project-URL: Homepage, https://magmacrunch.com/arcade/
|
|
6
|
+
Author: magmacrunch media
|
|
7
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: NOTICE
|
|
10
|
+
Keywords: arcade,games,launcher,terminal,tui
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: magmacrunch-george-boole>=0.1.0
|
|
13
|
+
Requires-Dist: magmacrunch-moonlight-drift>=0.1.0
|
|
14
|
+
Requires-Dist: magmacrunch-thld>=0.1.0
|
|
15
|
+
Requires-Dist: texastoast[tui]>=0.10.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# magmacrunch
|
|
22
|
+
|
|
23
|
+
The terminal arcade. One command, a menu of every cabinet installed on the
|
|
24
|
+
machine, and a way into one.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install magmacrunch
|
|
28
|
+
magmacrunch
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
__
|
|
33
|
+
.--------.---.-.-----.--------.---.-.----.----.--.--.-----.----.| |--.
|
|
34
|
+
| | _ | _ | | _ | __| _| | | | __|| |
|
|
35
|
+
|__|__|__|___._|___ |__|__|__|___._|____|__| |_____|__|__|____||__|__|
|
|
36
|
+
|_____|
|
|
37
|
+
EVERY CABINET ON THIS MACHINE
|
|
38
|
+
INSERT COIN
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
|
42
|
+
│ George Boole Has Entered The │ │ Texas Hold'Em Lava Dome │
|
|
43
|
+
│ Chat │ │ │
|
|
44
|
+
│ │ │ Solo hold'em against a │
|
|
45
|
+
│ 2048 played with logic gates │ │ threshold that climbs every │
|
|
46
|
+
│ — merge, invert, overflow. │ │ round. │
|
|
47
|
+
│ │ │ │
|
|
48
|
+
│ ▶ ENTER_ │ │ ▶ ENTER │
|
|
49
|
+
└──────────────────────────────┘ └──────────────────────────────┘
|
|
50
|
+
|
|
51
|
+
↑↓←→ CHOOSE ENTER PLAY Q QUIT
|
|
52
|
+
© 2026 magmacrunch media · magmacrunch.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It is `magmacrunch.com/arcade` in a shell — the same palette, the same card
|
|
56
|
+
grid, the same blinking cursor on the card you are about to start. The one
|
|
57
|
+
thing that could not come across is the typography: `arcade.css` sets
|
|
58
|
+
`Press Start 2P` on nearly every string, and a terminal program does not
|
|
59
|
+
choose its font. Pixel lettering has to be *drawn*, out of block glyphs, which
|
|
60
|
+
is what the narrow title variant does — and what the web page's own title art
|
|
61
|
+
does too, in Courier Prime rather than Press Start 2P, for the same reason.
|
|
62
|
+
|
|
63
|
+
The title art stands down as the window grows or shrinks, and always before
|
|
64
|
+
the cabinets have to — which is what keeps the floor as low as it is:
|
|
65
|
+
|
|
66
|
+
| window | banner |
|
|
67
|
+
|---|---|
|
|
68
|
+
| 121×34 and up | the full site wordmark, MAGMACRUNCH over ARCADE, 119×19 |
|
|
69
|
+
| ~73×30 | the same two words in 71 columns instead of 119 |
|
|
70
|
+
| **80×24** | **MAGMACRUNCH alone, 71×5** |
|
|
71
|
+
| ~62–72 cols | the ARCADE wordmark alone, 60×8 |
|
|
72
|
+
| ~50 cols | a block-glyph MAGMACRUNCH, 48×2 |
|
|
73
|
+
| 36 cols | spaced capitals |
|
|
74
|
+
|
|
75
|
+
Art is measured rather than declared, so the 119-column piece can sit at the
|
|
76
|
+
top of the list without ever bothering someone on an ordinary terminal — it
|
|
77
|
+
is simply never the one that fits. Adding your own is one entry in `VARIANTS`
|
|
78
|
+
in [`banner.py`](magmacrunch/banner.py) and nothing else.
|
|
79
|
+
|
|
80
|
+
Choosing a cabinet starts it on the terminal the arcade is already holding.
|
|
81
|
+
Leaving the game brings you back here. Every game is also its own command
|
|
82
|
+
(`george-boole`, `lava-dome`, `moonlight-drift`) and plays identically either way — the arcade is
|
|
83
|
+
not a wrapper around them, it is the same host they run on.
|
|
84
|
+
|
|
85
|
+
## What is installed
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
magmacrunch --list
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Prints every cabinet found, what terminal size it wants, and how it wants
|
|
92
|
+
input. If a game you installed is not in the menu, this says why — a game that
|
|
93
|
+
fails to load is reported rather than silently skipped.
|
|
94
|
+
|
|
95
|
+
## How games get here
|
|
96
|
+
|
|
97
|
+
Games are **not** bundled. Each one lives in its own repo alongside its browser
|
|
98
|
+
and Wii versions, publishes its own wheel, and declares an entry point:
|
|
99
|
+
|
|
100
|
+
```toml
|
|
101
|
+
[project.entry-points."magmacrunch.games"]
|
|
102
|
+
george-boole = "boole.arcade:GAME"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
pointing at an object with `info: GameInfo` and `start(host) -> scene`. The
|
|
106
|
+
launcher enumerates that group at startup. **Installing a game makes it appear
|
|
107
|
+
here; uninstalling makes it vanish; neither needs a release of the arcade.**
|
|
108
|
+
|
|
109
|
+
Nothing in this package imports a game, so the arcade runs with none installed
|
|
110
|
+
and tells you how to get one. The contract lives in
|
|
111
|
+
[`texastoast.arcade`](https://pypi.org/project/texastoast/); the two cabinets
|
|
112
|
+
above are worked examples of it.
|
|
113
|
+
|
|
114
|
+
## Cabinets
|
|
115
|
+
|
|
116
|
+
| | |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `magmacrunch-george-boole` | 2048 played with logic gates |
|
|
119
|
+
| `magmacrunch-thld` | Solo hold'em against a climbing threshold |
|
|
120
|
+
| `magmacrunch-moonlight-drift` | Thread the columns; hold to climb |
|
|
121
|
+
|
|
122
|
+
Installing `magmacrunch` installs all three.
|
|
123
|
+
|
|
124
|
+
## Requires
|
|
125
|
+
|
|
126
|
+
Python 3.10+, and a terminal at least 36×17. Individual cabinets ask for more
|
|
127
|
+
(around 59×22); below that their card greys out, says the size it wants, and
|
|
128
|
+
cannot be started. The arcade's own floor is deliberately lower than any
|
|
129
|
+
game's, so getting to the floor is never the thing that fails.
|
|
130
|
+
|
|
131
|
+
Two cards sit side by side from 70 columns and stack into one below that. More
|
|
132
|
+
cabinets than fit are paged, not scrolled.
|
|
133
|
+
|
|
134
|
+
Truecolor helps but is not required.
|
|
135
|
+
|
|
136
|
+
## Licence
|
|
137
|
+
|
|
138
|
+
PolyForm Noncommercial 1.0.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
|
139
|
+
Noncommercial because the games it seats are. The engine underneath,
|
|
140
|
+
[texastoast](https://pypi.org/project/texastoast/), stays Apache-2.0.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
magmacrunch/__init__.py,sha256=a4EEYSnLnwyaBHKgxnHvCImPwTLg1b7TzhWDxIlk6Co,705
|
|
2
|
+
magmacrunch/__main__.py,sha256=SAJjcnMmuQbiQA3rE2UY8wmqNJ_08wtILIkNhcsSplE,3290
|
|
3
|
+
magmacrunch/app.py,sha256=F8ky-2yZXGQ5-q5c8mMSX9WO66EkT-KLf_EtO2jH3dk,3944
|
|
4
|
+
magmacrunch/banner.py,sha256=UjtHesK9_R4z7-3r6jV_4UxQN81S1WWvTsirV19HfcA,9597
|
|
5
|
+
magmacrunch/cards.py,sha256=zDKL9UjDoFEV9MCgIMq1-tbD1jLG1mFjTsLfxv7RNKE,6185
|
|
6
|
+
magmacrunch/scenes.py,sha256=aJnuPZuesbT08c0YxwxdbGThtkgoe8Px3dCfQf7nSAo,10871
|
|
7
|
+
magmacrunch/theme.py,sha256=kAx6KOLLLeNdj6BnFCCcQ-DLuwxN03ZbrtDX2Tf1G8A,3904
|
|
8
|
+
magmacrunch-0.1.0.dist-info/METADATA,sha256=aynF4E12HXiPgjxJDEkUlxxj1X0Ls3qGUIVJ3C2a4kA,5915
|
|
9
|
+
magmacrunch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
magmacrunch-0.1.0.dist-info/entry_points.txt,sha256=0JqIHnkppyYXU3HVxCTiZWUdA19NYzy1UDPaxSQUrFo,58
|
|
11
|
+
magmacrunch-0.1.0.dist-info/licenses/LICENSE,sha256=dQ7s_huoOpXIV8Gtdt3ZVm4N2UofO4MvxlappsL14Q8,4640
|
|
12
|
+
magmacrunch-0.1.0.dist-info/licenses/NOTICE,sha256=4NAfifkrNa6CCMl7Q8ibvd3y4Xhvykwf-muPIg6LB8c,1330
|
|
13
|
+
magmacrunch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Required Notice: Copyright 2026 magmacrunch media (https://magmacrunch.com)
|
|
2
|
+
|
|
3
|
+
# PolyForm Noncommercial License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
magmacrunch — the terminal arcade
|
|
2
|
+
Copyright 2026 magmacrunch media
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
THE LICENCE
|
|
6
|
+
|
|
7
|
+
Everything in this repository -- the launcher, the tests, the build files and
|
|
8
|
+
the documentation -- is licensed under the PolyForm Noncommercial License
|
|
9
|
+
1.0.0, in the LICENSE file beside this one.
|
|
10
|
+
|
|
11
|
+
Read it, learn from it, build on it, play with it. Any noncommercial purpose is
|
|
12
|
+
permitted, and that includes personal projects, teaching and research. What is
|
|
13
|
+
not permitted is commercial use: selling it or a derivative of it, shipping it
|
|
14
|
+
inside a product, or running it as part of a business.
|
|
15
|
+
|
|
16
|
+
It is Noncommercial because the games it seats are. This package contains no
|
|
17
|
+
game code -- it finds installed games by entry point and never imports one --
|
|
18
|
+
but it depends on them, and a package cannot be more permissive than what it
|
|
19
|
+
depends on.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
WHAT THIS DEPENDS ON
|
|
23
|
+
|
|
24
|
+
texastoast, the engine, with its terminal backend. Apache License 2.0,
|
|
25
|
+
copyright 2026 magmacrunch media. https://pypi.org/project/texastoast/
|
|
26
|
+
|
|
27
|
+
texastoast's terminal backend depends in turn on Textual, and through it on
|
|
28
|
+
Rich (MIT, copyright Will McGugan). Both carry their own licences in the
|
|
29
|
+
installed distributions.
|
|
30
|
+
|
|
31
|
+
The cabinets -- magmacrunch-george-boole, magmacrunch-thld -- are each
|
|
32
|
+
PolyForm Noncommercial 1.0.0, copyright 2026 magmacrunch media, and carry
|
|
33
|
+
their own NOTICE files.
|