kaypy 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.
- kaplay/__init__.py +192 -0
- kaplay/assets.py +127 -0
- kaplay/callutil.py +54 -0
- kaplay/camera.py +44 -0
- kaplay/cli.py +86 -0
- kaplay/comps/__init__.py +0 -0
- kaplay/comps/area.py +24 -0
- kaplay/comps/body.py +50 -0
- kaplay/comps/move.py +67 -0
- kaplay/comps/pos.py +52 -0
- kaplay/comps/shapes.py +96 -0
- kaplay/comps/sprite.py +86 -0
- kaplay/comps/state.py +66 -0
- kaplay/comps/transform.py +104 -0
- kaplay/debugmod.py +10 -0
- kaplay/engine.py +311 -0
- kaplay/events.py +128 -0
- kaplay/gameobj.py +192 -0
- kaplay/geometry.py +53 -0
- kaplay/kaboom.py +42 -0
- kaplay/level.py +44 -0
- kaplay/physics.py +208 -0
- kaplay/render.py +119 -0
- kaplay/starter/CREDITS.md +134 -0
- kaplay/starter/dungeon/elf_m.png +0 -0
- kaplay/starter/dungeon.png +0 -0
- kaplay/starter/game.py +81 -0
- kaplay/starter/images/bean.png +0 -0
- kaplay/starter/images/coin.png +0 -0
- kaplay/starter/images/dino_0.png +0 -0
- kaplay/starter/images/dino_1.png +0 -0
- kaplay/starter/images/dino_2.png +0 -0
- kaplay/starter/images/dino_3.png +0 -0
- kaplay/starter/images/dino_4.png +0 -0
- kaplay/starter/images/dino_5.png +0 -0
- kaplay/starter/images/dino_6.png +0 -0
- kaplay/starter/images/dino_7.png +0 -0
- kaplay/starter/images/dino_8.png +0 -0
- kaplay/starter/images/ghosty.png +0 -0
- kaplay/starter/images/grass.png +0 -0
- kaplay/starter/images/portal.png +0 -0
- kaplay/starter/images/spike.png +0 -0
- kaplay/starter/images/steel.png +0 -0
- kaplay/starter/sounds/background.wav +0 -0
- kaplay/starter/sounds/ding.wav +0 -0
- kaplay/starter/sounds/screech.wav +0 -0
- kaplay/timers.py +57 -0
- kaplay/vec2.py +114 -0
- kaplay/webbuild.py +361 -0
- kaypy-0.1.0.dist-info/METADATA +437 -0
- kaypy-0.1.0.dist-info/RECORD +55 -0
- kaypy-0.1.0.dist-info/WHEEL +5 -0
- kaypy-0.1.0.dist-info/entry_points.txt +2 -0
- kaypy-0.1.0.dist-info/licenses/LICENSE +33 -0
- kaypy-0.1.0.dist-info/top_level.txt +1 -0
kaplay/__init__.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""from kaplay import * — every name here is Kaplay's own name, in
|
|
2
|
+
Kaplay's own order, with Kaplay's own arguments. The Kaplay documentation
|
|
3
|
+
and every Kaplay example on the internet applies to what you write here;
|
|
4
|
+
only the language underneath (real, native Python, via pygame-ce) is new.
|
|
5
|
+
"""
|
|
6
|
+
from .vec2 import vec2, Vec2
|
|
7
|
+
from .engine import Engine, current_engine, rand, randi, choose
|
|
8
|
+
from .debugmod import debug
|
|
9
|
+
from .level import addLevel, Level
|
|
10
|
+
from .kaboom import addKaboom
|
|
11
|
+
|
|
12
|
+
# ---- components ---------------------------------------------------------
|
|
13
|
+
from .comps.pos import pos
|
|
14
|
+
from .comps.sprite import sprite
|
|
15
|
+
from .comps.shapes import rect, circle, text
|
|
16
|
+
from .comps.area import area
|
|
17
|
+
from .comps.body import body
|
|
18
|
+
from .comps.transform import anchor, scale, color, opacity, outline, z, fixed
|
|
19
|
+
from .comps.state import state
|
|
20
|
+
from .comps.move import move, offscreen, tile
|
|
21
|
+
from .callutil import register_or_decorate
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"kaplay",
|
|
25
|
+
"loadSprite", "loadSpriteAtlas", "loadSound",
|
|
26
|
+
"setGravity", "setBackground",
|
|
27
|
+
"add", "get", "addLevel", "addKaboom",
|
|
28
|
+
"pos", "sprite", "rect", "circle", "text", "area", "body",
|
|
29
|
+
"anchor", "scale", "color", "opacity", "outline", "z", "fixed",
|
|
30
|
+
"move", "offscreen", "tile", "state",
|
|
31
|
+
"onUpdate", "onKeyDown", "onKeyPress", "onKeyRelease", "onClick",
|
|
32
|
+
"wait", "loop",
|
|
33
|
+
"scene", "go",
|
|
34
|
+
"width", "height", "center", "dt", "vec2",
|
|
35
|
+
"rand", "randi", "choose",
|
|
36
|
+
"mousePos", "toWorld",
|
|
37
|
+
"setCamPos", "setCamScale", "shake",
|
|
38
|
+
"play",
|
|
39
|
+
"debug",
|
|
40
|
+
"run",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def kaplay(width=800, height=600, background=(0, 0, 0)):
|
|
45
|
+
"""Starts the engine. It has to come first, before anything else."""
|
|
46
|
+
return Engine(width=width, height=height, background=background)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---- loading -------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def loadSprite(name, path, sliceX=1, sliceY=1, anims=None):
|
|
52
|
+
return current_engine().assets.loadSprite(name, path, sliceX=sliceX, sliceY=sliceY, anims=anims)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def loadSpriteAtlas(path, atlas):
|
|
56
|
+
return current_engine().assets.loadSpriteAtlas(path, atlas)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def loadSound(name, path):
|
|
60
|
+
return current_engine().assets.loadSound(name, path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def setGravity(n):
|
|
64
|
+
current_engine().setGravity(n)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def setBackground(r, g, b):
|
|
68
|
+
current_engine().setBackground(r, g, b)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---- making things --------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def add(comp_list):
|
|
74
|
+
return current_engine().add(comp_list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get(tag):
|
|
78
|
+
return current_engine().get(tag)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---- events ----------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def onUpdate(*args):
|
|
84
|
+
"""onUpdate(fn) / onUpdate(tag, fn), or as a decorator:
|
|
85
|
+
|
|
86
|
+
@onUpdate # every frame
|
|
87
|
+
@onUpdate("enemy") # every frame, once per tagged object
|
|
88
|
+
"""
|
|
89
|
+
if len(args) == 2:
|
|
90
|
+
tag, fn = args
|
|
91
|
+
return register_or_decorate(fn, lambda f: current_engine().events.on_update(tag, f))
|
|
92
|
+
if len(args) == 1 and callable(args[0]):
|
|
93
|
+
return current_engine().events.on_update(args[0])
|
|
94
|
+
if len(args) == 1:
|
|
95
|
+
tag = args[0]
|
|
96
|
+
return register_or_decorate(None, lambda f: current_engine().events.on_update(tag, f))
|
|
97
|
+
raise TypeError("onUpdate() takes a function, or a tag and a function")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def onKeyDown(key, fn=None):
|
|
101
|
+
return register_or_decorate(fn, lambda f: current_engine().events.on_key_down(key, f))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def onKeyPress(key, fn=None):
|
|
105
|
+
return register_or_decorate(fn, lambda f: current_engine().events.on_key_press(key, f))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def onKeyRelease(key, fn=None):
|
|
109
|
+
return register_or_decorate(fn, lambda f: current_engine().events.on_key_release(key, f))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def onClick(fn=None):
|
|
113
|
+
return register_or_decorate(fn, lambda f: current_engine().events.on_click(f))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def wait(seconds, fn=None):
|
|
117
|
+
return register_or_decorate(fn, lambda f: current_engine().timers.wait(seconds, f))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def loop(seconds, fn=None):
|
|
121
|
+
return register_or_decorate(fn, lambda f: current_engine().timers.loop(seconds, f))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---- scenes -----------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def scene(name, fn=None):
|
|
127
|
+
"""scene(name, fn), or as a decorator:
|
|
128
|
+
|
|
129
|
+
@scene("game")
|
|
130
|
+
def build_game(): ...
|
|
131
|
+
"""
|
|
132
|
+
def register(f):
|
|
133
|
+
current_engine().scene(name, f)
|
|
134
|
+
return f
|
|
135
|
+
return register_or_decorate(fn, register)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def go(name, *args):
|
|
139
|
+
current_engine().go(name, *args)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---- useful values ------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def width():
|
|
145
|
+
return current_engine().width()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def height():
|
|
149
|
+
return current_engine().height()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def center():
|
|
153
|
+
return current_engine().center()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def dt():
|
|
157
|
+
return current_engine().dt()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mousePos():
|
|
161
|
+
return current_engine().mousePos()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def toWorld(pos_):
|
|
165
|
+
return current_engine().toWorld(pos_)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def setCamPos(pos_):
|
|
169
|
+
current_engine().setCamPos(pos_)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def setCamScale(n):
|
|
173
|
+
current_engine().setCamScale(n)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def shake(n=8):
|
|
177
|
+
current_engine().shake(n)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def play(name, loop=False, paused=False, volume=1.0):
|
|
181
|
+
from .engine import SoundHandle
|
|
182
|
+
engine = current_engine()
|
|
183
|
+
if name not in engine.assets.sounds:
|
|
184
|
+
raise KeyError(f"no sound called {name!r} — loadSound(\"{name}\", ...) first")
|
|
185
|
+
return SoundHandle(engine.assets.sounds[name], loop=loop, paused=paused, volume=volume)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run():
|
|
189
|
+
"""Not part of the Kaplay API — the loop starts on its own once your
|
|
190
|
+
script finishes. Exposed only so 'kaypy run game.py'-style tooling
|
|
191
|
+
(or a script that wants to be explicit) has something to call."""
|
|
192
|
+
current_engine().run()
|
kaplay/assets.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Sprite / sound loading: loadSprite, loadSpriteAtlas, loadSound.
|
|
2
|
+
|
|
3
|
+
Kaplay's own troubleshooting note applies here too: "the sprite is
|
|
4
|
+
invisible but the game runs" almost always means a bad path, and
|
|
5
|
+
"every frame is half one pose and half the next" means sliceX doesn't
|
|
6
|
+
match the sheet. We raise clear errors for the first and let the second
|
|
7
|
+
happen silently (cutting a rectangle of nothing without complaint),
|
|
8
|
+
exactly as Kaplay's own guide describes.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import pygame
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpriteAsset:
|
|
17
|
+
def __init__(self, frames, anims=None, frame_size=None):
|
|
18
|
+
self.frames = frames # list[pygame.Surface]
|
|
19
|
+
self.anims = anims or {} # name -> {"from","to","speed","loop"} or int
|
|
20
|
+
self.frame_size = frame_size or (frames[0].get_width(), frames[0].get_height())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_image(path):
|
|
24
|
+
if not os.path.isfile(path):
|
|
25
|
+
raise FileNotFoundError(
|
|
26
|
+
f"loadSprite/loadSpriteAtlas: no such file {path!r} — "
|
|
27
|
+
f"check the path (the two sprite packs live in separate "
|
|
28
|
+
f"folders; a dungeon sprite is not under images/)"
|
|
29
|
+
)
|
|
30
|
+
return pygame.image.load(path).convert_alpha()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _slice_surface(surface, slice_x, slice_y):
|
|
34
|
+
w = surface.get_width() // slice_x
|
|
35
|
+
h = surface.get_height() // slice_y
|
|
36
|
+
frames = []
|
|
37
|
+
for row in range(slice_y):
|
|
38
|
+
for col in range(slice_x):
|
|
39
|
+
rect = pygame.Rect(col * w, row * h, w, h)
|
|
40
|
+
frame = pygame.Surface((w, h), pygame.SRCALPHA)
|
|
41
|
+
frame.blit(surface, (0, 0), rect)
|
|
42
|
+
frames.append(frame)
|
|
43
|
+
return frames, (w, h)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_anims(anims):
|
|
47
|
+
out = {}
|
|
48
|
+
for name, spec in (anims or {}).items():
|
|
49
|
+
if isinstance(spec, int):
|
|
50
|
+
out[name] = {"from": spec, "to": spec, "speed": 1, "loop": False}
|
|
51
|
+
else:
|
|
52
|
+
out[name] = {
|
|
53
|
+
"from": spec.get("from", 0),
|
|
54
|
+
"to": spec.get("to", 0),
|
|
55
|
+
"speed": spec.get("speed", 10),
|
|
56
|
+
"loop": spec.get("loop", False),
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AssetManager:
|
|
62
|
+
def __init__(self):
|
|
63
|
+
self.sprites: dict[str, SpriteAsset] = {}
|
|
64
|
+
self.sounds: dict[str, "pygame.mixer.Sound"] = {}
|
|
65
|
+
self._atlas_cache: dict[str, "pygame.Surface"] = {}
|
|
66
|
+
|
|
67
|
+
def loadSprite(self, name, path, sliceX=1, sliceY=1, anims=None):
|
|
68
|
+
if isinstance(path, (list, tuple)):
|
|
69
|
+
frames = [_load_image(p) for p in path]
|
|
70
|
+
frame_size = (frames[0].get_width(), frames[0].get_height())
|
|
71
|
+
else:
|
|
72
|
+
surface = _load_image(path)
|
|
73
|
+
if sliceX > 1 or sliceY > 1:
|
|
74
|
+
frames, frame_size = _slice_surface(surface, sliceX, sliceY)
|
|
75
|
+
else:
|
|
76
|
+
frames = [surface]
|
|
77
|
+
frame_size = (surface.get_width(), surface.get_height())
|
|
78
|
+
self.sprites[name] = SpriteAsset(frames, _normalize_anims(anims), frame_size)
|
|
79
|
+
return self.sprites[name]
|
|
80
|
+
|
|
81
|
+
def loadSpriteAtlas(self, path, atlas):
|
|
82
|
+
if path not in self._atlas_cache:
|
|
83
|
+
self._atlas_cache[path] = _load_image(path)
|
|
84
|
+
sheet = self._atlas_cache[path]
|
|
85
|
+
for name, spec in atlas.items():
|
|
86
|
+
x, y = spec["x"], spec["y"]
|
|
87
|
+
w, h = spec["width"], spec["height"]
|
|
88
|
+
region = pygame.Surface((w, h), pygame.SRCALPHA)
|
|
89
|
+
region.blit(sheet, (0, 0), pygame.Rect(x, y, w, h))
|
|
90
|
+
slice_x = spec.get("sliceX", 1)
|
|
91
|
+
slice_y = spec.get("sliceY", 1)
|
|
92
|
+
if slice_x > 1 or slice_y > 1:
|
|
93
|
+
frames, frame_size = _slice_surface(region, slice_x, slice_y)
|
|
94
|
+
else:
|
|
95
|
+
frames = [region]
|
|
96
|
+
frame_size = (w, h)
|
|
97
|
+
self.sprites[name] = SpriteAsset(
|
|
98
|
+
frames, _normalize_anims(spec.get("anims")), frame_size
|
|
99
|
+
)
|
|
100
|
+
return self.sprites
|
|
101
|
+
|
|
102
|
+
def loadSound(self, name, path):
|
|
103
|
+
if sys.platform == "emscripten":
|
|
104
|
+
# Browsers' WASM SDL2-mixer build can't reliably decode most
|
|
105
|
+
# .wav/.mp3/.aiff encodings — pygbag's own --build step flags
|
|
106
|
+
# this (see tools/build_web.py, which converts each .wav it
|
|
107
|
+
# copies into a same-named .ogg via ffmpeg when available). If
|
|
108
|
+
# that sibling .ogg exists, prefer it silently, so scripts never
|
|
109
|
+
# need an if-web branch just to pick a sound file extension.
|
|
110
|
+
ogg_path = os.path.splitext(path)[0] + ".ogg"
|
|
111
|
+
if os.path.isfile(ogg_path):
|
|
112
|
+
path = ogg_path
|
|
113
|
+
if not os.path.isfile(path):
|
|
114
|
+
raise FileNotFoundError(f"loadSound: no such file {path!r}")
|
|
115
|
+
try:
|
|
116
|
+
self.sounds[name] = pygame.mixer.Sound(path)
|
|
117
|
+
except pygame.error:
|
|
118
|
+
self.sounds[name] = None # audio device unavailable (headless/CI)
|
|
119
|
+
return self.sounds[name]
|
|
120
|
+
|
|
121
|
+
def get_sprite(self, name) -> SpriteAsset:
|
|
122
|
+
if name not in self.sprites:
|
|
123
|
+
raise KeyError(
|
|
124
|
+
f"no sprite called {name!r} — loadSprite(\"{name}\", ...) "
|
|
125
|
+
f"must run before it is used"
|
|
126
|
+
)
|
|
127
|
+
return self.sprites[name]
|
kaplay/callutil.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Kaplay callbacks can ignore arguments they don't want: onKeyDown hands
|
|
2
|
+
the key that was pressed, but `lambda: ...` takes nothing and works
|
|
3
|
+
anyway, exactly as `() => ...` does in JavaScript. If you *do* want the
|
|
4
|
+
argument, `lambda key: ...` gets it."""
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
_sig_cache = {}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_or_decorate(fn, register):
|
|
11
|
+
"""Let one event function serve as both a callback and a decorator.
|
|
12
|
+
|
|
13
|
+
onKeyPress("space", jump) # hand it a function
|
|
14
|
+
@onKeyPress("space") # or put it above one
|
|
15
|
+
def jump(): ...
|
|
16
|
+
|
|
17
|
+
Kaplay's own form is the first one, and it stays the form every
|
|
18
|
+
Kaplay example and doc page translates into directly. The second
|
|
19
|
+
exists because a lambda can only hold one expression, and working
|
|
20
|
+
around that teaches bad habits: `lambda: p.jump() if p.isGrounded()
|
|
21
|
+
else None` has an `else None` that does nothing and exists purely to
|
|
22
|
+
satisfy a conditional expression, and `lambda: setattr(p, "pos", v)`
|
|
23
|
+
reaches for setattr only because a lambda can't assign. With a
|
|
24
|
+
decorator, both are just ordinary indented code — and the handler
|
|
25
|
+
gets a name, so it shows up in tracebacks as `jump` rather than
|
|
26
|
+
`<lambda>`.
|
|
27
|
+
|
|
28
|
+
`register` does the actual work and returns the function; this only
|
|
29
|
+
decides whether to call it now or hand back something that will.
|
|
30
|
+
"""
|
|
31
|
+
if fn is not None:
|
|
32
|
+
return register(fn)
|
|
33
|
+
|
|
34
|
+
def decorator(f):
|
|
35
|
+
register(f)
|
|
36
|
+
return f # leave the name bound to the function itself
|
|
37
|
+
|
|
38
|
+
return decorator
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def call_flexible(fn, *args):
|
|
42
|
+
n = _sig_cache.get(fn)
|
|
43
|
+
if n is None:
|
|
44
|
+
try:
|
|
45
|
+
params = inspect.signature(fn).parameters.values()
|
|
46
|
+
has_varargs = any(p.kind == p.VAR_POSITIONAL for p in params)
|
|
47
|
+
n = len(args) if has_varargs else sum(
|
|
48
|
+
1 for p in params
|
|
49
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
50
|
+
)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
n = len(args)
|
|
53
|
+
_sig_cache[fn] = n
|
|
54
|
+
return fn(*args[:n])
|
kaplay/camera.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from .vec2 import Vec2
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Camera:
|
|
5
|
+
def __init__(self, width, height):
|
|
6
|
+
self.width = width
|
|
7
|
+
self.height = height
|
|
8
|
+
self.pos = Vec2(width / 2, height / 2)
|
|
9
|
+
self.scale = 1.0
|
|
10
|
+
self.shake_amount = 0.0
|
|
11
|
+
self._frame_shake_offset = Vec2(0, 0)
|
|
12
|
+
|
|
13
|
+
def setPos(self, pos):
|
|
14
|
+
self.pos = Vec2(pos.x, pos.y)
|
|
15
|
+
|
|
16
|
+
def setScale(self, n):
|
|
17
|
+
self.scale = n
|
|
18
|
+
|
|
19
|
+
def shake(self, amount=8):
|
|
20
|
+
self.shake_amount = amount
|
|
21
|
+
|
|
22
|
+
def begin_frame(self):
|
|
23
|
+
"""Called once per frame by the render system: decays shake and
|
|
24
|
+
freezes this frame's random offset so every object drawn this
|
|
25
|
+
frame shakes together."""
|
|
26
|
+
if self.shake_amount <= 0:
|
|
27
|
+
self._frame_shake_offset = Vec2(0, 0)
|
|
28
|
+
return
|
|
29
|
+
import random
|
|
30
|
+
a = self.shake_amount
|
|
31
|
+
self._frame_shake_offset = Vec2(random.uniform(-a, a), random.uniform(-a, a))
|
|
32
|
+
self.shake_amount *= 0.9
|
|
33
|
+
if self.shake_amount < 0.2:
|
|
34
|
+
self.shake_amount = 0
|
|
35
|
+
|
|
36
|
+
def world_to_screen(self, world_pos: Vec2) -> Vec2:
|
|
37
|
+
center = Vec2(self.width / 2, self.height / 2)
|
|
38
|
+
return (world_pos - self.pos) * self.scale + center + self._frame_shake_offset
|
|
39
|
+
|
|
40
|
+
def screen_to_world(self, screen_pos) -> Vec2:
|
|
41
|
+
if not isinstance(screen_pos, Vec2):
|
|
42
|
+
screen_pos = Vec2(screen_pos[0], screen_pos[1])
|
|
43
|
+
center = Vec2(self.width / 2, self.height / 2)
|
|
44
|
+
return (screen_pos - center) / self.scale + self.pos
|
kaplay/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The `kaypy` command.
|
|
2
|
+
|
|
3
|
+
kaypy new mygame make a folder with a working game and its assets
|
|
4
|
+
kaypy web game.py build that game as a web page and serve it
|
|
5
|
+
|
|
6
|
+
Both exist because a pip install has no repo to stand in. `kaypy new` is
|
|
7
|
+
what gets a student from `pip install kaypy` to something moving on
|
|
8
|
+
screen without hunting for sprite files, and `kaypy web` is the same
|
|
9
|
+
build tool the checkout calls `webbuild.py`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import argparse
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
STARTER = Path(__file__).resolve().parent / "starter"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cmd_new(args) -> int:
|
|
21
|
+
dest = Path(args.name).resolve()
|
|
22
|
+
if dest.exists() and any(dest.iterdir()):
|
|
23
|
+
print(f"{dest} already exists and isn't empty — pick another name, "
|
|
24
|
+
f"or delete it first.")
|
|
25
|
+
return 1
|
|
26
|
+
if not STARTER.is_dir():
|
|
27
|
+
print("this kaypy install has no starter files in it, which shouldn't "
|
|
28
|
+
"happen — reinstall, or copy a game from the project's examples/.")
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
shutil.copytree(
|
|
32
|
+
STARTER, dest, dirs_exist_ok=True,
|
|
33
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
here = Path.cwd()
|
|
37
|
+
try:
|
|
38
|
+
shown = dest.relative_to(here)
|
|
39
|
+
except ValueError:
|
|
40
|
+
shown = dest
|
|
41
|
+
|
|
42
|
+
print(f"Created {shown}/ — a game, and the sprites and sounds to build on.\n")
|
|
43
|
+
print("Try it:\n")
|
|
44
|
+
print(f" cd {shown}")
|
|
45
|
+
print(" python game.py\n")
|
|
46
|
+
print("Arrow keys to move, space to jump. Then open game.py and change it.")
|
|
47
|
+
print(f"To put it on the web instead: kaypy web game.py")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_web(args) -> int:
|
|
52
|
+
from . import webbuild
|
|
53
|
+
# webbuild parses its own arguments, so hand it the ones meant for it.
|
|
54
|
+
sys.argv = ["kaypy web"] + args.rest
|
|
55
|
+
webbuild.main()
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv=None) -> int:
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
prog="kaypy",
|
|
62
|
+
description="kaypy — a real-Python game engine with Kaplay's API.",
|
|
63
|
+
)
|
|
64
|
+
sub = parser.add_subparsers(dest="command")
|
|
65
|
+
|
|
66
|
+
p_new = sub.add_parser("new", help="create a new game folder, ready to run")
|
|
67
|
+
p_new.add_argument("name", help="folder to create, e.g. mygame")
|
|
68
|
+
p_new.set_defaults(func=cmd_new)
|
|
69
|
+
|
|
70
|
+
p_web = sub.add_parser(
|
|
71
|
+
"web", help="build a game as a web page and serve it",
|
|
72
|
+
add_help=False,
|
|
73
|
+
)
|
|
74
|
+
p_web.add_argument("rest", nargs=argparse.REMAINDER,
|
|
75
|
+
help="the game file, plus any webbuild options")
|
|
76
|
+
p_web.set_defaults(func=cmd_web)
|
|
77
|
+
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
if not getattr(args, "command", None):
|
|
80
|
+
parser.print_help()
|
|
81
|
+
return 1
|
|
82
|
+
return args.func(args)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
kaplay/comps/__init__.py
ADDED
|
File without changes
|
kaplay/comps/area.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""area(): the collision box. Both objects in a collision need one —
|
|
2
|
+
an object without area() is invisible to collision, and that's the
|
|
3
|
+
single most common reason a collision "doesn't work"."""
|
|
4
|
+
from ..gameobj import Comp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AreaComp(Comp):
|
|
8
|
+
id = "area"
|
|
9
|
+
|
|
10
|
+
def add(self, obj):
|
|
11
|
+
self._obj = obj
|
|
12
|
+
|
|
13
|
+
def get_rect(self):
|
|
14
|
+
from ..geometry import get_world_rect
|
|
15
|
+
return get_world_rect(self._obj)
|
|
16
|
+
|
|
17
|
+
def isHovering(self) -> bool:
|
|
18
|
+
from ..engine import current_engine
|
|
19
|
+
mx, my = current_engine().mousePos()
|
|
20
|
+
return self.get_rect().collidepoint(mx, my)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def area():
|
|
24
|
+
return AreaComp()
|
kaplay/comps/body.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""body(): makes an object respond to physics. isStatic=True makes it a
|
|
2
|
+
wall — solid, and never moved by anything. mass=n makes it heavy but
|
|
3
|
+
still pushable. Gives .jump(), .isGrounded(), .onGround()."""
|
|
4
|
+
from ..gameobj import Comp
|
|
5
|
+
from ..vec2 import Vec2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BodyComp(Comp):
|
|
9
|
+
id = "body"
|
|
10
|
+
|
|
11
|
+
def __init__(self, isStatic=False, mass=1, jumpForce=800):
|
|
12
|
+
self.isStatic = isStatic
|
|
13
|
+
self.mass = mass
|
|
14
|
+
self.jumpForce = jumpForce
|
|
15
|
+
self.vel = Vec2(0, 0)
|
|
16
|
+
self._grounded = False
|
|
17
|
+
self._pending_grounded = False
|
|
18
|
+
self._ground_handlers = []
|
|
19
|
+
|
|
20
|
+
def add(self, obj):
|
|
21
|
+
self._obj = obj
|
|
22
|
+
|
|
23
|
+
def jump(self, force=None):
|
|
24
|
+
if force is None:
|
|
25
|
+
force = self.jumpForce
|
|
26
|
+
self.vel.y = -force
|
|
27
|
+
self._grounded = False
|
|
28
|
+
|
|
29
|
+
def isGrounded(self) -> bool:
|
|
30
|
+
return self._grounded
|
|
31
|
+
|
|
32
|
+
def onGround(self, fn=None):
|
|
33
|
+
from ..callutil import register_or_decorate
|
|
34
|
+
|
|
35
|
+
def register(f):
|
|
36
|
+
self._ground_handlers.append(f)
|
|
37
|
+
return f
|
|
38
|
+
|
|
39
|
+
return register_or_decorate(fn, register)
|
|
40
|
+
|
|
41
|
+
def _apply_grounded(self, value: bool):
|
|
42
|
+
was = self._grounded
|
|
43
|
+
self._grounded = value
|
|
44
|
+
if value and not was:
|
|
45
|
+
for fn in self._ground_handlers:
|
|
46
|
+
fn()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def body(isStatic=False, mass=1, jumpForce=800):
|
|
50
|
+
return BodyComp(isStatic=isStatic, mass=mass, jumpForce=jumpForce)
|
kaplay/comps/move.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""move(dir, speed): drifts an object in a straight line forever.
|
|
2
|
+
offscreen(destroy=True): cleans it up once it leaves the screen.
|
|
3
|
+
tile(isObstacle=): marks an object as occupying a square on a grid."""
|
|
4
|
+
from ..gameobj import Comp
|
|
5
|
+
from ..vec2 import vec2, Vec2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MoveComp(Comp):
|
|
9
|
+
id = "move"
|
|
10
|
+
|
|
11
|
+
def __init__(self, direction, speed):
|
|
12
|
+
if isinstance(direction, Vec2):
|
|
13
|
+
self.direction = direction.unit()
|
|
14
|
+
else:
|
|
15
|
+
self.direction = vec2(*direction).unit()
|
|
16
|
+
self.speed = speed
|
|
17
|
+
|
|
18
|
+
def add(self, obj):
|
|
19
|
+
self._obj = obj
|
|
20
|
+
|
|
21
|
+
def update(self, obj):
|
|
22
|
+
if obj.has("pos"):
|
|
23
|
+
obj.comp("pos").move(self.direction * self.speed)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def move(direction, speed):
|
|
27
|
+
return MoveComp(direction, speed)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OffscreenComp(Comp):
|
|
31
|
+
id = "offscreen"
|
|
32
|
+
|
|
33
|
+
def __init__(self, destroy=False, distance=64):
|
|
34
|
+
self.destroy = destroy
|
|
35
|
+
self.distance = distance
|
|
36
|
+
|
|
37
|
+
def add(self, obj):
|
|
38
|
+
self._obj = obj
|
|
39
|
+
|
|
40
|
+
def update(self, obj):
|
|
41
|
+
from ..engine import current_engine
|
|
42
|
+
from ..geometry import get_world_pos
|
|
43
|
+
engine = current_engine()
|
|
44
|
+
p = get_world_pos(obj)
|
|
45
|
+
d = self.distance
|
|
46
|
+
if (p.x < -d or p.x > engine.width() + d or
|
|
47
|
+
p.y < -d or p.y > engine.height() + d):
|
|
48
|
+
if self.destroy:
|
|
49
|
+
obj.destroy()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def offscreen(destroy=False, distance=64):
|
|
53
|
+
return OffscreenComp(destroy=destroy, distance=distance)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TileComp(Comp):
|
|
57
|
+
id = "tile"
|
|
58
|
+
|
|
59
|
+
def __init__(self, isObstacle=False):
|
|
60
|
+
self.isObstacle = isObstacle
|
|
61
|
+
|
|
62
|
+
def add(self, obj):
|
|
63
|
+
self._obj = obj
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tile(isObstacle=False):
|
|
67
|
+
return TileComp(isObstacle=isObstacle)
|