alchemy-game 0.1.0__tar.gz
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.
- alchemy_game-0.1.0/LICENSE +21 -0
- alchemy_game-0.1.0/PKG-INFO +105 -0
- alchemy_game-0.1.0/README.md +78 -0
- alchemy_game-0.1.0/pyproject.toml +48 -0
- alchemy_game-0.1.0/pyproject.toml.orig +41 -0
- alchemy_game-0.1.0/src/alchemy/__init__.py +3 -0
- alchemy_game-0.1.0/src/alchemy/__main__.py +3 -0
- alchemy_game-0.1.0/src/alchemy/app.py +157 -0
- alchemy_game-0.1.0/src/alchemy/assets/air.png +0 -0
- alchemy_game-0.1.0/src/alchemy/assets/earth.png +0 -0
- alchemy_game-0.1.0/src/alchemy/assets/fire.png +0 -0
- alchemy_game-0.1.0/src/alchemy/assets/sun.png +0 -0
- alchemy_game-0.1.0/src/alchemy/assets/water.png +0 -0
- alchemy_game-0.1.0/src/alchemy/audio.py +49 -0
- alchemy_game-0.1.0/src/alchemy/board.py +136 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Davidson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alchemy-game
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An alchemical match-four puzzle game built with Pygame
|
|
5
|
+
Keywords: game,puzzle,match-four,pygame,alchemy
|
|
6
|
+
Author: Andrew Davidson
|
|
7
|
+
Author-email: Andrew Davidson <drace.davidson@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: MacOS X
|
|
12
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
13
|
+
Classifier: Environment :: X11 Applications
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Games/Entertainment :: Puzzle Games
|
|
21
|
+
Requires-Dist: pygame>=2.6.1,<3
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/wulfalpha/alchemy
|
|
24
|
+
Project-URL: Repository, https://github.com/wulfalpha/alchemy
|
|
25
|
+
Project-URL: Issues, https://github.com/wulfalpha/alchemy/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Alchemy
|
|
29
|
+
|
|
30
|
+
A Pygame match-four puzzle with independently drawn alchemical symbols and a quiet laboratory aesthetic.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
With Python 3.12+:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install alchemy-game
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or as a standalone tool (no project venv needed):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pipx install alchemy-game
|
|
44
|
+
# or: uv tool install alchemy-game
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Then run:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
alchemy
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Play
|
|
54
|
+
|
|
55
|
+
From a clone of this repository, with [uv](https://docs.astral.sh/uv/):
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
uv run alchemy
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or install into your Python environment with `python -m pip install -e .`, then run `alchemy`.
|
|
62
|
+
|
|
63
|
+
- Click a symbol, then an adjacent symbol to swap them.
|
|
64
|
+
- Match **four or more** identical symbols horizontally or vertically. Diagonals and groups without a four-symbol line do not clear.
|
|
65
|
+
- Valid swaps use one of your 30 moves. Invalid swaps are reversed for free.
|
|
66
|
+
- Cleared tiles refill from above; further matches produce cascades.
|
|
67
|
+
- Each cleared symbol earns 25 points, with another 25 for each symbol beyond four. The total is multiplied by the cascade number. Intersections count each tile once. A true plus adds **200 points per crossing**, before the cascade multiplier: both horizontal and vertical runs must be 4+ symbols and extend on both sides of the crossing. T and L shapes do not earn this bonus.
|
|
68
|
+
- **H** or **Hint** highlights a valid swap, without a penalty.
|
|
69
|
+
- **R** or **New game** restarts. **Esc** quits.
|
|
70
|
+
- Boards with no valid swaps are replaced automatically for free; the score and remaining moves carry over.
|
|
71
|
+
|
|
72
|
+
## Sounds
|
|
73
|
+
|
|
74
|
+
Optional sound hooks are ready. Drop WAV or OGG effects into `sounds/` using the filenames in [sounds/README.md](https://github.com/wulfalpha/alchemy/blob/main/sounds/README.md), then restart. Press **M** to mute; use `--volume 0.4` to adjust volume. Missing audio files or devices do not prevent play.
|
|
75
|
+
|
|
76
|
+
## Artwork
|
|
77
|
+
|
|
78
|
+
`img/AoA_symbols.pdf` is a visual reference supplied with the project. The game does not extract or display artwork from that PDF.
|
|
79
|
+
|
|
80
|
+
The five transparent PNGs in `img/` are fresh geometric drawings of traditional fire, water, air, earth, and sun symbols. Replace `fire.png`, `water.png`, `air.png`, `earth.png`, or `sun.png` to customize the game. Square transparent images work best; they display at 48 × 48 pixels.
|
|
81
|
+
|
|
82
|
+
`python tools/draw_symbols.py` regenerates the default images in both `img/` and `src/alchemy/assets/`. The latter provides packaged fallback assets for installed builds. Copy custom images there as well before building a distribution.
|
|
83
|
+
|
|
84
|
+
This is an independent prototype. No affiliation with an earlier game is implied; naming and distribution rights have not been legally reviewed.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
Rules live in `src/alchemy/board.py`; rendering and input live in `src/alchemy/app.py`.
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
92
|
+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy PYTHONPATH=src python -m alchemy --smoke-test --seed 42 --screenshot /tmp/alchemy.png
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The full suite, including the edge-case tests in `tests/edge/`, needs pytest:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy uv run --with pytest pytest -q
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use `alchemy --seed 42` for a reproducible game. This first version has session scoring; persistent high scores, bundled sound clips, and additional modes are not implemented yet.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
Released under the [MIT License](https://github.com/wulfalpha/alchemy/blob/main/LICENSE). Copyright (c) 2026 Andrew Davidson.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Alchemy
|
|
2
|
+
|
|
3
|
+
A Pygame match-four puzzle with independently drawn alchemical symbols and a quiet laboratory aesthetic.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
With Python 3.12+:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install alchemy-game
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or as a standalone tool (no project venv needed):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pipx install alchemy-game
|
|
17
|
+
# or: uv tool install alchemy-game
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then run:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
alchemy
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Play
|
|
27
|
+
|
|
28
|
+
From a clone of this repository, with [uv](https://docs.astral.sh/uv/):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
uv run alchemy
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or install into your Python environment with `python -m pip install -e .`, then run `alchemy`.
|
|
35
|
+
|
|
36
|
+
- Click a symbol, then an adjacent symbol to swap them.
|
|
37
|
+
- Match **four or more** identical symbols horizontally or vertically. Diagonals and groups without a four-symbol line do not clear.
|
|
38
|
+
- Valid swaps use one of your 30 moves. Invalid swaps are reversed for free.
|
|
39
|
+
- Cleared tiles refill from above; further matches produce cascades.
|
|
40
|
+
- Each cleared symbol earns 25 points, with another 25 for each symbol beyond four. The total is multiplied by the cascade number. Intersections count each tile once. A true plus adds **200 points per crossing**, before the cascade multiplier: both horizontal and vertical runs must be 4+ symbols and extend on both sides of the crossing. T and L shapes do not earn this bonus.
|
|
41
|
+
- **H** or **Hint** highlights a valid swap, without a penalty.
|
|
42
|
+
- **R** or **New game** restarts. **Esc** quits.
|
|
43
|
+
- Boards with no valid swaps are replaced automatically for free; the score and remaining moves carry over.
|
|
44
|
+
|
|
45
|
+
## Sounds
|
|
46
|
+
|
|
47
|
+
Optional sound hooks are ready. Drop WAV or OGG effects into `sounds/` using the filenames in [sounds/README.md](https://github.com/wulfalpha/alchemy/blob/main/sounds/README.md), then restart. Press **M** to mute; use `--volume 0.4` to adjust volume. Missing audio files or devices do not prevent play.
|
|
48
|
+
|
|
49
|
+
## Artwork
|
|
50
|
+
|
|
51
|
+
`img/AoA_symbols.pdf` is a visual reference supplied with the project. The game does not extract or display artwork from that PDF.
|
|
52
|
+
|
|
53
|
+
The five transparent PNGs in `img/` are fresh geometric drawings of traditional fire, water, air, earth, and sun symbols. Replace `fire.png`, `water.png`, `air.png`, `earth.png`, or `sun.png` to customize the game. Square transparent images work best; they display at 48 × 48 pixels.
|
|
54
|
+
|
|
55
|
+
`python tools/draw_symbols.py` regenerates the default images in both `img/` and `src/alchemy/assets/`. The latter provides packaged fallback assets for installed builds. Copy custom images there as well before building a distribution.
|
|
56
|
+
|
|
57
|
+
This is an independent prototype. No affiliation with an earlier game is implied; naming and distribution rights have not been legally reviewed.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
Rules live in `src/alchemy/board.py`; rendering and input live in `src/alchemy/app.py`.
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
PYTHONPATH=src python -m unittest discover -s tests -v
|
|
65
|
+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy PYTHONPATH=src python -m alchemy --smoke-test --seed 42 --screenshot /tmp/alchemy.png
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The full suite, including the edge-case tests in `tests/edge/`, needs pytest:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy uv run --with pytest pytest -q
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Use `alchemy --seed 42` for a reproducible game. This first version has session scoring; persistent high scores, bundled sound clips, and additional modes are not implemented yet.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
Released under the [MIT License](https://github.com/wulfalpha/alchemy/blob/main/LICENSE). Copyright (c) 2026 Andrew Davidson.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alchemy-game"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An alchemical match-four puzzle game built with Pygame"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["pygame>=2.6.1,<3"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"game",
|
|
12
|
+
"puzzle",
|
|
13
|
+
"match-four",
|
|
14
|
+
"pygame",
|
|
15
|
+
"alchemy",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: MacOS X",
|
|
20
|
+
"Environment :: Win32 (MS Windows)",
|
|
21
|
+
"Environment :: X11 Applications",
|
|
22
|
+
"Intended Audience :: End Users/Desktop",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Games/Entertainment :: Puzzle Games",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Andrew Davidson"
|
|
33
|
+
email = "drace.davidson@gmail.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/wulfalpha/alchemy"
|
|
37
|
+
Repository = "https://github.com/wulfalpha/alchemy"
|
|
38
|
+
Issues = "https://github.com/wulfalpha/alchemy/issues"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
alchemy = "alchemy:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "alchemy"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alchemy-game"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An alchemical match-four puzzle game built with Pygame"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Andrew Davidson", email = "drace.davidson@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = ["pygame>=2.6.1,<3"]
|
|
13
|
+
keywords = ["game", "puzzle", "match-four", "pygame", "alchemy"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: MacOS X",
|
|
17
|
+
"Environment :: Win32 (MS Windows)",
|
|
18
|
+
"Environment :: X11 Applications",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Games/Entertainment :: Puzzle Games",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/wulfalpha/alchemy"
|
|
30
|
+
Repository = "https://github.com/wulfalpha/alchemy"
|
|
31
|
+
Issues = "https://github.com/wulfalpha/alchemy/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
alchemy = "alchemy:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.23,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv.build-backend]
|
|
41
|
+
module-name = "alchemy"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Pygame presentation and input."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
os.environ.setdefault('PYGAME_HIDE_SUPPORT_PROMPT', '1')
|
|
7
|
+
import pygame
|
|
8
|
+
from .board import Game, legal_moves
|
|
9
|
+
from .audio import Audio
|
|
10
|
+
|
|
11
|
+
WIDTH, HEIGHT = 1040, 780
|
|
12
|
+
LEFT, TOP, TILE = 48, 154, 70
|
|
13
|
+
BG = (15, 24, 30)
|
|
14
|
+
TEXT = (241, 229, 203)
|
|
15
|
+
MUTED = (156, 173, 174)
|
|
16
|
+
GOLD = (223, 181, 103)
|
|
17
|
+
COLORS = [(107, 54, 45), (38, 80, 103), (73, 89, 77), (85, 68, 104), (110, 88, 42)]
|
|
18
|
+
NAMES = ['fire', 'water', 'air', 'earth', 'sun']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
parser = argparse.ArgumentParser(description='Alchemy: a match-four puzzle')
|
|
23
|
+
parser.add_argument('--seed', type=int, help='Reproducible starting board')
|
|
24
|
+
parser.add_argument('--smoke-test', action='store_true', help='Render a frame and exit')
|
|
25
|
+
parser.add_argument('--screenshot', type=Path, help='Save the rendered frame as PNG')
|
|
26
|
+
parser.add_argument('--sound-dir', type=Path, help='Directory of optional WAV/OGG effects')
|
|
27
|
+
parser.add_argument('--mute', action='store_true', help='Start with sound muted')
|
|
28
|
+
parser.add_argument('--volume', type=float, default=0.6, help='Effect volume from 0 to 1')
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
if not 0 <= args.volume <= 1:
|
|
31
|
+
parser.error('--volume must be between 0 and 1')
|
|
32
|
+
pygame.init()
|
|
33
|
+
audio = Audio(args.sound_dir, args.volume, args.mute)
|
|
34
|
+
screen = pygame.display.set_mode((WIDTH, HEIGHT))
|
|
35
|
+
pygame.display.set_caption('Alchemy | The Fourfold Art')
|
|
36
|
+
clock = pygame.time.Clock()
|
|
37
|
+
fonts = {size: pygame.font.SysFont('dejavusans', size) for size in (14, 16, 19, 24, 38, 48)}
|
|
38
|
+
root = Path(__file__).resolve().parents[2] / 'img'
|
|
39
|
+
if not root.is_dir():
|
|
40
|
+
root = Path(__file__).resolve().parent / 'assets'
|
|
41
|
+
symbols = []
|
|
42
|
+
for name in NAMES:
|
|
43
|
+
custom = root / f'{name}.png'
|
|
44
|
+
fallback = Path(__file__).resolve().parent / 'assets' / f'{name}.png'
|
|
45
|
+
symbols.append(pygame.transform.smoothscale(pygame.image.load(str(custom if custom.exists() else fallback)).convert_alpha(), (48, 48)))
|
|
46
|
+
game = Game(args.seed)
|
|
47
|
+
selected = None
|
|
48
|
+
hint = None
|
|
49
|
+
next_resolve = 0
|
|
50
|
+
running = True
|
|
51
|
+
hint_button = pygame.Rect(662, 590, 142, 46)
|
|
52
|
+
restart_button = pygame.Rect(818, 590, 164, 46)
|
|
53
|
+
|
|
54
|
+
def text(value, pos, size=19, color=TEXT):
|
|
55
|
+
screen.blit(fonts[size].render(str(value), True, color), pos)
|
|
56
|
+
|
|
57
|
+
while running:
|
|
58
|
+
now = pygame.time.get_ticks()
|
|
59
|
+
for event in pygame.event.get():
|
|
60
|
+
if event.type == pygame.QUIT:
|
|
61
|
+
running = False
|
|
62
|
+
elif event.type == pygame.KEYDOWN:
|
|
63
|
+
if event.key == pygame.K_ESCAPE:
|
|
64
|
+
running = False
|
|
65
|
+
elif event.key == pygame.K_m:
|
|
66
|
+
audio.toggle()
|
|
67
|
+
elif event.key == pygame.K_r:
|
|
68
|
+
audio.play("restart")
|
|
69
|
+
game = Game(args.seed)
|
|
70
|
+
selected = hint = None
|
|
71
|
+
elif event.key == pygame.K_h and not game.pending and game.moves:
|
|
72
|
+
hint = legal_moves(game.grid)[0]
|
|
73
|
+
audio.play("hint")
|
|
74
|
+
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
|
75
|
+
if restart_button.collidepoint(event.pos):
|
|
76
|
+
audio.play("restart")
|
|
77
|
+
game = Game(args.seed)
|
|
78
|
+
selected = hint = None
|
|
79
|
+
elif hint_button.collidepoint(event.pos) and not game.pending and game.moves:
|
|
80
|
+
hint = legal_moves(game.grid)[0]
|
|
81
|
+
audio.play("hint")
|
|
82
|
+
elif not game.pending and game.moves:
|
|
83
|
+
x = (event.pos[0] - LEFT) // TILE
|
|
84
|
+
y = (event.pos[1] - TOP) // TILE
|
|
85
|
+
if 0 <= x < 8 and 0 <= y < 8:
|
|
86
|
+
cell = (x, y)
|
|
87
|
+
hint = None
|
|
88
|
+
if selected == cell:
|
|
89
|
+
selected = None
|
|
90
|
+
elif selected is not None and abs(selected[0]-x) + abs(selected[1]-y) == 1:
|
|
91
|
+
if game.attempt(selected, cell):
|
|
92
|
+
audio.play("swap")
|
|
93
|
+
next_resolve = now + 350
|
|
94
|
+
else:
|
|
95
|
+
audio.play("invalid")
|
|
96
|
+
selected = None
|
|
97
|
+
else:
|
|
98
|
+
selected = cell
|
|
99
|
+
audio.play("select")
|
|
100
|
+
if game.pending and now >= next_resolve:
|
|
101
|
+
for sound_event in game.resolve():
|
|
102
|
+
audio.play(sound_event)
|
|
103
|
+
next_resolve = now + 350
|
|
104
|
+
|
|
105
|
+
screen.fill(BG)
|
|
106
|
+
pygame.draw.circle(screen, (21, 34, 40), (975, 60), 260, 1)
|
|
107
|
+
pygame.draw.circle(screen, (21, 34, 40), (975, 60), 220, 1)
|
|
108
|
+
text('A L C H E M Y', (48, 28), 38)
|
|
109
|
+
text('T H E F O U R F O L D A R T', (50, 85), 14, GOLD)
|
|
110
|
+
text('A little patience. A little transformation.', (662, 67), 16, MUTED)
|
|
111
|
+
pygame.draw.line(screen, (57, 68, 68), (48, 125), (982, 125))
|
|
112
|
+
for y, row in enumerate(game.grid):
|
|
113
|
+
for x, value in enumerate(row):
|
|
114
|
+
rect = pygame.Rect(LEFT+x*TILE+3, TOP+y*TILE+3, TILE-6, TILE-6)
|
|
115
|
+
pygame.draw.rect(screen, COLORS[value], rect, border_radius=10)
|
|
116
|
+
pygame.draw.rect(screen, (255, 226, 154) if (x,y) in game.pending else (104, 107, 99), rect, 1, border_radius=10)
|
|
117
|
+
screen.blit(symbols[value], symbols[value].get_rect(center=rect.center))
|
|
118
|
+
if selected == (x, y) or (hint and (x, y) in hint):
|
|
119
|
+
pygame.draw.rect(screen, GOLD, rect.inflate(4, 4), 3, border_radius=12)
|
|
120
|
+
if (x,y) in game.pending:
|
|
121
|
+
overlay = pygame.Surface(rect.size, pygame.SRCALPHA)
|
|
122
|
+
overlay.fill((255, 234, 173, 95))
|
|
123
|
+
screen.blit(overlay, rect)
|
|
124
|
+
|
|
125
|
+
text('YOUR EXPERIMENT', (662, 158), 14, GOLD)
|
|
126
|
+
text(f'{game.score:,}', (658, 183), 48)
|
|
127
|
+
text('POINTS', (664, 244), 14, MUTED)
|
|
128
|
+
text(f'{game.moves:02d}', (855, 183), 48)
|
|
129
|
+
text('MOVES LEFT', (857, 244), 14, MUTED)
|
|
130
|
+
pygame.draw.line(screen, (57, 68, 68), (662, 282), (982, 282))
|
|
131
|
+
text('The practice', (662, 305), 24)
|
|
132
|
+
for i, line in enumerate(('Swap two neighboring symbols.', 'Align 4 or more in a row or column.', 'Cleared symbols make room for new ones.', 'Chain reactions multiply your points.', 'Cross two 4+ lines: +200 per plus.')):
|
|
133
|
+
text(line, (662, 350+i*28), 14, MUTED)
|
|
134
|
+
for i, symbol in enumerate(symbols):
|
|
135
|
+
screen.blit(symbol, (663+i*65, 508))
|
|
136
|
+
for button, label in ((hint_button, 'Hint [H]'), (restart_button, 'New game [R]')):
|
|
137
|
+
pygame.draw.rect(screen, (35, 49, 55), button, border_radius=8)
|
|
138
|
+
pygame.draw.rect(screen, (92, 105, 103), button, 1, border_radius=8)
|
|
139
|
+
screen.blit(fonts[16].render(label, True, TEXT), fonts[16].render(label, True, TEXT).get_rect(center=button.center))
|
|
140
|
+
text(f'Sound: {"off" if audio.muted else "on"} [M] / ESC to quit', (662, 661), 14, MUTED)
|
|
141
|
+
text(game.message, (48, 735), 16, GOLD)
|
|
142
|
+
|
|
143
|
+
if game.moves == 0 and not game.pending:
|
|
144
|
+
shade = pygame.Surface((560, 560), pygame.SRCALPHA)
|
|
145
|
+
shade.fill((9, 17, 24, 225))
|
|
146
|
+
screen.blit(shade, (LEFT, TOP))
|
|
147
|
+
text('Experiment complete', (110, 320), 24)
|
|
148
|
+
text(f'{game.score:,} points', (150, 370), 38, GOLD)
|
|
149
|
+
text('Press R to begin a new experiment.', (122, 439), 16)
|
|
150
|
+
pygame.display.flip()
|
|
151
|
+
if args.screenshot:
|
|
152
|
+
pygame.image.save(screen, str(args.screenshot))
|
|
153
|
+
args.screenshot = None
|
|
154
|
+
if args.smoke_test:
|
|
155
|
+
running = False
|
|
156
|
+
clock.tick(60)
|
|
157
|
+
pygame.quit()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Optional drop-in sound effects. Missing files and audio devices are harmless."""
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pygame
|
|
6
|
+
|
|
7
|
+
EVENTS = ('select', 'swap', 'invalid', 'match', 'cascade', 'plus',
|
|
8
|
+
'hint', 'shuffle', 'restart', 'game_over')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Audio:
|
|
12
|
+
def __init__(self, directory=None, volume=0.6, muted=False):
|
|
13
|
+
self.muted = muted
|
|
14
|
+
self.sounds = {}
|
|
15
|
+
if not pygame.mixer.get_init():
|
|
16
|
+
try:
|
|
17
|
+
pygame.mixer.init()
|
|
18
|
+
except pygame.error:
|
|
19
|
+
return
|
|
20
|
+
roots = ([Path(directory)] if directory is not None else [
|
|
21
|
+
Path(__file__).resolve().parents[2] / 'sounds',
|
|
22
|
+
Path(__file__).resolve().parent / 'assets' / 'sounds',
|
|
23
|
+
])
|
|
24
|
+
for event in EVENTS:
|
|
25
|
+
for root in roots:
|
|
26
|
+
for extension in ('.wav', '.ogg'):
|
|
27
|
+
path = root / (event + extension)
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
sound = pygame.mixer.Sound(str(path))
|
|
32
|
+
sound.set_volume(max(0.0, min(1.0, volume)))
|
|
33
|
+
self.sounds[event] = sound
|
|
34
|
+
break
|
|
35
|
+
except (pygame.error, OSError) as error:
|
|
36
|
+
logging.warning('Cannot load sound %s: %s', path, error)
|
|
37
|
+
if event in self.sounds:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
def play(self, event):
|
|
41
|
+
if not self.muted and pygame.mixer.get_init():
|
|
42
|
+
sound = self.sounds.get(event)
|
|
43
|
+
if sound is not None:
|
|
44
|
+
sound.play()
|
|
45
|
+
|
|
46
|
+
def toggle(self):
|
|
47
|
+
self.muted = not self.muted
|
|
48
|
+
if self.muted and pygame.mixer.get_init():
|
|
49
|
+
pygame.mixer.stop()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Match-four rules, independent of the display."""
|
|
2
|
+
from random import Random
|
|
3
|
+
|
|
4
|
+
SIZE = 8
|
|
5
|
+
KINDS = 5
|
|
6
|
+
PLUS_BONUS = 200
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def matches(grid):
|
|
10
|
+
found = set()
|
|
11
|
+
for y in range(SIZE):
|
|
12
|
+
for x in range(SIZE):
|
|
13
|
+
value = grid[y][x]
|
|
14
|
+
if value is None:
|
|
15
|
+
continue
|
|
16
|
+
for dx, dy in ((1, 0), (0, 1)):
|
|
17
|
+
run = []
|
|
18
|
+
xx, yy = x, y
|
|
19
|
+
while xx < SIZE and yy < SIZE and grid[yy][xx] == value:
|
|
20
|
+
run.append((xx, yy))
|
|
21
|
+
xx += dx
|
|
22
|
+
yy += dy
|
|
23
|
+
if len(run) >= 4:
|
|
24
|
+
found.update(run)
|
|
25
|
+
return found
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def plus_centers(grid):
|
|
29
|
+
"""True pluses: both 4+ runs extend on both sides of their crossing."""
|
|
30
|
+
centers = set()
|
|
31
|
+
for y in range(1, SIZE - 1):
|
|
32
|
+
for x in range(1, SIZE - 1):
|
|
33
|
+
value = grid[y][x]
|
|
34
|
+
if value is None:
|
|
35
|
+
continue
|
|
36
|
+
lengths = []
|
|
37
|
+
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
38
|
+
length = 0
|
|
39
|
+
xx, yy = x + dx, y + dy
|
|
40
|
+
while 0 <= xx < SIZE and 0 <= yy < SIZE and grid[yy][xx] == value:
|
|
41
|
+
length += 1
|
|
42
|
+
xx, yy = xx + dx, yy + dy
|
|
43
|
+
lengths.append(length)
|
|
44
|
+
right, left, down, up = lengths
|
|
45
|
+
if min(lengths) > 0 and right + left + 1 >= 4 and down + up + 1 >= 4:
|
|
46
|
+
centers.add((x, y))
|
|
47
|
+
return centers
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def swap(grid, a, b):
|
|
51
|
+
ax, ay = a
|
|
52
|
+
bx, by = b
|
|
53
|
+
grid[ay][ax], grid[by][bx] = grid[by][bx], grid[ay][ax]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def legal_moves(grid):
|
|
57
|
+
result = []
|
|
58
|
+
for y in range(SIZE):
|
|
59
|
+
for x in range(SIZE):
|
|
60
|
+
for b in ((x + 1, y), (x, y + 1)):
|
|
61
|
+
if b[0] >= SIZE or b[1] >= SIZE:
|
|
62
|
+
continue
|
|
63
|
+
a = (x, y)
|
|
64
|
+
swap(grid, a, b)
|
|
65
|
+
if matches(grid):
|
|
66
|
+
result.append((a, b))
|
|
67
|
+
swap(grid, a, b)
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def new_grid(rng):
|
|
72
|
+
while True:
|
|
73
|
+
grid = [[rng.randrange(KINDS) for _ in range(SIZE)] for _ in range(SIZE)]
|
|
74
|
+
if not matches(grid) and legal_moves(grid):
|
|
75
|
+
return grid
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def refill(grid, cleared, rng):
|
|
79
|
+
for x in range(SIZE):
|
|
80
|
+
remaining = [grid[y][x] for y in range(SIZE) if (x, y) not in cleared]
|
|
81
|
+
column = [rng.randrange(KINDS) for _ in range(SIZE - len(remaining))] + remaining
|
|
82
|
+
for y, value in enumerate(column):
|
|
83
|
+
grid[y][x] = value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Game:
|
|
87
|
+
def __init__(self, seed=None):
|
|
88
|
+
self.rng = Random(seed)
|
|
89
|
+
self.grid = new_grid(self.rng)
|
|
90
|
+
self.score = 0
|
|
91
|
+
self.moves = 30
|
|
92
|
+
self.chain = 0
|
|
93
|
+
self.pending = set()
|
|
94
|
+
self.message = 'Select a symbol, then an adjacent symbol.'
|
|
95
|
+
|
|
96
|
+
def attempt(self, a, b):
|
|
97
|
+
if self.pending or self.moves <= 0:
|
|
98
|
+
return False
|
|
99
|
+
if any(not (0 <= x < SIZE and 0 <= y < SIZE) for x, y in (a, b)):
|
|
100
|
+
return False
|
|
101
|
+
if abs(a[0] - b[0]) + abs(a[1] - b[1]) != 1:
|
|
102
|
+
return False
|
|
103
|
+
swap(self.grid, a, b)
|
|
104
|
+
self.pending = matches(self.grid)
|
|
105
|
+
if not self.pending:
|
|
106
|
+
swap(self.grid, a, b)
|
|
107
|
+
self.message = 'Make a line of at least four. Try another swap.'
|
|
108
|
+
return False
|
|
109
|
+
self.moves -= 1
|
|
110
|
+
self.chain = 0
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def resolve(self):
|
|
114
|
+
if not self.pending:
|
|
115
|
+
return
|
|
116
|
+
self.chain += 1
|
|
117
|
+
count = len(self.pending)
|
|
118
|
+
pluses = len(plus_centers(self.grid) & self.pending)
|
|
119
|
+
bonus = pluses * PLUS_BONUS
|
|
120
|
+
points = (count * 25 + max(0, count - 4) * 25 + bonus) * self.chain
|
|
121
|
+
events = ["cascade" if self.chain > 1 else "match"]
|
|
122
|
+
if pluses:
|
|
123
|
+
events.append("plus")
|
|
124
|
+
self.score += points
|
|
125
|
+
self.message = f'{count} symbols transmuted! +{points} / Cascade x{self.chain}'
|
|
126
|
+
if pluses:
|
|
127
|
+
self.message += f' / Plus bonus +{bonus * self.chain}'
|
|
128
|
+
refill(self.grid, self.pending, self.rng)
|
|
129
|
+
self.pending = matches(self.grid)
|
|
130
|
+
if not self.pending and self.moves and not legal_moves(self.grid):
|
|
131
|
+
self.grid = new_grid(self.rng)
|
|
132
|
+
self.message = 'No swaps left: a fresh board has been brewed.'
|
|
133
|
+
events.append("shuffle")
|
|
134
|
+
if not self.pending and not self.moves:
|
|
135
|
+
events.append("game_over")
|
|
136
|
+
return events
|