-
Notifications
You must be signed in to change notification settings - Fork 0
/
asteroids.py
1954 lines (1543 loc) · 90.1 KB
/
asteroids.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
# Fremde Imports
import json
import math
import pygame
import random
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Callable
# Eigene Imports
import sounds
# Klassen
# Diese Klasse repräsentiert einen Vektor aus zwei Koordinaten:
class Vector:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
self.tup = (x, y) # ein Tupel aus beiden Koordinaten
# Diese Methode addiert einen Vektor zu diesem und gibt das Ergebnis als neuen Vektor zurück:
def add(self, v: Vector) -> Vector:
return Vector(self.x + v.x, self.y + v.y)
# Diese Methode subtrahiert einen Vektor von diesem Vektor und gibt das Ergebnis als neuen Vektor zurück:
def sub(self, v: Vector) -> Vector:
return Vector(self.x - v.x, self.y - v.y)
# Diese Methode multipliziert diesen Vektor mit einem Faktor und gibt das Ergebnis als neuen Vektor zurück:
def mult(self, f: float) -> Vector:
return Vector(self.x * f, self.y * f)
# Diese Methode setzt die Magnitüde dieses Vektors und gibt das Ergebnis als neuen Vektor zurück:
def set_mag(self, mag: float) -> Vector:
prev_mag = self.mag()
x = self.x * mag / prev_mag # Diese Rechnung skaliert den Vektor so, dass seine Magnitüde den übergebenen Wert beträgt
y = self.y * mag / prev_mag
return Vector(x, y)
# Diese Methode reduziert die Magnitüde auf einen Maximalwert, wenn sie diesen überschreitet, und gibt das Ergebnis als neuen
# Vektor zurück:
def limit(self, max_mag: float) -> Vector:
mag = self.mag()
if mag > max_mag: # Wenn die maximale Magnitüde überschritten wird
return self.set_mag(max_mag)
else:
return Vector(self.x, self.y)
# Diese Methode gibt die Magnitüde dieses Vektors zurück:
def mag(self) -> float:
return math.sqrt(self.x ** 2 + self.y ** 2) # Satz des Pythagoras
# Diese Methode gibt die Entfernung dieses Vektors zu einem anderen zurück:
def distance(self, other: Vector) -> float:
return math.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
# Diese Methode gibt ein Dictionary mit den Werten dieses Vektors zurück:
def to_dict(self) -> dict[str, float]:
return {
'x': self.x,
'y': self.y
}
# Diese Klasse repräsentiert eine Polarkoordinate aus einem Winkel (Theta) und einem Radius:
class PolarCoordinate:
def __init__(self, theta: float, radius: float) -> None:
self.theta = theta
self.radius = radius
# Diese Methode gibt den dieser Polarkoordinate entsprechenden kartesischen Vektor zurück:
def cartesian(self) -> Vector:
x = self.radius * math.cos(self.theta)
y = self.radius * math.sin(self.theta)
return Vector(x, y)
# Diese Methode gibt ein Dictionary mit den Werten dieser Polarkoordinate zurück:
def to_dict(self) -> dict[str, float]:
return {
'theta': self.theta,
'radius': self.radius
}
# Diese Klasse repräsentiert ein Polygon mit eine Mitte und Polarkoordinaten:
class Polygon:
def __init__(self, center: Vector, polar_coordinates: tuple[PolarCoordinate], stroke_weight: int, visible: bool) -> None:
self.center = center # die Mitte des Polygons
self.polar_coordinates = polar_coordinates # die Polarkoordinaten des Polygons
self.stroke_weight = stroke_weight # die Dicke der Umrandung
self.visible = visible # ob das Polygon sichtbar ist
# Diese Methode gibt ein Tupel aus den kartesischen Koordinaten dieses Polygons zurück:
def cartesian(self) -> tuple[Vector]:
# jede Koordinate in eine kartesische Koordinate umrechnen, in einem Tupel speichern und zurückgeben:
return tuple(c.cartesian().add(self.center) for c in self.polar_coordinates)
# Diese Methode gibt zurück, ob ein Vektor in diesem Polygon liegt:
def vector_in(self, vector: Vector) -> bool:
cartesian = self.cartesian()
if len(cartesian) < 3:
return False
count = 0
for i in range(len(cartesian)):
v1 = cartesian[i]
v2 = cartesian[(i + 1) % len(cartesian)]
if ((v1.y > vector.y) != (v2.y > vector.y)) and (vector.x < (v2.x - v1.x) * (vector.y - v1.y) / (v2.y - v1.y) + v1.x):
count += 1
return count % 2 == 1
# Diese Methode gibt zurück, ob dieses Polygon mit einem anderen Polygon kollidiert:
def collides_with_polygon(self, polygon: Polygon) -> bool:
# prüfen, ob ein Vektor des anderen Polygons in diesem Polygon liegt:
for v in polygon.cartesian():
if self.vector_in(v):
return True
# prüfen, ob ein Vektor dieses Polygons im anderen Polygon liegt:
for v in self.cartesian():
if polygon.vector_in(v):
return True
return False # Wenn keine Kollision erkannt wurde, „falsch“ zurückgeben
# Diese Methode rotiert dieses Polygon:
def rotate(self, angle: float) -> None:
for c in self.polar_coordinates: # den Winkel zu den Winkeln aller Polarkoordinaten addieren
c.theta += angle
# Diese Methode bewegt dieses Polygon:
def move(self, vector: Vector) -> None:
self.center = self.center.add(vector) # den Vektor zur Mitte dieses Polygons addieren
# Diese Methode rendert dieses Polygon:
def render(self, screen: pygame.Surface) -> None:
if self.visible: # wenn dieses Polygon sichtbar ist
vectors = self.cartesian()
for i in range(len(vectors)):
pygame.draw.line(screen, Color.WHITE, vectors[i].tup, vectors[(i + 1) % len(vectors)].tup, self.stroke_weight)
# Diese Methode gibt ein Dictionary mit den Werten dieses Polygons zurück:
def to_dict(self) -> dict[str, object]:
return {
'center': self.center.to_dict(),
'polar_coordinates': [c.to_dict() for c in self.polar_coordinates],
'stroke_weight': self.stroke_weight,
'visible': self.visible
}
# TODO kommentieren
# Diese Klasse repräsentiert das Raumschiff des Spielers:
class Player:
def __init__(self, body: Polygon | None, thrust: Polygon | None, turning_angle: float, mot: Vector | None, ticks: int) -> None:
# der Körper des Spielers:
self.body = body
if body is None:
self.body = Polygon(center=CENTER, polar_coordinates=(
PolarCoordinate(-math.pi / 2, 20),
PolarCoordinate(2 * math.pi * 0.14, 20),
PolarCoordinate(2 * math.pi * 0.16, 12),
PolarCoordinate(2 * math.pi * 0.34, 12),
PolarCoordinate(2 * math.pi * 0.36, 20)
), stroke_weight=2, visible=True)
# der Schub:
self.thrust = thrust
if thrust is None:
self.thrust = Polygon(center=CENTER, polar_coordinates=(
PolarCoordinate(2 * math.pi * 0.2, 15),
PolarCoordinate(2 * math.pi * 0.25, 22),
PolarCoordinate(2 * math.pi * 0.3, 15)
), stroke_weight=2, visible=False)
# der Rest:
self.turning_angle = turning_angle
self.mot = Vector(0.0, 0.0) if mot is None else mot
self.ticks = ticks # wie viele Ticks der Spieler schon lebt
# Diese Methode dreht den Spieler leicht nach links:
def turn_left(self) -> None:
# 0,01 vom Drehungswinkel abziehen und das Minimum setzen
self.turning_angle = max(self.turning_angle - 0.01, -MAX_TURNING_SPEED)
# Diese Methode dreht den Spieler leicht nach rechts:
def turn_right(self) -> None:
# 0,01 zum Drehungswinkel addieren und das Maximum setzen
self.turning_angle = min(self.turning_angle + 0.01, MAX_TURNING_SPEED)
# Diese Methode aktualisiert die Bewegung dieses Spielers:
def foreward(self) -> None:
acc = PolarCoordinate(self.body.polar_coordinates[0].theta, 0.5).cartesian() # das, was zur Bewegung hinzukommt
self.mot = self.mot.add(acc)
self.mot = self.mot.limit(MAX_SPEED) # die Bewegung begrenzen
# Diese Methode kümmert sich um die Ränder. Wenn der Spieler an den Seiten das Fenster verlässt, wird er auf die andere Seite
# teleportiert:
def edges(self) -> None:
if self.body.center.x < -30:
new_x = self.body.center.x + WIDTH + 60
self.body.center.x = new_x
self.thrust.center.x = new_x
elif self.body.center.x > WIDTH + 30:
new_x = self.body.center.x - WIDTH - 60
self.body.center.x = new_x
self.thrust.center.x = new_x
if self.body.center.y < -30:
new_y = self.body.center.y + HEIGHT + 60
self.body.center.y = new_y
self.thrust.center.y = new_y
elif self.body.center.y > HEIGHT + 30:
new_y = self.body.center.y - HEIGHT - 60
self.body.center.y = new_y
self.thrust.center.y = new_y
# Diese Methode wird einmal pro Tick aufgerufen und aktualisiert die Position und die Drehung des Spielers:
def update(self) -> None:
self.turning_angle *= TURNING_FRICTION # die Drehung mit Reibung abbremsen
self.mot = self.mot.limit(self.mot.mag() * FRICTION) # die Bewegung abbremsen
# wenn der Spieler die Taste W oder die Pfeiltaste nach oben drückt:
if pygame.key.get_pressed()[pygame.K_w] or pygame.key.get_pressed()[pygame.K_UP]:
self.foreward() # Vorwärts!
self.thrust.visible = True # den Schub sichtbar machen
else:
self.thrust.visible = False # den Schub unsichtbar machen
# wenn die Taste A oder die Pfeiltaste nach links gedrückt sind:
if pygame.key.get_pressed()[pygame.K_a] or pygame.key.get_pressed()[pygame.K_LEFT]:
# wenn weder die Taste D noch die Pfeiltaste nach rechts gedrückt ist:
if not (pygame.key.get_pressed()[pygame.K_d] or pygame.key.get_pressed()[pygame.K_RIGHT]):
self.turn_left()
# wenn weder die Taste A noch die Pfeiltaste nach links gedrückt ist, dafür aber die Taste D oder die
# Pfeiltaste nach rechts:
elif pygame.key.get_pressed()[pygame.K_d] or pygame.key.get_pressed()[pygame.K_RIGHT]:
self.turn_right()
# den Körper und den Schub des Raumschiffs drehen:
self.body.rotate(self.turning_angle)
self.thrust.rotate(self.turning_angle)
# den Körper und den Schub des Raumschiffs bewegen:
self.body.center = self.body.center.add(self.mot)
self.thrust.center = self.thrust.center.add(self.mot)
self.edges() # siehe Kommentar über der Funktion edges()
self.ticks += 1
# Diese Methode rendert diesen Spieler:
def render(self, screen: pygame.Surface) -> None:
# Wenn der Spieler erst kürzer als die Unbesiegbarkeitszeit existiert, wird er blinkend angezeigt:
if self.ticks >= INVINCIBILITY_TIME or self.ticks % (FPS // 3) in range(FPS // 6):
self.body.render(screen)
if self.ticks % (FPS // 10) in range(FPS // 20): # auch der Schub wird (schneller) blinkend angezeigt
self.thrust.render(screen)
# Diese Funktion gibt ein Dictionary mit den Werten dieses Spielers zurück:
def to_dict(self) -> dict[str, object]:
return {
'body': self.body.to_dict(),
'thrust': self.thrust.to_dict(),
'turning_angle': self.turning_angle,
'motion': self.mot.to_dict(),
'ticks': self.ticks
}
# Dies ist eine Klasse für Größen von Asteroiden:
@dataclass
class AsteroidSize:
avg_radius: float # der durchschnittliche Radius (die durchschnittliche Entfernung der Koordinaten von der Mitte)
min_speed: float
max_speed: float # die Geschwindigkeit soll ein Wert zwischen min_speed und max_speed werden
stroke_weight: int # die Dicke der Umrandung
points: int # die Anzahl von Punkten, die man für das Zerstören erhält
index: int # der Index dieser Asteroidgröße
# Diese Klasse repräsentiert einen Asteroiden:
class Asteroid:
def __init__(self, size: AsteroidSize, body: Polygon, mot_angle: float, mot: Vector, rot: float, hit_by: int) -> None:
self.size = size
self.body = body
self.mot_angle = mot_angle
self.mot = mot
self.rot = rot
self.hit_by = hit_by
# Diese Methode wird einmal pro Tick aufgerufen und aktualisiert die Position und die Drehung dieses Asteroiden:
def update(self) -> None:
self.body.move(self.mot)
self.body.rotate(self.rot)
# Diese Methode prüft, ob dieser Asteroid von einer Kugel getroffen wird:
def check_hit(self, bullets: list[Bullet], by: int) -> None:
for b in bullets:
if self.body.vector_in(b.pos): # wenn dieser Asteroid von der Kugel getroffen wird
self.hit_by = by
bullets.remove(b) # die Kugel aus der Liste entfernen
break # Es ist nicht weiter nötig zu prüfen, ob dieser Asteroid getroffen wird
# Diese Methode rendert diesen Asteroiden:
def render(self, screen: pygame.Surface) -> None:
self.body.render(screen)
# Diese Methode prüft, ob dieser Asteroid aus dem Fenster verschwunden ist:
def offscreen(self) -> bool:
return self.body.center.distance(CENTER) > ASTEROID_DESPAWN_DISTANCE
# Diese Methode gibt ein Dictionary aus den Werten dieses Asteroiden zurück:
def to_dict(self) -> dict[str, object]:
return {
'size': self.size.index,
'body': self.body.to_dict(),
'motion_angle': self.mot_angle,
'motion': self.mot.to_dict(),
'rotation': self.rot,
'hit_by': self.hit_by
}
# Eine Klasse für Größen von fliegenden Untertassen:
@dataclass
class SaucerSize:
def __init__(self, radius: float, stroke_weight: int, min_speed: float, max_speed: float, points: int, aim: float,
shoot_probability: float, bullet_lifetime: int, sound: int, index: int) -> None:
self.radius = radius
self.stroke_weight = stroke_weight # die Dicke der Umrandung
self.min_speed = min_speed
self.max_speed = max_speed # Es wird ein zufälliger Wert zwischen min_speed und max_speed als Geschwindigkeit gesetzt
self.points = points # die Anzahl von Punkten, die man bekommt, wenn man die fliegende Untertasse zerstört
self.aim = aim # die Zielgenauigkeit
self.shoot_probability = shoot_probability / FPS # die Wahrscheinlichkeit, dass die fliegende Untertasse eine Kugel abschießt
self.bullet_lifetime = int(bullet_lifetime * FPS) # die Lebenszeit einer Kugel der fliegenden Untertasse
self.sound = sound # das Geräusch, das immer wieder abgespielt wird, wenn eine fliegende Untertasse der Größe im Fenster ist
self.index = index
# Diese Klasse repräsentiert eine fliegende Untertasse
class Saucer:
def __init__(self, size: SaucerSize, body: Polygon, mot: Vector, speed: float, steps: int, ticks: int, hit_by: int) -> None:
self.size = size
self.body = body
self.mot = mot
self.speed = speed
self.steps = steps
self.ticks = ticks
self.hit_by = hit_by
# Diese Methode gibt zurück, ob diese fliegende Untertasse noch im Fenster ist:
def on_screen(self) -> bool:
if self.body.center.x < -self.size.radius:
return False
if self.body.center.x > WIDTH + self.size.radius:
return False
if self.body.center.y < -self.size.radius:
return False
if self.body.center.y > HEIGHT + self.size.radius:
return False
return True
# Diese Methode wird bei jedem Tick aufgerufen und aktualisiert diese fliegende Untertasse:
def update(self) -> None:
self.body.move(self.mot)
if self.ticks == self.steps: # Wenn die fliegende Untertasse an ihrem Ziel angekommen ist, neues Ziel anvisieren
self.ticks = -1
self.steps = random.randint(SAUCER_MIN_STEPS, SAUCER_MAX_STEPS)
self.mot = PolarCoordinate(random.random() * 2 * math.pi, self.speed).cartesian()
self.ticks += 1
# Diese Methode prüft, ob diese fliegende Untertasse von einer Kugel getroffen worden ist:
def check_hit(self, bullets: list[Bullet], by: int) -> None:
for b in bullets:
if self.body.vector_in(b.pos):
self.hit_by = by
bullets.remove(b)
break
# Diese Methode rendert diese fliegende Untertasse:
def render(self, screen: pygame.Surface) -> None:
self.body.render(screen) # die Umrandung anzeigen
# die zwei Linien, die von links nach rechts gezeichnet werden:
l1a = self.body.polar_coordinates[1].cartesian().add(self.body.center).tup
l1b = self.body.polar_coordinates[6].cartesian().add(self.body.center).tup
l2a = self.body.polar_coordinates[2].cartesian().add(self.body.center).tup
l2b = self.body.polar_coordinates[5].cartesian().add(self.body.center).tup
# die zwei Linien zeichnen:
pygame.draw.line(screen, Color.WHITE, l1a, l1b, self.size.stroke_weight)
pygame.draw.line(screen, Color.WHITE, l2a, l2b, self.size.stroke_weight)
# Diese Methode gibt ein Dictionary mit den Werten dieser fliegenden Untertasse zurück:
def to_dict(self) -> dict[str, object]:
return {
'size': self.size.index,
'body': self.body.to_dict(),
'motion': self.mot.to_dict(),
'speed': self.speed,
'steps': self.steps,
'ticks': self.ticks,
'hit_by': self.hit_by
}
# Diese Klasse repräsentiert eine Kugel, die vom Spieler abgeschossen wurde. Die Klasse SaucerBullet, die eine Kugel repräsentiert,
# die von einer fliegenden Untertasse abgeschossen wurde, erbt von dieser Klasse:
class Bullet:
def __init__(self, pos: Vector, mot: Vector) -> None:
self.pos = pos # Position
self.mot = mot # Bewegung (motion)
# Diese Methode wird einmal pro Tick aufgerufen und aktualisiert diese Kugel:
def update(self) -> None:
self.pos = self.pos.add(self.mot) # die Bewegung zur Position addieren
# Diese Methode rendert diese Kugel:
def render(self, screen: pygame.Surface) -> None:
pygame.draw.circle(screen, Color.WHITE, self.pos.tup, BULLET_RADIUS) # die Kugel auf das Fenster zeichnen
# Diese Methode gibt zurück, ob diese Kugel zu entfernen ist:
def to_remove(self) -> bool:
if self.pos.x < -30: # über den linken Fensterrand hinaus
return True
if self.pos.x > WIDTH + 30: # über den rechten Fensterrand hinaus
return True
if self.pos.y < -30: # über den oberen Bildschirmrand hinaus
return True
if self.pos.y > HEIGHT + 30: # über den unteren Bildschirmrand hinaus
return True
return False # noch auf dem Fenster oder nicht zu weit vom Fensterrand entfernt
# Diese Methode gibt ein Dictionary mit den Werten dieser Kugel zurück:
def to_dict(self) -> dict[str, object]:
return {
'position': self.pos.to_dict(),
'motion': self.mot.to_dict()
}
# Diese Klasse repräsentiert eine Kugel, die von einer fliegenden Untertasse abgeschossen wurde:
class SaucerBullet(Bullet):
def __init__(self, pos: Vector, mot: Vector, lifetime: int, ticks: int) -> None:
super().__init__(pos, mot)
self.lifetime = lifetime # wie viele Ticks die Kugel existiert
self.ticks = ticks
# Diese Methode wird einmal pro Tick aufgerufen und aktualisiert diese von einer fligenden Untertasse abgeschossene Kugel:
def update(self) -> None:
super().update()
self.ticks += 1
# Diese Methode gibt zurück, ob diese von einer fliegenden Untertasse abgeschossene Kugel zu entfernen ist:
def to_remove(self) -> bool:
# Rückgabe: ob entweder die Ticks höher sind als die Lebenszeit der Kugel oder to_remove() in der Superklasse wahr ist:
return self.ticks >= self.lifetime or super().to_remove()
# Diese Methode gibt ein Dictionary mit den Werten dieser von einer fligenden Untertasse abgeschossenen Kugel zurück:
def to_dict(self) -> dict[str, object]:
return {
'position': self.pos.to_dict(),
'motion': self.mot.to_dict(),
'lifetime': self.lifetime,
'ticks': self.ticks
}
# Diese Klasse repräsentiert ein Fragment. Ein Fragment entsteht, wenn ein Spieler oder eine fliegende Untertasse zerstört wird:
class Fragment:
def __init__(self, lines: list[Line], ticks: int) -> str:
self.lines = lines
self.ticks = ticks
# Diese Methode wird einmal pro Tick aufgerufen und aktualisiert dieses Fragment:
def update(self) -> None:
for l in self.lines:
l.update() # alle Linien aktualisieren
self.ticks += 1
# Diese Methode rendert dieses Fragment:
def render(self, screen: pygame.Surface) -> None:
for l in self.lines: # alle Linien rendern
l.render(screen)
# Diese Methode gibt ein Dictionary mit den Werten dieses Fragments zurück:
def to_dict(self) -> dict[str, object]:
return {
'lines': [l.to_dict() for l in self.lines],
'ticks': self.ticks
}
# Diese Klasse repräsentiert eine Linie mit einem Mittelpunkt, zwei Polarkoordinaten, einer Bewegung und einer Rotation:
class Line:
def __init__(self, center: Vector, center_to_a: PolarCoordinate, center_to_b: PolarCoordinate, mot: Vector, rot: float,
stroke_weight: int) -> None:
self.center = center # der Mittelpunkt
self.center_to_a = center_to_a # eine Polarkoordinate: vom Mittelpunkt zum Punkt A
self.center_to_b = center_to_b # eine Polarkoordinate: vom Mittelpunkt zum Punkt B
self.mot = mot # Bewegung (motion)
self.rot = rot # Rotation
self.stroke_weight = stroke_weight # die Dicke der Linie
# Diese Methode aktualisiert diese Linie:
def update(self) -> None:
# die Rotation zu den Theta-Winkeln der beiden Polarkoordinaten addieren:
self.center_to_a.theta += self.rot
self.center_to_b.theta += self.rot
self.center = self.center.add(self.mot) # bewegen
self.rot *= FRICTION # die Rotation abbremsen
self.mot = self.mot.mult(FRICTION) # die Bewegung abbremsen
# Diese Methode rendert diese Linie:
def render(self, screen: pygame.Surface) -> None:
a = self.center_to_a.cartesian().add(self.center).tup # der Punkt A als Tupel
b = self.center_to_b.cartesian().add(self.center).tup # der Punkt B als Tupel
pygame.draw.line(screen, Color.WHITE, a, b, self.stroke_weight)
# Diese Methode gibt ein Dictionary mit den Werten dieser Linie zurück:
def to_dict(self) -> dict[str, object]:
return {
'center': self.center.to_dict(),
'center_to_a': self.center_to_a.to_dict(),
'center_to_b': self.center_to_b.to_dict(),
'motion': self.mot.to_dict(),
'rotation': self.rot,
'stroke_weight': self.stroke_weight
}
# Diese Klasse repräsentiert eine Explosion aus mehreren Partikeln, die beim Sprengen eines kleinen Asteroiden entsteht:
class Explosion:
def __init__(self, particles: list[Particle]) -> None:
self.particles = particles
# Diese Methode aktualisiert diese Explosion:
def update(self) -> None:
for p in self.particles:
p.update() # alle Partikel aktualisieren
# Diese Methode rendert diese Explosion:
def render(self, screen: pygame.Surface) -> None:
for p in self.particles:
p.render(screen) # alle Partikel rendern
# Diese Methode gibt zurück, ob diese Explosion zu entfernen ist:
def to_remove(self) -> bool:
return False not in (p.to_remove() for p in self.particles) # wahr, wenn alle Partikel zu entfernen sind
# Diese Methode gibt ein Dictionary mit den Werten dieser Explosion zurück:
def to_dict(self) -> dict[str, object]:
return {
'particles': [p.to_dict() for p in self.particles]
}
# Diese Klasse repräsentiert ein Partikel, aus dem Explosionen bestehen
class Particle:
def __init__(self, pos: Vector, mot: Vector, ticks: int, lifetime: int) -> None:
self.pos = pos
self.mot = mot
self.ticks = ticks
self.lifetime = lifetime
# Diese Funktion wird einmal pro Tick aufgerufen und aktualisiert dieses Partikel:
def update(self) -> None:
self.pos = self.pos.add(self.mot) # bewegen
self.ticks += 1
# Diese Funktion rendert dieses Partikel:
def render(self, screen: pygame.Surface) -> None:
if self.ticks < self.lifetime: # wenn dieses Partikel noch angezeigt werden soll
pygame.draw.circle(screen, Color.WHITE, self.pos.tup, 2) # dieses Partikel als kleinen Kreis auf das Fenster zeichnen
# Diese Methode gibt zurück, ob dieses Partikel zu entfernen ist:
def to_remove(self) -> bool:
return self.ticks >= self.lifetime
# Diese Methode gibt ein Dictionary mit den Werten dieses Partikels zurück:
def to_dict(self) -> dict[str, object]:
return {
'position': self.pos.to_dict(),
'motion': self.mot.to_dict(),
'ticks': self.ticks,
'lifetime': self.lifetime
}
# Diese Klasse repräsentiert ein Menü, durch das der Spieler navigieren kann:
class Menu:
def __init__(self, title: str, text: str | None, transparent: bool, parent: Menu | None, button_data: tuple[ButtonData, ...]) -> None:
self.title_surface = TITLE_FONT.render(title, True, Color.WHITE) # die Textoberfläche für den Titel rendern
self.title_pos = ((WIDTH - self.title_surface.get_width()) / 2, 100) # die Position des Titels
self.set_text(text)
self.transparent = transparent
self.parent = parent
# den Hintergrund (der transparent ist oder nicht) erstellen
self.background = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
pygame.draw.rect(self.background, Color.BLACK_ALPHA if transparent else Color.BLACK, self.background.get_rect())
# Die Buttons konstruieren:
start_y = 225 if text is None else self.text_positions[-1].y + self.text_surfaces[-1].get_height() + 50
self.buttons = tuple([Button(d.text, start_y + i * 50, d.active, d.on_action) for (i, d) in enumerate(button_data)])
self.select_top_button()
def set_text(self, text: str | None, update_buttons_y: bool = False) -> None:
if text is None:
self.text_surfaces = ()
self.text_positions = ()
else:
# die Textoberflächen der Buttons:
self.text_surfaces = tuple(TEXT_FONT.render(line, True, Color.WHITE) for line in text.split('\n'))
# die Positionen der Buttons:
self.text_positions = tuple(Vector((WIDTH - s.get_width()) / 2, 205 + i * s.get_height())
for (i, s) in enumerate(self.text_surfaces))
if update_buttons_y:
# die y-Koordinaten aller Buttons aktualisieren:
for (i, b) in enumerate(self.buttons):
b.y = self.text_positions[-1].y + self.text_surfaces[-1].get_height() + 50 + i * 50
# Diese Funktion wählt alle Buttons dieses Menüs ab:
def deselect_all_buttons(self) -> None:
for b in self.buttons:
b.selected = False # alle Buttons abwählen
# Diese Funktion wählt den obersten aktiven Button dieses Menüs aus:
def select_top_button(self) -> None:
self.deselect_all_buttons() # alle Buttons abwählen
for b in self.buttons:
if b.active: # den obersten aktiven Button finden
b.selected = True
return
# Diese Funktion wird aufgerufen, wenn der Spieler in diesem Menü die Entertaste drückt:
def action(self) -> None:
for b in self.buttons:
if b.selected: # den ausgewählten Button finden
b.on_action()
sounds.MENU_ACTION.play()
return
# Diese Funktion wird aufgerufen, wenn der Spieler in diesem Menü die Taste W oder die Pfeiltaste nach oben drückt.
# Sie wählt den Button über dem zuvor ausgewählten Button aus:
def select_button_above(self) -> None:
for (i, b) in enumerate(self.buttons):
if b.selected: # den ausgewählten Button finden
# der Button über dem ausgewählten Button oder None, wenn keiner darüber ist:
button_above = self.__get_button_above(i)
if button_above is not None:
b.selected = False
button_above.selected = True
sounds.MENU_SELECT.play()
return
# Diese Funktion wird aufgerufen, wenn der Spieler in diesem Menü die Taste S oder die Pfeiltaste nach unten drückt.
# Sie wählt den Button unter dem zuvor ausgewählten Button aus:
def select_button_below(self) -> None:
for (i, b) in enumerate(self.buttons):
if b.selected: # den ausgewählten Button finden
# der Button unter dem ausgewählten Button oder None, wenn keiner darunter ist:
button_below = self.__get_button_below(i)
if button_below is not None:
b.selected = False
button_below.selected = True
sounds.MENU_SELECT.play()
return
# Diese Funktion findet den Button über dem gerade ausgewählten Button:
def __get_button_above(self, index: int) -> Button | None:
if index == 0:
return None # Wenn der oberste Button ausgewählt ist, gibt es keinen Button darüber
for i in range(index - 1, -1, -1):
if self.buttons[i].active: # den ersten Button über dem gerade ausgewählten finden
return self.buttons[i] # diesen zurückgeben
return None # wenn kein Button gefunden wurde
# Diese Funktion findet den Button unter dem gerade ausgewählten Button:
def __get_button_below(self, index: int) -> Button | None:
if index == len(self.buttons) - 1:
return None # Wenn der unterste Button ausgewählt ist, gibt es keinen Button darunter
for i in range(index + 1, len(self.buttons)):
if self.buttons[i].active: # den ersten Button unter dem gerade ausgewählten finden
return self.buttons[i] # diesen zurückgeben
return None # wenn kein Button gefunden wurde
# Diese Funktion rendert dieses Menü:
def render(self, screen: pygame.Surface) -> None:
screen.blit(self.background, (0, 0)) # den Hintergrund malen
screen.blit(self.title_surface, self.title_pos) # den Titel platzieren
for (s, p) in zip(self.text_surfaces, self.text_positions):
screen.blit(s, p.tup) # alle Texte an ihren Positionen platzieren
for b in self.buttons:
b.render(screen) # alle Buttons rendern
# Diese Klasse repräsentiert einen Button in einem Menü, den man mit den Pfeiltasten auswählen kann:
class Button:
def __init__(self, text: str, y: int, active: bool, on_action: Callable) -> None:
self.text_surface = BUTTON_FONT.render(text, True, Color.WHITE) # die weiße Textoberfläche für diesen Button rendern
self.gray_text_surface = BUTTON_FONT.render(text, True, Color.GRAY) # die graue Textoberfläche für diesen Button rendern
self.x = (WIDTH - self.text_surface.get_width()) / 2 # die x-Koordinate berechnen, sodass der Button mittig angezeigt wird
self.y = y # die y-Koordinate des Buttons zuweisen
self.active = active
self.on_action = on_action
self.selected = False # anfangs ist der Button nicht ausgewählt
def render(self, screen: pygame.Surface) -> None:
# Die Textoberfläche (weiß wenn ausgewählt, sonst grau) anzeigen:
surface = self.text_surface if self.active else self.gray_text_surface
screen.blit(surface, (self.x, self.y))
# Wenn der Button ausgewählt ist, links und rechts zwei Pfeile anzeigen:
if self.selected:
# die Punkte der zwei Polygone berechnen, die die Pfeile beschreiben:
p1v1 = (self.x - 10, self.y + self.text_surface.get_height() / 2) # Polygon 1 Vektor 1
p1v2 = (self.x - 25, self.y + self.text_surface.get_height() * 0.25) # Polygon 1 Vektor 2
p1v3 = (self.x - 25, self.y + self.text_surface.get_height() * 0.75) # Polygon 1 Vektor 3
p2v1 = (self.x + self.text_surface.get_width() + 10, p1v1[1]) # Polygon 2 Vektor 1
p2v2 = (self.x + self.text_surface.get_width() + 25, p1v2[1]) # Polygon 2 Vektor 2
p2v3 = (self.x + self.text_surface.get_width() + 25, p1v3[1]) # Polygon 2 Vektor 3
# die Polygone auf das Fenster zeichnen:
pygame.draw.line(screen, Color.WHITE, p1v1, p1v2)
pygame.draw.line(screen, Color.WHITE, p1v1, p1v3)
pygame.draw.line(screen, Color.WHITE, p2v1, p2v2)
pygame.draw.line(screen, Color.WHITE, p2v1, p2v3)
# pygame.draw.polygon(screen, Color.WHITE, (p1v1, p1v2, p1v3))
# pygame.draw.polygon(screen, Color.WHITE, (p2v1, p2v2, p2v3))
# Diese Datenklasse repräsentiert einen Highscore:
@dataclass
class HighScore:
score: int # die Punktzahl
timestamp: int # der Zeitstempel, wann der Highscore erreicht wurde
# Diese Methode gibt ein Dictionary mit den Werten des Highscores zurück:
def to_dict(self) -> dict[str, int]:
return {
'score': self.score,
'timestamp': self.timestamp
}
# Diese Klasse enthält einige Farben:
class Color:
BLACK = (0, 0, 0) # Schwarz
WHITE = (255, 255, 255) # Weiß
GRAY = (80, 80, 80) # Grau
RED = (255, 0, 0) # Rot
GREEN = (0, 255, 0) # Grün
ORANGE = (255, 127, 0) # Orange
BLACK_ALPHA = (0, 0, 0, 127) # Schwarz mit 50 % Deckkraft
# Dies ist eine Klasse für Schwierigkeitsstufen
class Difficulty:
def __init__(self, asteroid_spawn_chance: float, start_at_seconds: int) -> None:
# Die Wahrscheinlichkeit, dass ein Asteroid spawnt, wird durch die FPS geteilt, um sie daran anzupassen:
self.asteroid_spawn_chance = asteroid_spawn_chance / FPS
# Die Sekunden, wann die Schwierigkeit starten soll, werden mit den FPS multipliziert, um sie in Ticks umzurechnen:
self.starts_at_ticks = start_at_seconds * FPS
# Dies ist eine Datenklasse für die Daten eines Buttons:
@dataclass
class ButtonData:
text: str # der Text des Buttons
active: bool # ob der Button aktiv ist
on_action: Callable # was beim Drücken der Entertaste geschehen soll, wenn dieser Button ausgewählt ist
# Konstanten
WIDTH = 800 # die Breite des Fensters
HEIGHT = 600 # die Höhe des Fensters
SIZE = (WIDTH, HEIGHT)
CENTER = Vector(WIDTH / 2, HEIGHT / 2) # die Mitte des Fensters als Vektor
PERIMETER = WIDTH * 2 + HEIGHT * 2 # der Umfang des Fensters
# wie wahrscheinlich es sein soll, dass etwas an einer bestimmten Seite spawnt:
SIDE_PROBABILITIES = [WIDTH / PERIMETER, HEIGHT / PERIMETER, WIDTH / PERIMETER, HEIGHT / PERIMETER]
FPS = 60 # die Bildfrequenz
INVINCIBILITY_TIME = 3 * FPS # die Anzahl von Ticks, wie lange der Spieler nach einer Kollision unbesiegbar sein soll (3 Sekunden)
MAX_TURNING_SPEED = 0.1 # die maximale Drehgeschwindigkeit des Spielers
TURNING_FRICTION = 0.925 # die Reibung, die das Drehen des Spielers abbremst
MAX_SPEED = 7.5 # die maximale Geschwindigkeit des Spielers
FRICTION = 0.975 # die Reibung, die die Bewegung des Spielers abbremst
SAUCER_MIN_STEPS = FPS * 3 # wie lange eine fliegende Untertasse sich mindestens in eine Richtung bewegt
SAUCER_MAX_STEPS = FPS * 5 # wie lange eine fliegende Untertasse sich höchstens in eine Richtung bewegt
BULLET_SPEED = 10 # die Geschwindigkeit, mit der Kugeln sich bewegen
BULLET_RADIUS = 2 # der Radius von Kugeln
ASTEROID_SPAWN_DISTANCE: int # die Entfernung zur Fenstermitte, mit der Asteroiden spawnen (wird in init_constants() gesetzt)
ASTEROID_DESPAWN_DISTANCE: int # die Entfernung zur Fenstermitte, mit der Asteroiden despawnen (wird in init_constants() gesetzt)
SCORE_FONT: pygame.font.Font # die Schriftart, mit der der Punktestand angezeigt wird
HIGHSCORE_FONT: pygame.font.Font # die Schriftart, mit der der Highscore angezeigt wird
TITLE_FONT: pygame.font.Font # die Schriftart, mit der Titel angezeigt werden
TEXT_FONT: pygame.font.Font # die Schriftart, mit der Texte in Menüs angezeigt werden
BUTTON_FONT: pygame.font.Font # die Schriftart, mit der Texte in Buttons angezeigt werden
# die Größen, mit denen Asteroiden spawnen können, und dazugehörige Werte (s. AsteroidSize für die Bedeutungen der Attribute):
ASTEROID_SIZES = {
'small': AsteroidSize(avg_radius=15, min_speed=2.4, max_speed=3.6, stroke_weight=1, points=100, index=0),
'medium': AsteroidSize(avg_radius=30, min_speed=1.8, max_speed=2.7, stroke_weight=2, points=50, index=1),
'large': AsteroidSize(avg_radius=60, min_speed=1.2, max_speed=1.8, stroke_weight=3, points=20, index=2)
}
# die Größen, mit denen fliegende Untertassen spawnen können, und dazugehörige Weret (s. SaucerSize für die Bedeutungen der Attribute):
SAUCER_SIZES = {
'small': SaucerSize(radius=15, stroke_weight=2, min_speed=2, max_speed=3, points=1000, aim=0.6, shoot_probability=1, bullet_lifetime=0.8, sound=0, index=0),
'large': SaucerSize(radius=30, stroke_weight=3, min_speed=1.5, max_speed=2.25, points=200, aim=0.9, shoot_probability=0.5, bullet_lifetime=0.6, sound=1, index=1)
}
# die Schwierigkeiten
DIFFICULTIES = (
# Die erste Schwierigkeit startet nach 0 Sekunden, und die Wahrscheinlichkeit, dass Asteroiden spawnen, liegt bei 0,6:
Difficulty(0.6, 0),
# Die erste Schwierigkeit startet nach 10 Sekunden, und die Wahrscheinlichkeit, dass Asteroiden spawnen, liegt bei 0,72:
Difficulty(0.72, 10),
Difficulty(0.9, 25), # Die dritte Schwierigkeit ...
Difficulty(1.14, 45),
Difficulty(1.44, 75),
Difficulty(1.8, 120),
Difficulty(2.16, 180),
Difficulty(2.58, 300),
Difficulty(3.06, 600)
)
# Die Menüs:
MAIN_MENU: Menu # das Hauptmenü
PAUSE_MENU: Menu # das Pausenmenü
GAME_OVER_MENU: Menu # das Spiel-ist-aus-Menü
SETTINGS_MENU: Menu # das Einstellungsmenü
RESET_ALL_CONFIRM_MENU: Menu # das Menü, in dem man gefragt wird, ob man wirklich alles löschen will
HIGH_SCORES_MENU: Menu # das Highscores-Menü
# Variablen
running: bool = True # so lange „wahr“, solange das Spiel läuft
game_over: bool = True # speichert, ob das Spiel aus ist
player: Player | None = None # der Spieler
fragment: Fragment | None = None # eventuell das Fragment des Spielers
asteroids: list[Asteroid] = [] # die Liste der Asteroiden
saucer: Saucer | None = None # die fliegende Untertasse
saucer_on_screen: bool # ob die fliegende Untertasse im Fenster ist
saucer_fragments: list[Fragment] = [] # die Fragmente fliegender Untertassen
fire_bullet: bool = False # ob man die Leertaste gedrückt hat, um zu schießen
bullets: list[Bullet] = [] # die Liste der Kugeln
saucer_bullets: list[SaucerBullet] = [] # die Liste der von fliegenden Untertassen abgefeuerten Kugeln
explosions: list[Explosion] = [] # die Liste der Explosionen (die beim Sprengen eines kleinen Asteroiden entstehen)
score: int
add_points: int = 0 # wie viele Punkte der Punktzahl hinzugefügt werden
ticks_since_last_points: int = 0 # die Anzahl von Ticks, die seit dem letzten Erhalten von Punkten vergangen sind
high_scores: list[HighScore] = [] # die Liste der Highscores (5 Highscores)
new_high_score: bool
lives: int
life_surface: pygame.Surface #
playing_ticks: int = 0 # wie viele Ticks das aktuelle Spiel schon anhält
difficulty: Difficulty # die aktuelle Schwierigkeit
opened_menu: Menu | None # das aktuelle Menü
last_sound_tick: int = 0 # der letzte Tick, bei dem das Geräusch abgespielt wurde
play_beat_1: bool = True # ob als Nächstes der erste Beat gespielt werden soll
# Funktionen
# Die main()-Funktion
def main() -> None:
global score, add_points
pygame.init() # Pygame initialisieren
pygame.font.init() # das Rendern von Schrift in Pygame initialisieren
pygame.mixer.init() # den Soundmixer von Pygame initialisieren
sounds.init() # die Geräusche in diesem Spiel initialisieren
init() # dieses Spiel initialisieren
screen = pygame.display.set_mode(SIZE) # das Fenster anlegen
pygame.display.set_caption('Asteroids') # die Fensterüberschrift setzen
clock = pygame.time.Clock() # mit dieser Uhr kann die Bildfrequenz geregelt werden
# Spielloop
while running:
# Event-Handling
for event in pygame.event.get():
match event.type:
case pygame.KEYDOWN:
handle_keydown_event(event)
case pygame.KEYUP:
handle_keyup_event(event)
case pygame.QUIT:
quit_game()
update() # die Funktion update() aktualisiert alle Objekte, die im Fenster angezeigt werden
render(screen) # die Funktion render() rendert alles, was angezeigt werden soll
pygame.display.update() # das neu gerenderte Bild im Fenster anzeigen