-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathgame_object.py
More file actions
1206 lines (1095 loc) · 49.1 KB
/
game_object.py
File metadata and controls
1206 lines (1095 loc) · 49.1 KB
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
import os, math, random
from collections import namedtuple
import vector
from art import Art, ArtInstance
from renderable import GameObjectRenderable
from renderable_line import OriginIndicatorRenderable, BoundsIndicatorRenderable
from collision import Contact, Collideable, CST_NONE, CST_CIRCLE, CST_AABB, CST_TILE, CT_NONE, CTG_STATIC, CTG_DYNAMIC, point_in_box
# facings
GOF_LEFT = 0
"Object is facing left"
GOF_RIGHT = 1
"Object is facing right"
GOF_FRONT = 2
"Object is facing front"
GOF_BACK = 3
"Object is facing back"
FACINGS = {
GOF_LEFT: 'left',
GOF_RIGHT: 'right',
GOF_FRONT: 'front',
GOF_BACK: 'back'
}
"Dict mapping GOF_* facing enum values to strings"
FACING_DIRS = {
GOF_LEFT: (-1, 0),
GOF_RIGHT: (1, 0),
GOF_FRONT: (0, -1),
GOF_BACK: (0, 1)
}
"Dict mapping GOF_* facing enum values to (x,y) orientations"
DEFAULT_STATE = 'stand'
# timer slots
TIMER_PRE_UPDATE = 0
TIMER_UPDATE = 1
TIMER_POST_UPDATE = 2
__pdoc__ = {}
__pdoc__['GameObject.x'] = "Object's location in 3D space."
class GameObject:
"""
Base class game object. GameObjects (GOs) are spawned into and managed by
a GameWorld. All GOs render and collide via a single Renderable and
Collideable, respectively. GOs can have states and facings. GOs are
serialized in game state save files. Much of Playscii game creation involves
creating flavors of GameObject.
See game_util_object module for some generic subclasses for things like
a player, spawners, triggers, attachments etc.
"""
art_src = 'game_object_default'
"""
If specified, this art file will be loaded from disk and used as object's
default appearance. If object has states/facings, this is the "base"
filename prefix, eg "hero" in "hero_stand_front.psci".
"""
state_changes_art = False
"If True, art will change with current state; depends on file naming."
stand_if_not_moving = False
"If True, object will go to stand state any time velocity is zero."
valid_states = [DEFAULT_STATE]
"List of valid states for this object, used to find anims"
facing_changes_art = False
"If True, art will change based on facing AND state"
generate_art = False
"""
If True, blank Art will be created with these dimensions, charset,
and palette
"""
use_art_instance = False
"If True, always use an ArtInstance of source Art"
animating = False
"If True, object's Art will animate on init/reset"
art_width, art_height = 8, 8
art_charset, art_palette = None, None
y_sort = False
"If True, object will sort according to its Y position a la Zelda LttP"
lifespan = 0.
"If >0, object will self-destroy after this many seconds"
kill_distance_from_origin = 1000
"""
If object gets further than this distance from origin,
(non-overridden) update will self-destroy
"""
spawner = None
"If another object spawned us, store reference to it here"
physics_move = True
"If False, don't do move physics updates for this object"
fast_move_steps = 0
"""
If >0, subdivide high-velocity moves into fractions-of-this-object-sized
steps to avoid tunneling. turn this up if you notice an object tunneling.
# 1 = each step is object's full size
# 2 = each step is half object's size
# N = each step is 1/N object's size
"""
move_accel_x = move_accel_y = 200.
"Acceleration per update from player movement"
ground_friction = 10.0
air_friction = 25.0
mass = 1.
"Mass: negative number = infinitely dense"
bounciness = 0.25
"Bounciness aka restitution, % of velocity reflected on bounce"
stop_velocity = 0.1
"Near-zero point at which any velocity is set to zero"
log_move = False
log_load = False
log_spawn = False
visible = True
alpha = 1.
locked = False
"If True, location is protected from edit mode drags, can't click to select"
show_origin = False
show_bounds = False
show_collision = False
collision_shape_type = CST_NONE
"Collision shape: tile, circle, AABB - see the CST_* enum values"
collision_type = CT_NONE
"Type of collision (static, dynamic)"
col_layer_name = 'collision'
"Collision layer name for CST_TILE objects"
draw_col_layer = False
"If True, collision layer will draw normally"
col_offset_x, col_offset_y = 0., 0.
"Collision circle/box offset from origin"
col_radius = 1.
"Collision circle size, if CST_CIRCLE"
col_width, col_height = 1., 1.
"Collision AABB size, if CST_AABB"
art_off_pct_x, art_off_pct_y = 0.5, 0.5
"""
Art offset from pivot: Renderable's origin_pct set to this if not None
0,0 = top left; 1,1 = bottom right; 0.5,0.5 = center
"""
should_save = True
"If True, write this object to state save files"
serialized = ['name', 'x', 'y', 'z', 'art_src', 'visible', 'locked', 'y_sort',
'art_off_pct_x', 'art_off_pct_y', 'alpha', 'state', 'facing',
'animating', 'scale_x', 'scale_y']
"List of members to serialize (no weak refs!)"
editable = ['show_collision', 'col_radius', 'col_width', 'col_height',
'mass', 'bounciness', 'stop_velocity']
"""
Members that don't need to be serialized, but should be exposed to
object edit UI
"""
set_methods = {'art_src': 'set_art_src', 'alpha': '_set_alpha',
'scale_x': '_set_scale_x', 'scale_y': '_set_scale_y',
'name': '_rename', 'col_radius': '_set_col_radius',
'col_width': '_set_col_width',
'col_height': '_set_col_height'
}
"If setting a given member should run some logic, specify the method here"
selectable = True
"If True, user can select this object in edit mode"
deleteable = True
"If True, user can delete this object in edit mode"
is_debug = False
"If True, object's visibility can be toggled with View menu option"
exclude_from_object_list = False
"If True, do not list object in edit mode UI - system use only!"
exclude_from_class_list = False
"If True, do not list class in edit mode UI - system use only!"
attachment_classes = {}
"Objects to spawn as attachments: key is member name, value is class"
noncolliding_classes = []
"Blacklist of string names for classes to ignore collisions with"
sound_filenames = {}
'Dict of sound filenames, keys are string "tags"'
looping_state_sounds = {}
"Dict of looping sounds that should play while in a given state"
update_if_outside_room = False
"""
If True, object's update function will run even if it's
outside the world's current room
"""
handle_key_events = False
"If True, handle key input events passed in from world / input handler"
handle_mouse_events = False
"If True, handle mouse click/wheel events passed in from world / input handler"
consume_mouse_events = False
"If True, prevent any other mouse click/wheel events from being processed"
def __init__(self, world, obj_data=None):
"""
Create new GameObject in world, from serialized data if provided.
"""
self.x, self.y, self.z = 0., 0., 0.
"Object's location in 3D space."
self.scale_x, self.scale_y, self.scale_z = 1., 1., 1.
"Object's scale in 3D space."
self.rooms = {}
"Dict of rooms we're in - if empty, object appears in all rooms"
self.state = DEFAULT_STATE
"String representing object state. Every object has one, even if it never changes."
self.facing = GOF_FRONT
"Every object gets a facing, even if it never changes"
self.name = self.get_unique_name()
# apply serialized data before most of init happens
# properties that need non-None defaults should be declared above
if obj_data:
for v in self.serialized:
if not v in obj_data:
if self.log_load:
self.app.dev_log("Serialized property '%s' not found for %s" % (v, self.name))
continue
# if value is in data and serialized list but undeclared, do so
if not hasattr(self, v):
setattr(self, v, None)
# match type of variable as declared, eg loc might be written as
# an int in the JSON so preserve its floatness
if getattr(self, v) is not None:
src_type = type(getattr(self, v))
setattr(self, v, src_type(obj_data[v]))
else:
setattr(self, v, obj_data[v])
self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
"Object's velocity in units per second. Derived from acceleration."
self.move_x, self.move_y = 0, 0
"User-intended acceleration"
self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
self.last_update_end = 0
self.flip_x = False
"Set by state, True if object's renderable should be flipped in X axis."
self.world = world
"GameWorld this object is managed by"
self.app = self.world.app
"For convenience, Application instance for this object's GameWorld"
self.destroy_time = 0
"If >0, object will self-destroy at/after this time (in milliseconds)"
# lifespan property = easy auto-set for fixed lifetime objects
if self.lifespan > 0:
self.set_destroy_timer(self.lifespan)
self.timer_functions_pre_update = {}
"Dict of running GameObjectTimerFuctions that run during pre_update"
self.timer_functions_update = {}
"Dict of running GameObjectTimerFuctions that run during update"
self.timer_functions_post_update = {}
"Dict of running GameObjectTimerFuctions that run during post_update"
self.last_update_failed = False
"When True, object's last update threw an exception"
# load/create assets
self.arts = {}
"Dict of all Arts this object can reference, eg for states"
# if art_src not specified, create a new art according to dimensions
if self.generate_art:
self.art_src = '%s_art' % self.name
self.art = self.app.new_art(self.art_src, self.art_width,
self.art_height, self.art_charset,
self.art_palette)
else:
self.load_arts()
if self.art is None or not self.art.valid:
# grab first available art
if len(self.arts) > 0:
for art in self.arts:
self.art = self.arts[art]
break
if not self.art:
self.app.log("Couldn't spawn GameObject with art %s" % self.art_src)
return
self.renderable = GameObjectRenderable(self.app, self.art, self)
self.renderable.alpha = self.alpha
self.origin_renderable = OriginIndicatorRenderable(self.app, self)
"Renderable for debug drawing of object origin."
self.bounds_renderable = BoundsIndicatorRenderable(self.app, self)
"1px LineRenderable showing object's bounding box"
for art in self.arts.values():
if not art in self.world.art_loaded:
self.world.art_loaded.append(art)
self.orig_collision_type = self.collision_type
"Remember last collision type for enable/disable - don't set manually!"
self.collision = Collideable(self)
self.world.new_objects[self.name] = self
self.attachments = []
if self.attachment_classes:
for atch_name,atch_class_name in self.attachment_classes.items():
atch_class = self.world.classes[atch_class_name]
attachment = atch_class(self.world)
self.attachments.append(attachment)
attachment.attach_to(self)
setattr(self, atch_name, attachment)
self.should_destroy = False
"If True, object will be destroyed on next world update."
self.pre_first_update_run = False
"Flag that tells us we should run post_init next update."
self.last_state = None
self.last_warp_update = -1
"Most recent warp world update, to prevent thrashing"
# set up art instance only after all art/renderable init complete
if self.use_art_instance:
self.set_art(ArtInstance(self.art))
if self.animating and self.art.frames > 0:
self.start_animating()
if self.log_spawn:
self.app.log('Spawned %s with Art %s' % (self.name, os.path.basename(self.art.filename)))
def get_unique_name(self):
"Generate and return a somewhat human-readable unique name for object"
name = str(self)
return '%s_%s' % (type(self).__name__, name[name.rfind('x')+1:-1])
def _rename(self, new_name):
# pass thru to world, this method exists for edit set method
self.world.rename_object(self, new_name)
def pre_first_update(self):
"""
Run before first update; use this for any logic that depends on
init/creation being done ie all objects being present.
"""
pass
def load_arts(self):
"Fill self.arts dict with Art references for eg states and facings."
self.art = self.app.load_art(self.art_src, False)
if self.art:
self.arts[self.art_src] = self.art
# if no states, use a single art always
if not self.state_changes_art:
self.arts[self.art_src] = self.art
return
for state in self.valid_states:
if self.facing_changes_art:
# load each facing for each state
for facing in FACINGS.values():
art_name = '%s_%s_%s' % (self.art_src, state, facing)
art = self.app.load_art(art_name, False)
if art:
self.arts[art_name] = art
else:
# load each state
art_name = '%s_%s' % (self.art_src, state)
art = self.app.load_art(art_name, False)
if art:
self.arts[art_name] = art
# get reasonable default pose
self.art, self.flip_x = self.get_art_for_state()
def is_point_inside(self, x, y):
"Return True if given point is inside our bounds"
left, top, right, bottom = self.get_edges()
return point_in_box(x, y, left, top, right, bottom)
def get_edges(self):
"Return coords of our bounds (left, top, right, bottom)"
left = self.x - (self.renderable.width * self.art_off_pct_x)
right = self.x + (self.renderable.width * (1 - self.art_off_pct_x))
top = self.y + (self.renderable.height * self.art_off_pct_y)
bottom = self.y - (self.renderable.height * (1 - self.art_off_pct_y))
return left, top, right, bottom
def distance_to_object(self, other):
"Return distance from center of this object to center of given object."
return self.distance_to_point(other.x, other.y)
def distance_to_point(self, point_x, point_y):
"Return distance from center of this object to given point."
dx = self.x - point_x
dy = self.y - point_y
return math.sqrt(dx ** 2 + dy ** 2)
def normal_to_object(self, other):
"Return tuple normal pointing in direction of given object."
return self.normal_to_point(other.x, other.y)
def normal_to_point(self, point_x, point_y):
"Return tuple normal pointing in direction of given point."
dist = self.distance_to_point(point_x, point_y)
dx, dy = point_x - self.x, point_y - self.y
if dist == 0:
return 0, 0
inv_dist = 1 / dist
return dx * inv_dist, dy * inv_dist
def get_render_offset(self):
"Return a custom render offset. Override this in subclasses as needed."
return 0, 0, 0
def is_dynamic(self):
"Return True if object is dynamic."
return self.collision_type in CTG_DYNAMIC
def is_entering_state(self, state):
"Return True if object is in given state this frame but not last frame."
return self.state == state and self.last_state != state
def is_exiting_state(self, state):
"Return True if object is in given state last frame but not this frame."
return self.state != state and self.last_state == state
def play_sound(self, sound_name, loops=0, allow_multiple=False):
"Start playing given sound."
# use sound_name as filename if it's not in our filenames dict
sound_filename = self.sound_filenames.get(sound_name, sound_name)
sound_filename = self.world.sounds_dir + sound_filename
self.world.app.al.object_play_sound(self, sound_filename,
loops, allow_multiple)
def stop_sound(self, sound_name):
"Stop playing given sound."
sound_filename = self.sound_filenames.get(sound_name, sound_name)
sound_filename = self.world.sounds_dir + sound_filename
self.world.app.al.object_stop_sound(self, sound_filename)
def stop_all_sounds(self):
"Stop all sounds playing on object."
self.world.app.al.object_stop_all_sounds(self)
def enable_collision(self):
"Enable this object's collision."
self.collision_type = self.orig_collision_type
def disable_collision(self):
"Disable this object's collision."
if self.collision_type == CT_NONE:
return
# remember prior collision type
self.orig_collision_type = self.collision_type
self.collision_type = CT_NONE
def started_overlapping(self, other):
"""
Run when object begins overlapping with, but does not collide with,
another object.
"""
pass
def started_colliding(self, other):
"Run when object begins colliding with another object."
self.resolve_collision_momentum(other)
def stopped_colliding(self, other):
"Run when object stops colliding with another object."
if not other.name in self.collision.contacts:
# TODO: understand why this spams when player has a MazePickup
#self.world.app.log("%s stopped colliding with %s but wasn't in its contacts!" % (self.name, other.name))
return
# called from check_finished_contacts
self.collision.contacts.pop(other.name)
def resolve_collision_momentum(self, other):
"Resolve velocities between this object and given other object."
# don't resolve a pair twice
if self in self.world.cl.collisions_this_frame:
return
# determine new direction and velocity
total_vel = self.vel_x + self.vel_y + other.vel_x + other.vel_y
# negative mass = infinite
total_mass = max(0, self.mass) + max(0, other.mass)
if other.name not in self.collision.contacts or \
self.name not in other.collision.contacts:
return
# redistribute velocity based on mass we're colliding with
if self.is_dynamic() and self.mass >= 0:
ax = self.collision.contacts[other.name].overlap.x
ay = self.collision.contacts[other.name].overlap.y
a_vel = total_vel * (self.mass / total_mass)
a_vel *= self.bounciness
self.vel_x, self.vel_y = -ax * a_vel, -ay * a_vel
if other.is_dynamic() and other.mass >= 0:
bx = other.collision.contacts[self.name].overlap.x
by = other.collision.contacts[self.name].overlap.y
b_vel = total_vel * (other.mass / total_mass)
b_vel *= other.bounciness
other.vel_x, other.vel_y = -bx * b_vel, -by * b_vel
# mark objects as resolved
self.world.cl.collisions_this_frame.append(self)
self.world.cl.collisions_this_frame.append(other)
def check_finished_contacts(self):
"""
Updates our Collideable's contacts dict for contacts that were
happening last update but not this one, and call stopped_colliding.
"""
# put stopped-colliding objects in a list to process after checks
finished = []
# keep separate list of names of objects no longer present
destroyed = []
for obj_name,contact in self.collision.contacts.items():
if contact.timestamp < self.world.cl.ticks:
# object might have been destroyed
obj = self.world.objects.get(obj_name, None)
if obj:
finished.append(obj)
else:
destroyed.append(obj_name)
for obj_name in destroyed:
self.collision.contacts.pop(obj_name)
for obj in finished:
self.stopped_colliding(obj)
obj.stopped_colliding(self)
def get_contacting_objects(self):
"Return list of all objects we're currently contacting."
return [self.world.objects[obj] for obj in self.collision.contacts]
def get_collisions(self):
"Return list of all overlapping shapes our shapes should collide with."
overlaps = []
for shape in self.collision.shapes:
for other in self.world.cl.dynamic_shapes:
if other.go is self:
continue
if not other.go.should_collide():
continue
if not self.can_collide_with(other.go):
continue
if not other.go.can_collide_with(self):
continue
overlaps.append(shape.get_overlap(other))
for other in shape.get_overlapping_static_shapes():
overlaps.append(other)
return overlaps
def is_overlapping(self, other):
"Return True if we overlap with other object's collision"
return other.name in self.collision.contacts
def are_bounds_overlapping(self, other):
"Return True if we overlap with other object's Art's bounds"
left, top, right, bottom = self.get_edges()
for x,y in [(left, top), (right, top), (right, bottom), (left, bottom)]:
if other.is_point_inside(x, y):
return True
return False
def get_tile_at_point(self, point_x, point_y):
"Return x,y tile coord for given worldspace point"
left, top, right, bottom = self.get_edges()
x = (point_x - left) / self.art.quad_width
x = math.floor(x)
y = (point_y - top) / self.art.quad_height
y = math.ceil(-y)
return x, y
def get_tiles_overlapping_box(self, box_left, box_top, box_right, box_bottom, log=False):
"Returns x,y coords for each tile overlapping given box"
if self.collision_shape_type != CST_TILE:
return []
left, top = self.get_tile_at_point(box_left, box_top)
right, bottom = self.get_tile_at_point(box_right, box_bottom)
if bottom < top:
top, bottom = bottom, top
# stay in bounds
left = max(0, left)
right = min(right, self.art.width - 1)
top = max(1, top)
bottom = min(bottom, self.art.height)
tiles = []
# account for range start being inclusive, end being exclusive
for x in range(left, right + 1):
for y in range(top - 1, bottom):
tiles.append((x, y))
return tiles
def overlapped(self, other, overlap):
"""
Called by CollisionLord when two objects overlap.
returns: bool "overlap allowed", bool "collision starting"
"""
started = other.name not in self.collision.contacts
# create or update contact info: (overlap, timestamp)
self.collision.contacts[other.name] = Contact(overlap,
self.world.cl.ticks)
can_collide = self.can_collide_with(other)
if not can_collide and started:
self.started_overlapping(other)
return can_collide, started
def get_tile_loc(self, tile_x, tile_y, tile_center=True):
"Return top left / center of current Art's tile in world coordinates"
left, top, right, bottom = self.get_edges()
x = left
x += self.art.quad_width * tile_x
y = top
y -= self.art.quad_height * tile_y
if tile_center:
x += self.art.quad_width / 2
y -= self.art.quad_height / 2
return x, y
def get_layer_z(self, layer_name):
"Return Z of layer with given name"
return self.z + self.art.layers_z[self.art.layer_names.index(layer_name)]
def get_all_art(self):
"Return a list of all Art used by this object"
return list(self.arts.keys())
def start_animating(self):
"Start animation playback."
self.renderable.start_animating()
def stop_animating(self):
"Pause animation playback on current frame."
self.renderable.stop_animating()
def set_object_property(self, prop_name, new_value):
"Set property by given name to given value."
if not hasattr(self, prop_name):
return
if prop_name in self.set_methods:
method = getattr(self, self.set_methods[prop_name])
method(new_value)
else:
setattr(self, prop_name, new_value)
def get_art_for_state(self, state=None):
"Return Art (and 'flip X' bool) that best represents current state"
# use current state if none specified
state = self.state if state is None else state
art_state_name = '%s_%s' % (self.art_src, self.state)
# simple case: no facing, just state
if not self.facing_changes_art:
# return art for current state, use default if not available
if art_state_name in self.arts:
return self.arts[art_state_name], False
else:
default_name = '%s_%s' % (self.art_src, self.state or DEFAULT_STATE)
#assert(default_name in self.arts
# don't assert - if base+state name available, use that
if default_name in self.arts:
return self.arts[default_name], False
else:
#self.app.log('%s: Art with name %s not available, using %s' % (self.name, default_name, self.art_src))
return self.arts[self.art_src], False
# more complex case: art determined by both state and facing
facing_suffix = FACINGS[self.facing]
# first see if anim exists for this exact state, skip subsequent logic
exact_name = '%s_%s' % (art_state_name, facing_suffix)
if exact_name in self.arts:
return self.arts[exact_name], False
# see what anims are available and try to choose best for facing
has_state = False
for anim in self.arts:
if anim.startswith(art_state_name):
has_state = True
break
# if NO anims for current state, fall back to default
if not has_state:
default_name = '%s_%s' % (self.art_src, DEFAULT_STATE)
art_state_name = default_name
front_name = '%s_%s' % (art_state_name, FACINGS[GOF_FRONT])
left_name = '%s_%s' % (art_state_name, FACINGS[GOF_LEFT])
right_name = '%s_%s' % (art_state_name, FACINGS[GOF_RIGHT])
back_name = '%s_%s' % (art_state_name, FACINGS[GOF_BACK])
has_front = front_name in self.arts
has_left = left_name in self.arts
has_right = right_name in self.arts
has_sides = has_left or has_right
# throw an error if nothing basic is available
#assert(has_front or has_sides)
if not has_front and not has_sides:
return self.arts[self.art_src], False
# if left/right opposite available, flip it
if self.facing == GOF_LEFT and has_right:
return self.arts[right_name], True
elif self.facing == GOF_RIGHT and has_left:
return self.arts[left_name], True
# if left or right but neither, use front
elif self.facing in [GOF_LEFT, GOF_RIGHT] and not has_sides:
return self.arts[front_name], False
# if no front but sides, use either
elif self.facing == GOF_FRONT and has_sides:
if has_right:
return self.arts[right_name], False
elif has_left:
return self.arts[left_name], False
# if no back, use sides or, as last resort, front
elif self.facing == GOF_BACK and has_sides:
if has_right:
return self.arts[right_name], False
elif has_left:
return self.arts[left_name], False
else:
return self.arts[front_name], False
# fall-through: keep using current art
return self.art, False
def set_art(self, new_art, start_animating=True):
"Set object to use new given Art (passed by reference)."
if new_art is self.art:
return
self.art = new_art
self.renderable.set_art(self.art)
self.bounds_renderable.set_art(self.art)
if self.collision_shape_type == CST_TILE:
self.collision.create_shapes()
if (start_animating or self.animating) and new_art.frames > 1:
self.renderable.start_animating()
def set_art_src(self, new_art_filename):
"Set object to use new given Art (passed by filename)"
if self.art_src == new_art_filename:
return
new_art = self.app.load_art(new_art_filename)
if not new_art:
return
self.art_src = new_art_filename
# reset arts dict
self.arts = {}
self.load_arts()
self.set_art(new_art)
def set_loc(self, x, y, z=None):
"Set this object's location."
self.x, self.y = x, y
self.z = z or 0
def reset_last_loc(self):
'Reset "last location" values used for updating state and fast_move'
self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
def set_scale(self, x, y, z):
"Set this object's scale."
self.scale_x, self.scale_y, self.scale_z = x, y, z
self.renderable.scale_x = self.scale_x
self.renderable.scale_y = self.scale_y
self.renderable.reset_size()
def _set_scale_x(self, new_x):
self.set_scale(new_x, self.scale_y, self.scale_z)
def _set_scale_y(self, new_y):
self.set_scale(self.scale_x, new_y, self.scale_z)
def _set_col_radius(self, new_radius):
self.col_radius = new_radius
self.collision.shapes[0].radius = new_radius
def _set_col_width(self, new_width):
self.col_width = new_width
self.collision.shapes[0].halfwidth = new_width / 2
def _set_col_height(self, new_height):
self.col_height = new_height
self.collision.shapes[0].halfheight = new_height / 2
def _set_alpha(self, new_alpha):
self.renderable.alpha = self.alpha = new_alpha
def allow_move(self, dx, dy):
"Return True only if this object is allowed to move based on input."
return True
def allow_move_x(self, dx):
"Return True if given movement in X axis is allowed."
return True
def allow_move_y(self, dy):
"Return True if given movement in Y axis is allowed."
return True
def move(self, dir_x, dir_y):
"""
Input player/sim-initiated velocity. Given value is multiplied by
acceleration in get_acceleration.
"""
# don't handle moves while game paused
# (add override flag if this becomes necessary)
if self.world.paused:
return
# check allow_move first
if not self.allow_move(dir_x, dir_y):
return
if self.allow_move_x(dir_x):
self.move_x += dir_x
if self.allow_move_y(dir_y):
self.move_y += dir_y
def is_on_ground(self):
'''
Return True if object is "on the ground". Subclasses define custom
logic here.
'''
return True
def get_friction(self):
"Return friction that should be applied for object's current context."
return self.ground_friction if self.is_on_ground() else self.air_friction
def is_affected_by_gravity(self):
"Return True if object should be affected by gravity."
return False
def get_gravity(self):
"Return x,y,z force of gravity for object's current context."
return self.world.gravity_x, self.world.gravity_y, self.world.gravity_z
def get_acceleration(self, vel_x, vel_y, vel_z):
"""
Return x,y,z acceleration values for object's current context.
"""
force_x = self.move_x * self.move_accel_x
force_y = self.move_y * self.move_accel_y
force_z = 0
if self.is_affected_by_gravity():
grav_x, grav_y, grav_z = self.get_gravity()
force_x += grav_x * self.mass
force_y += grav_y * self.mass
force_z += grav_z * self.mass
# friction / drag
friction = self.get_friction()
speed = math.sqrt(vel_x ** 2 + vel_y ** 2 + vel_z ** 2)
force_x -= friction * self.mass * vel_x
force_y -= friction * self.mass * vel_y
force_z -= friction * self.mass * vel_z
# divide force by mass to get acceleration
accel_x = force_x / self.mass
accel_y = force_y / self.mass
accel_z = force_z / self.mass
# zero out acceleration beneath a threshold
# TODO: determine if this should be made tunable
return vector.cut_xyz(accel_x, accel_y, accel_z, 0.01)
def apply_move(self):
"""
Apply current acceleration / velocity to position using Verlet
integration with half-step velocity estimation.
"""
accel_x, accel_y, accel_z = self.get_acceleration(self.vel_x, self.vel_y, self.vel_z)
timestep = self.world.app.timestep / 1000
hsvel_x = self.vel_x + 0.5 * timestep * accel_x
hsvel_y = self.vel_y + 0.5 * timestep * accel_y
hsvel_z = self.vel_z + 0.5 * timestep * accel_z
self.x += hsvel_x * timestep
self.y += hsvel_y * timestep
self.z += hsvel_z * timestep
accel_x, accel_y, accel_z = self.get_acceleration(hsvel_x, hsvel_y, hsvel_z)
self.vel_x = hsvel_x + 0.5 * timestep * accel_x
self.vel_y = hsvel_y + 0.5 * timestep * accel_y
self.vel_z = hsvel_z + 0.5 * timestep * accel_z
self.vel_x, self.vel_y, self.vel_z = vector.cut_xyz(self.vel_x, self.vel_y, self.vel_z, self.stop_velocity)
def moved_this_frame(self):
"Return True if object changed locations this frame."
delta = math.sqrt(abs(self.last_x - self.x) ** 2 + abs(self.last_y - self.y) ** 2 + abs(self.last_z - self.z) ** 2)
return delta > self.stop_velocity
def warped_recently(self):
"Return True if object warped during last update."
return self.world.updates - self.last_warp_update <= 0
def handle_key_down(self, key, shift_pressed, alt_pressed, ctrl_pressed):
"""
Handle "key pressed" event, with keyboard mods passed in.
GO subclasses can do stuff here if their handle_key_events=True
"""
pass
def handle_key_up(self, key, shift_pressed, alt_pressed, ctrl_pressed):
"""
Handle "key released" event, with keyboard mods passed in.
GO subclasses can do stuff here if their handle_key_events=True
"""
pass
def clicked(self, button, mouse_x, mouse_y):
"""
Handle mouse button down event, with button # and
click location (in world coordinates) passed in.
GO subclasses can do stuff here if their handle_mouse_events=True
"""
pass
def unclicked(self, button, mouse_x, mouse_y):
"""
Handle mouse button up event, with button # and
click location (in world coordinates) passed in.
GO subclasses can do stuff here if their handle_mouse_events=True
"""
pass
def hovered(self, mouse_x, mouse_y):
"""
Handle mouse hover (fires when object -starts- being hovered).
GO subclasses can do stuff here if their handle_mouse_events=True
"""
pass
def unhovered(self, mouse_x, mouse_y):
"""
Handle mouse unhover.
GO subclasses can do stuff here if their handle_mouse_events=True
"""
pass
def mouse_wheeled(self, wheel_y):
"""
Handle mouse wheel movement.
GO subclasses can do stuff here if their handle_mouse_events=True
"""
pass
def set_timer_function(self, timer_name, timer_function, delay_min,
delay_max=0, repeats=-1, slot=TIMER_PRE_UPDATE):
"""
Run given function in X seconds or every X seconds Y times.
If max is given, next execution will be between min and max time.
if repeat is -1, run indefinitely.
"Slot" determines whether function will run in pre_update, update, or
post_update.
"""
timer = GameObjectTimerFunction(self, timer_name, timer_function,
delay_min, delay_max, repeats, slot)
# add to slot-appropriate dict
d = [self.timer_functions_pre_update, self.timer_functions_update,
self.timer_functions_post_update][slot]
d[timer_name] = timer
def stop_timer_function(self, timer_name):
"Stop currently running timer function with given name."
timer = self.timer_functions_pre_update.get(timer_name, None) or \
self.timer_functions_update.get(timer_name, None) or \
self.timer_functions_post_update.get(timer_name, None)
if not timer:
self.app.log('Timer named %s not found on object %s' % (timer_name,
self.name))
d = [self.timer_functions_pre_update, self.timer_functions_update,
self.timer_functions_post_update][timer.slot]
d.pop(timer_name)
def update_state(self):
"Update object state based on current context, eg movement."
if self.state_changes_art and self.stand_if_not_moving and \
not self.moved_this_frame():
self.state = DEFAULT_STATE
def update_facing(self):
"Update object facing based on current context, eg movement."
dx, dy = self.x - self.last_x, self.y - self.last_y
if dx == 0 and dy == 0:
return
# TODO: flag for "side view only" objects
if abs(dy) > abs(dx):
self.facing = GOF_BACK if dy >= 0 else GOF_FRONT
else:
self.facing = GOF_RIGHT if dx >= 0 else GOF_LEFT
def update_state_sounds(self):
"Stop and play looping sounds appropriate to current/recent states."
for state,sound in self.looping_state_sounds.items():
if self.is_entering_state(state):
self.play_sound(sound, loops=-1)
elif self.is_exiting_state(state):
self.stop_sound(sound)
def frame_begin(self):
"Run at start of game loop iteration, before input/update/render."
self.move_x, self.move_y = 0, 0
self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
# if we're just entering stand state, play any sound for it
if self.last_state is None:
self.update_state_sounds()
self.last_state = self.state
def frame_update(self):
"Run once per frame, after input + simulation update and before render."
if not self.art.updated_this_tick:
self.art.update()
# update art based on state (and possibly facing too)
if self.state_changes_art:
new_art, flip_x = self.get_art_for_state()
self.set_art(new_art)
self.flip_x = flip_x
def pre_update(self):
"Run before any objects have updated this simulation tick."
pass
def post_update(self):
"Run after all objects have updated this simulation tick."
pass
def fast_move(self):
"""
Subdivide object's move this frame into steps to avoid tunneling.
Only called for objects with fast_move_steps >0.
"""
final_x, final_y = self.x, self.y
dx, dy = self.x - self.last_x, self.y - self.last_y
total_move_dist = math.sqrt(dx ** 2 + dy ** 2)
if total_move_dist == 0:
return
# get movement normal
inv_dist = 1 / total_move_dist
dir_x, dir_y = dx * inv_dist, dy * inv_dist
if self.collision_shape_type == CST_CIRCLE:
step_dist = self.col_radius * 2
elif self.collision_shape_type == CST_AABB: