ttyengine 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.
@@ -0,0 +1,4718 @@
1
+ #_owned - list of owned variables which will be inspected upon obj().create()
2
+ #_required - list of required components to be included upon obj().create()
3
+ #_default - bool of the component auto-inclusion upon obj().create()
4
+ #def on_discard() - function that will be called upon obj().discard()
5
+
6
+ #MODULES
7
+ import math, inspect, collections.abc, functools
8
+ from . import ansi
9
+ from .ascii_styler import alphaparse, alphamark, bgrecomp, fgrecomp
10
+
11
+ #GLOBAL
12
+ _NEIGHBOURS4 = ((-1, 0), (1, 0), (0, -1), (0, 1))
13
+ _NEIGHBOURS8 = _NEIGHBOURS4 + ((-1, -1), (1, -1), (-1, 1), (1, 1))
14
+ _NEIGHBOURS_HALF = ((1, 0), (0, 1), (1, 1), (-1, 1))
15
+
16
+ _EASINGS = {
17
+ 'linear': lambda p: p,
18
+ 'in': lambda p: p * p,
19
+ 'out': lambda p: 1 - (1 - p) * (1 - p),
20
+ 'smooth': lambda p: p * p * (3 - 2 * p),
21
+ }
22
+
23
+ def _easing(mode):
24
+ if callable(mode):
25
+ return mode
26
+ try:
27
+ return _EASINGS[mode]
28
+ except (KeyError, TypeError):
29
+ raise ValueError('ease must be a callable or one of: ' + ', '.join(_EASINGS))
30
+
31
+ @functools.lru_cache(maxsize=16)
32
+ def _offsets(radius, aspect):
33
+ span = int(radius / aspect) + 1
34
+ found = []
35
+ for dx in range(-span, span + 1):
36
+ for dy in range(-radius, radius + 1):
37
+ if dx or dy:
38
+ cost = math.hypot(dx * aspect, dy)
39
+ if cost <= radius:
40
+ found.append((cost - (0.01 if dy > 0 else 0), dx, dy))
41
+ found.sort()
42
+ return tuple((dx, dy) for _, dx, dy in found)
43
+ _SHAPES = ('circle', 'square', 'diamond')
44
+
45
+ @functools.lru_cache(maxsize=64)
46
+ def _footprint(radius, aspect, shape):
47
+ if shape not in _SHAPES:
48
+ raise ValueError('shape must be one of: ' + ', '.join(_SHAPES))
49
+ reach = max(1, radius) - 0.5
50
+ span_x = int(reach / aspect) + 1
51
+ span_y = int(reach) + 1
52
+ found = []
53
+ for dy in range(-span_y, span_y + 1):
54
+ for dx in range(-span_x, span_x + 1):
55
+ ux, uy = abs(dx) * aspect, abs(dy)
56
+ if shape == 'circle':
57
+ inside = math.hypot(ux, uy) < reach
58
+ elif shape == 'square':
59
+ inside = ux < reach and uy < reach
60
+ else:
61
+ inside = ux + uy < reach
62
+ if inside:
63
+ found.append((dx, dy))
64
+ return tuple(found)
65
+
66
+ _SHADOWS = ('physics', 'visual', 'hitbox')
67
+
68
+ def _check_shadow(value):
69
+ if value is not None and value not in _SHADOWS:
70
+ raise ValueError('shadow must be None or one of: ' + ', '.join(_SHADOWS))
71
+ return value
72
+
73
+ def _contact_point_cells(pixels, nx, ny, center):
74
+ n = len(pixels)
75
+ mx = sum(p[0] for p in pixels) / n
76
+ my = sum(p[1] for p in pixels) / n
77
+ if nx is None:
78
+ return (mx, my)
79
+ cx, cy = center
80
+ if ny:
81
+ xs = [p[0] for p in pixels]
82
+ return (max(min(xs) - 0.5, min(max(xs) + 0.5, cx)), my)
83
+ ys = [p[1] for p in pixels]
84
+ return (mx, max(min(ys) - 0.5, min(max(ys) + 0.5, cy)))
85
+
86
+ def _gradient_cells(pixels, center, sign, aspect):
87
+ cx, cy = center
88
+ sx = sy = 0.0
89
+ for x in range(math.ceil(cx - 2.5), math.floor(cx + 2.5) + 1):
90
+ for y in range(math.ceil(cy - 2.5), math.floor(cy + 2.5) + 1):
91
+ if (x, y) in pixels:
92
+ sx += (x - cx) * aspect
93
+ sy += y - cy
94
+ length = math.hypot(sx, sy)
95
+ if length < 1e-9:
96
+ return None
97
+ return sign * sx / length, sign * sy / length
98
+
99
+ def _contact_normal_cells(mine, theirs, point, nx, ny, inside, slope, aspect):
100
+ if not slope:
101
+ return nx, ny
102
+ shift = 0.5 if inside else -0.5
103
+ cx, cy = point
104
+ if abs(nx) > abs(ny):
105
+ cx += shift * (1 if nx > 0 else -1)
106
+ else:
107
+ cy += shift * (1 if ny > 0 else -1)
108
+ center = (round(cx * 2) / 2, round(cy * 2) / 2)
109
+ go = _gradient_cells(theirs, center, -1, aspect)
110
+ gs = _gradient_cells(mine, center, 1, aspect)
111
+ best = None
112
+ for g in (go, gs):
113
+ if g is None:
114
+ continue
115
+ d = g[0] * nx + g[1] * ny
116
+ if best is None or d > best[0]:
117
+ best = (d, g)
118
+ if best is None or best[0] < 0.25:
119
+ return nx, ny
120
+ return best[1]
121
+
122
+ def _mtv_cells(mine, other_pixels, aspect):
123
+ best = None
124
+ for dx, dy, cost in ((1, 0, aspect), (-1, 0, aspect), (0, 1, 1.0), (0, -1, 1.0)):
125
+ for k in range(1, 64):
126
+ if not ({(p[0] + dx * k, p[1] + dy * k) for p in mine} & other_pixels):
127
+ score = k * cost - (0.01 if dy == 1 else 0)
128
+ if best is None or score < best[0]:
129
+ best = (score, dx, dy, k)
130
+ break
131
+ return best
132
+
133
+ _SGR = ansi.SGR
134
+ _ALPHAS = functools.lru_cache(maxsize=4096)(alphaparse)
135
+ _HOLD = object()
136
+
137
+ def _unbake(baked, ref, alpha):
138
+ if alpha <= 0:
139
+ return baked
140
+ return tuple((baked[k] - ref[k] * (1 - alpha)) / alpha for k in range(3))
141
+
142
+ def _rebake(ref, source, alpha):
143
+ return tuple(max(0, min(255, int(round(ref[k] * (1 - alpha) + source[k] * alpha)))) for k in range(3))
144
+ _FGCODE = fgrecomp
145
+ _BGCODE = bgrecomp
146
+
147
+ def _argslen(cb):
148
+ params = inspect.signature(cb).parameters
149
+ l = len(params) - (1 if hasattr(cb, '__self__') else 0)
150
+ return l
151
+
152
+ def _closure_refs(fn, obj):
153
+ if not fn.__closure__:
154
+ return False
155
+ for cell in fn.__closure__:
156
+ try:
157
+ if cell.cell_contents is obj:
158
+ return True
159
+ except ValueError:
160
+ pass
161
+ return False
162
+
163
+ def _styleprop(name):
164
+ def fget(self):
165
+ return getattr(self, '_' + name)
166
+ def fset(self, value):
167
+ self._setstyle(name, value)
168
+ return property(fget, fset)
169
+
170
+ class _metainit(type):
171
+ def __new__(mcs, name, bases, attrs, **kwargs):
172
+ init_val = kwargs.pop('init', None)
173
+ cls = super().__new__(mcs, name, bases, attrs, **kwargs)
174
+ if init_val:
175
+ cls._target_init = init_val
176
+ return cls
177
+ def __call__(cls, *args, **kwargs):
178
+ inst = super().__call__(*args, **kwargs)
179
+ initfunc = getattr(cls, '_target_init', '_init')
180
+ instinit = getattr(inst, initfunc, None)
181
+ if instinit:
182
+ instinit()
183
+ return inst
184
+
185
+ class _change_callback(metaclass=_metainit, init='_chinit'):
186
+ def _chinit(self):
187
+ self._onchangecb = {}
188
+ self._changing = False
189
+ def cln():
190
+ discarded = self.obj
191
+ for other in self.obj.scene.objs:
192
+ com = getattr(other, self.__class__.__name__, None)
193
+ if other is discarded or not com or not hasattr(com, '_onchangecb'):
194
+ continue
195
+ com._onchangecb = {
196
+ cb:par for cb,par in com._onchangecb.items()
197
+ if not _closure_refs(cb, discarded)
198
+ }
199
+ if not hasattr(self, 'on_discard'):
200
+ self.on_discard = cln
201
+ else:
202
+ old_func = self.on_discard
203
+ def cln_():
204
+ try:
205
+ old_func(self)
206
+ except TypeError:
207
+ old_func()
208
+ cln()
209
+ self.on_discard = cln_
210
+
211
+ def onchange(self, cb, once=False):
212
+ if once:
213
+ match_attr = once if isinstance(once, str) else None
214
+ l = _argslen(cb)
215
+ fired = [False]
216
+ orig = cb
217
+ def wrapped(attr=None, old=None, internal=False):
218
+ if fired[0]: return
219
+ if match_attr is not None and attr != match_attr: return
220
+ fired[0] = True
221
+ if wrapped in self._onchangecb:
222
+ del self._onchangecb[wrapped]
223
+ if l > 2: orig(attr, old, internal)
224
+ elif l > 1: orig(attr, old)
225
+ elif l > 0: orig(attr)
226
+ else: orig()
227
+ cb = wrapped
228
+ self._onchangecb[cb] = 3
229
+ else:
230
+ self._onchangecb[cb] = _argslen(cb)
231
+ return cb
232
+
233
+ def offchange(self, cb):
234
+ self._onchangecb = {f:par for f,par in self._onchangecb.items() if f is not cb}
235
+
236
+ def _change(self, attr, old):
237
+ internal = self._changing
238
+ self._changing = True
239
+ try:
240
+ for cb, l in list(self._onchangecb.items()):
241
+ if l > 2:
242
+ cb(attr, old, internal)
243
+ elif l > 1:
244
+ cb(attr, old)
245
+ elif l > 0:
246
+ cb(attr)
247
+ else:
248
+ cb()
249
+ finally:
250
+ self._changing = internal
251
+
252
+
253
+ #COMPONENTS
254
+ class _lerpable:
255
+ _angles = frozenset()
256
+ _colors = frozenset()
257
+
258
+ def _lerpbase(self, key):
259
+ return None
260
+
261
+ def _lerpkeep(self, key, value):
262
+ return value
263
+
264
+ @staticmethod
265
+ def _lerpturn(current, target):
266
+ return (target - current + 540) % 360 - 180
267
+
268
+ def _lerpcolor(self, value):
269
+ if isinstance(value, list):
270
+ value = tuple(value)
271
+ return self.scene.styler.rgb(value)
272
+
273
+ def _lerpvalue(self, key, current, target, alpha):
274
+ if key in self._colors:
275
+ goal = self._lerpcolor(target)
276
+ if goal is None:
277
+ raise ValueError('unknown color: ' + repr(target))
278
+ start = self._lerpbase(key) if current is None else self._lerpcolor(current)
279
+ if start is None:
280
+ return target if alpha >= 1 else _HOLD
281
+ return tuple(int(round(a + (b - a) * alpha)) for a, b in zip(start, goal))
282
+ if current is None:
283
+ current = self._lerpbase(key)
284
+ if (isinstance(current, (tuple, list)) and isinstance(target, (tuple, list))
285
+ and len(current) == len(target)):
286
+ parts = [self._lerpvalue(key, a, b, alpha) for a, b in zip(current, target)]
287
+ if any(part is _HOLD for part in parts):
288
+ return target if alpha >= 1 else _HOLD
289
+ return type(current)(parts)
290
+ numeric = (int, float)
291
+ if (isinstance(current, numeric) and isinstance(target, numeric)
292
+ and not isinstance(current, bool) and not isinstance(target, bool)):
293
+ if key in self._angles:
294
+ return current + self._lerpturn(current, target) * alpha
295
+ return current + (target - current) * alpha
296
+ return target if alpha >= 1 else _HOLD
297
+
298
+ def lerp(self, weight=1, **kwargs):
299
+ for key, target in kwargs.items():
300
+ if not hasattr(self, key):
301
+ raise AttributeError('%s has no attribute %r' % (type(self).__name__, key))
302
+ if weight == 1:
303
+ value = target
304
+ else:
305
+ value = self._lerpvalue(key, getattr(self, key), target, weight)
306
+ if value is _HOLD:
307
+ continue
308
+ setattr(self, key, self._lerpkeep(key, value))
309
+ return self
310
+
311
+
312
+ class transform(_change_callback, _lerpable):
313
+ _angles = frozenset(('rotation',))
314
+ _colors = frozenset(('fg', 'bg'))
315
+ _owned = [
316
+ 'x', 'y',
317
+ 'vx', 'vy',
318
+ 'text', 'rtext', 'htext',
319
+ 'vtext', 'vrtext', 'vhtext',
320
+ 'rotation',
321
+ 'aspect',
322
+ 'fg', 'bg', 'alpha', 'bgalpha',
323
+ 'flip',
324
+ 'shadow',
325
+ ]
326
+ _default = True
327
+ _settings = ('x', 'y', 'text', 'rtext', 'htext', 'rotation', 'aspect', 'fg', 'bg', 'alpha', 'bgalpha',
328
+ 'flip', 'shadow')
329
+
330
+ def __init__(self, obj, **kwargs):
331
+ self.obj = obj
332
+ self.scene = obj.scene
333
+ raw = kwargs.get('text', '')
334
+ ctext = obj.scene.recomp.sub('', raw) if raw else ''
335
+ self._x = kwargs.get('x', 0)
336
+ self._y = kwargs.get('y', 0)
337
+ self._vx = kwargs.get('vx', 0)
338
+ self._vy = kwargs.get('vy', 0)
339
+ self._fg = kwargs.get('fg', None)
340
+ self._bg = kwargs.get('bg', None)
341
+ self._alpha = kwargs.get('alpha', None)
342
+ self._bgalpha = kwargs.get('bgalpha', None)
343
+ self._opacity_cache = None
344
+ self._style_cache = {}
345
+ self._styled_for = None
346
+ self._fade = None
347
+ self._plain_layout = None
348
+ self.vtext = raw
349
+ self._vbase = raw
350
+ self._vrtext = raw
351
+ self.vhtext = raw
352
+ self._text = ctext
353
+ self._rtext = ctext
354
+ self._htext = ctext
355
+ self._rotation = kwargs.get('rotation', 0)
356
+ self._rot_cache_angle = None
357
+ self._aspect = kwargs.get('aspect', None)
358
+ self._flip = kwargs.get('flip', None)
359
+ self._shadow = _check_shadow(kwargs.get('shadow', None))
360
+ self._cells_cache = None
361
+ self._anim = None
362
+ self._onsplit = {}
363
+ self._xlen_cache = None
364
+ self._ylen_cache = None
365
+ self._parse_pixels_key = None
366
+ self._parse_pixels_val = None
367
+ self._centroid_cache = {}
368
+ self._bg_cache = {}
369
+ self._rotmap = None
370
+ self._rotcells_v = {}
371
+ self._rotsrc_v = {}
372
+ self._rotmaps_ok = True
373
+ self._rotangle = 0
374
+ self._rotsize = (0, 0)
375
+ self._rotorigin = (0, 0)
376
+ self._ptext = ctext
377
+ self._pvx = 0
378
+ self._pvy = 0
379
+ self._pkey = None
380
+ self._onchangecb = {}
381
+ self._changing = False
382
+ self._vrtext = self._restyle(raw)
383
+ self.rotate(self._rotation, init=True)
384
+ if 'htext' in kwargs:
385
+ self.htext = kwargs['htext']
386
+ elif 'rtext' in kwargs:
387
+ self.rtext = kwargs['rtext']
388
+
389
+ def __getitem__(self, key):
390
+ return getattr(self, key)
391
+
392
+ def __setitem__(self, key, value):
393
+ setattr(self, key, value)
394
+
395
+ def _invalidate(self):
396
+ self._xlen_cache = None
397
+ self._ylen_cache = None
398
+ self._parse_pixels_key = None
399
+ self._parse_pixels_val = None
400
+ self._centroid_cache = {}
401
+ self._rot_cache_angle = None
402
+
403
+ @property
404
+ def x(self):
405
+ return self._x
406
+ @x.setter
407
+ def x(self, value):
408
+ old = self._x
409
+ self._x = value
410
+ self._change('x', old)
411
+
412
+ @property
413
+ def y(self):
414
+ return self._y
415
+ @y.setter
416
+ def y(self, value):
417
+ old = self._y
418
+ self._y = value
419
+ self._change('y', old)
420
+
421
+ @property
422
+ def vx(self):
423
+ return self._vx
424
+ @vx.setter
425
+ def vx(self, value):
426
+ old = self._vx
427
+ self._vx = value
428
+ self._change('vx', old)
429
+
430
+ @property
431
+ def vy(self):
432
+ return self._vy
433
+ @vy.setter
434
+ def vy(self, value):
435
+ old = self._vy
436
+ self._vy = value
437
+ self._change('vy', old)
438
+
439
+ @property
440
+ def text(self):
441
+ return self._text
442
+ @text.setter
443
+ def text(self, value):
444
+ old = self._text
445
+ self._anim = None
446
+ ctext = self.scene.recomp.sub('', value)
447
+ self.vrtext = value
448
+ self.vhtext = value
449
+ self._htext = ctext
450
+ self._rtext = ctext
451
+ self._invalidate()
452
+ self.rotate(self._rotation)
453
+ self._change('text', old)
454
+
455
+ @property
456
+ def rtext(self):
457
+ return self._rtext
458
+ @rtext.setter
459
+ def rtext(self, value):
460
+ old = self._rtext
461
+ ctext = self.scene.recomp.sub('', value)
462
+ self.vrtext = value
463
+ self._rtext = ctext
464
+ self._invalidate()
465
+ self.rotate(self._rotation)
466
+ self._change('rtext', old)
467
+
468
+ @property
469
+ def htext(self):
470
+ return self._htext
471
+ @htext.setter
472
+ def htext(self, value):
473
+ old = self._htext
474
+ self._anim = None
475
+ ctext = self.scene.recomp.sub('', value)
476
+ self.vrtext = value
477
+ self.vhtext = value
478
+ self._htext = ctext
479
+ self._rtext = ctext
480
+ self._invalidate()
481
+ self.rotate(self._rotation)
482
+ self._change('htext', old)
483
+
484
+ @property
485
+ def ptext(self):
486
+ return self._ptext
487
+
488
+ @property
489
+ def pvx(self):
490
+ return self._pvx
491
+
492
+ @property
493
+ def pvy(self):
494
+ return self._pvy
495
+
496
+ @property
497
+ def rotation(self):
498
+ return self._rotation
499
+ @rotation.setter
500
+ def rotation(self, value):
501
+ old = self._rotation
502
+ oldt = self._text
503
+ self._rotation = value
504
+ self.rotate(value)
505
+ self._change('rotation', old)
506
+ self._change('text', oldt)
507
+
508
+ @property
509
+ def aspect(self):
510
+ return self.scene.aspect if self._aspect is None else self._aspect
511
+ @aspect.setter
512
+ def aspect(self, value):
513
+ old = self._aspect
514
+ oldt = self._text
515
+ self._aspect = value
516
+ self._invalidate()
517
+ self.rotate(self._rotation)
518
+ self._change('aspect', old)
519
+ self._change('text', oldt)
520
+
521
+ @property
522
+ def flip(self):
523
+ return self._flip
524
+ @flip.setter
525
+ def flip(self, value):
526
+ old = self._flip
527
+ oldt = self._text
528
+ self._flip = value
529
+ self._invalidate()
530
+ self.rotate(self._rotation)
531
+ self._change('flip', old)
532
+ self._change('text', oldt)
533
+
534
+ @property
535
+ def shadow(self):
536
+ return self._shadow
537
+ @shadow.setter
538
+ def shadow(self, value):
539
+ old = self._shadow
540
+ self._shadow = _check_shadow(value)
541
+ self._change('shadow', old)
542
+
543
+ @property
544
+ def vrtext(self):
545
+ return self._vrtext
546
+ @vrtext.setter
547
+ def vrtext(self, value):
548
+ self._vbase = value
549
+ self._vrtext = self._restyle(value)
550
+
551
+ fg = _styleprop('fg')
552
+ bg = _styleprop('bg')
553
+ alpha = _styleprop('alpha')
554
+ bgalpha = _styleprop('bgalpha')
555
+
556
+ def _setstyle(self, key, value):
557
+ self._fade = None
558
+ self._applystyle(**{key: value})
559
+
560
+ def _applystyle(self, **changes):
561
+ olds = {key: getattr(self, '_' + key) for key in changes}
562
+ for key, value in changes.items():
563
+ setattr(self, '_' + key, value)
564
+ self._style_cache = {}
565
+ self._vrtext = self._restyle(self._vbase)
566
+ self._vrotate()
567
+ for key, old in olds.items():
568
+ self._change(key, old)
569
+
570
+ def _stylecheck(self):
571
+ if self._styled_for is not None and self._styled_for != (self.scene.bgcolor, self.scene.fgcolor):
572
+ self._style_cache = {}
573
+ self._vrtext = self._restyle(self._vbase)
574
+ self._vrotate()
575
+
576
+ def _styled(self, style):
577
+ cached = self._style_cache.get(style)
578
+ if cached is None:
579
+ sample = self.scene.styler.style(style + 'x', color=self._fg, alpha=self._alpha,
580
+ bgcolor=self._bg, bgalpha=self._bgalpha, onbg=True, clean=True)
581
+ cached = sample[:sample.index('x')]
582
+ self._style_cache[style] = cached
583
+ return cached
584
+
585
+ def _restyle(self, base):
586
+ if self._fg is None and self._bg is None and self._alpha is None and self._bgalpha is None:
587
+ self._styled_for = None
588
+ return base
589
+ self._styled_for = (self.scene.bgcolor, self.scene.fgcolor)
590
+ pixels = self.parse_pixels(base)
591
+ last = {}
592
+ widths = {}
593
+ for (ci, ri), (char, _) in pixels.items():
594
+ widths[ri] = max(widths.get(ri, 0), ci + 1)
595
+ if char != ' ':
596
+ last[ri] = max(last.get(ri, -1), ci)
597
+ restyled = {}
598
+ for (ci, ri), (char, style) in pixels.items():
599
+ restyled[(ci, ri)] = (char, self._styled(style) if ci <= last.get(ri, -1) else style)
600
+ return '\n'.join(self.build_rows(restyled, 0, widths.get(ri, 0) - 1, ri, ri)
601
+ for ri in range(len(base.splitlines())))
602
+
603
+ def _has_background(self, style):
604
+ cached = self._bg_cache.get(style)
605
+ if cached is not None:
606
+ return cached
607
+ found = False
608
+ for match in _SGR.finditer(style):
609
+ params = [int(v) if v else 0 for v in match.group(1).split(';')]
610
+ i = 0
611
+ while i < len(params):
612
+ v = params[i]
613
+ if v == 48 or 40 <= v <= 47 or 100 <= v <= 107:
614
+ found = True
615
+ break
616
+ if v == 38:
617
+ i += 2 if i + 1 < len(params) and params[i + 1] == 5 else 4
618
+ i += 1
619
+ if found:
620
+ break
621
+ self._bg_cache[style] = found
622
+ return found
623
+
624
+ def _pixel_centroid(self, txt):
625
+ cached = self._centroid_cache.get(id(txt))
626
+ if cached is not None and cached[0] == txt:
627
+ return cached[1]
628
+ clean = self.scene.recomp.sub('', txt)
629
+ xs, ys = [], []
630
+ for ri, row in enumerate(clean.splitlines()):
631
+ for ci, char in enumerate(row):
632
+ if char not in (' ', '\n'):
633
+ xs.append(ci)
634
+ ys.append(ri)
635
+ result = (sum(xs) / len(xs), sum(ys) / len(ys)) if xs else (0.0, 0.0)
636
+ self._centroid_cache[id(txt)] = (txt, result)
637
+ return result
638
+
639
+ def parse_pixels(self, text):
640
+ if self._parse_pixels_key is not None and self._parse_pixels_key == text:
641
+ return self._parse_pixels_val
642
+ pixels = {}
643
+ current_style = ''
644
+ for ri, line in enumerate(text.splitlines()):
645
+ ci = 0
646
+ for part in self.scene.recomp.split(line):
647
+ if not part:
648
+ continue
649
+ if self.scene.recomp.match(part):
650
+ if part == ansi.RESET:
651
+ current_style = ''
652
+ else:
653
+ current_style += part
654
+ else:
655
+ for char in part:
656
+ pixels[(ci, ri)] = (char, current_style)
657
+ ci += 1
658
+ self._parse_pixels_key = text
659
+ self._parse_pixels_val = pixels
660
+ return pixels
661
+
662
+ def build_rows(self, pixels, min_x, max_x, min_y, max_y):
663
+ out_rows = []
664
+ for ri in range(min_y, max_y + 1):
665
+ row = ''
666
+ prev_style = ''
667
+ for ci in range(min_x, max_x + 1):
668
+ if (ci, ri) in pixels:
669
+ char, style = pixels[(ci, ri)]
670
+ if style != prev_style:
671
+ row += (ansi.RESET if prev_style else '') + style
672
+ prev_style = style
673
+ row += char
674
+ else:
675
+ if prev_style:
676
+ row += ansi.RESET
677
+ prev_style = ''
678
+ row += ' '
679
+ if prev_style:
680
+ row += ansi.RESET
681
+ out_rows.append(row)
682
+ return '\n'.join(out_rows)
683
+
684
+ def merge(self, *layers, dx=0, dy=0, center=True):
685
+ items = []
686
+ for layer in layers:
687
+ if isinstance(layer, str):
688
+ items.append((layer, 0, 0))
689
+ else:
690
+ text, ox, oy = (tuple(layer) + (0, 0))[:3]
691
+ items.append((text, ox, oy))
692
+ if len(items) == 1:
693
+ items.insert(0, (self._vrtext, 0, 0))
694
+ floor = math.floor
695
+
696
+ def middle(pixels):
697
+ cells = [p for p, (char, _) in pixels.items() if char != ' '] or list(pixels)
698
+ if not cells:
699
+ return None
700
+ xs = [p[0] for p in cells]
701
+ ys = [p[1] for p in cells]
702
+ return (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
703
+
704
+ merged = dict(self.parse_pixels(items[0][0]))
705
+ anchor = middle(merged)
706
+ for text, ox, oy in items[1:]:
707
+ pixels = self.parse_pixels(text)
708
+ mid = middle(pixels)
709
+ sx = ox + dx
710
+ sy = -(oy + dy)
711
+ if center and anchor is not None and mid is not None:
712
+ sx += floor(anchor[0] - mid[0] + 0.5)
713
+ sy += floor(anchor[1] - mid[1] + 0.5)
714
+ for (ci, ri), (char, style) in pixels.items():
715
+ if char != ' ' or (style and self._has_background(style)):
716
+ merged[(ci + sx, ri + sy)] = (char, style)
717
+ if not merged:
718
+ return ''
719
+ xs = [p[0] for p in merged]
720
+ ys = [p[1] for p in merged]
721
+ return self.build_rows(merged, min(xs), max(xs), min(ys), max(ys))
722
+
723
+ def cells(self, mode=None):
724
+ mode = self._shadow if mode is None else _check_shadow(mode)
725
+ p = getattr(self.obj, 'physics', None)
726
+ if mode in (None, 'physics'):
727
+ if p is not None:
728
+ return p.getpixels()
729
+ text, ox, oy, full = self._ptext, self._pvx, self._pvy, False
730
+ elif mode == 'hitbox':
731
+ text, ox, oy, full = self._htext, 0, 0, True
732
+ else:
733
+ text, ox, oy, full = self._text, self.vx, self.vy, False
734
+ rx = round(self._x + ox)
735
+ ry = round(self._y + oy)
736
+ key = (full, rx, ry, text)
737
+ cached = self._cells_cache
738
+ if cached is not None and cached[0] == key:
739
+ return cached[1]
740
+ found = set()
741
+ for i, row in enumerate(text.splitlines()):
742
+ for j, char in enumerate(row):
743
+ if full or char != ' ':
744
+ found.add((rx + j, ry - i))
745
+ self._cells_cache = (key, found)
746
+ return found
747
+
748
+ def collides(self, objs, mode=None):
749
+ mine = self.cells(mode)
750
+ if not isinstance(objs, (list, tuple, set)):
751
+ objs = [objs]
752
+ found = {}
753
+ if not mine:
754
+ return found
755
+ for other in objs:
756
+ other = getattr(other, 'obj', other)
757
+ ot = getattr(other, 'transform', None)
758
+ if other is self.obj or ot is None:
759
+ continue
760
+ inter = mine & ot.cells(mode)
761
+ if inter:
762
+ found[other] = inter
763
+ return found
764
+
765
+ def normal(self, other, local=None):
766
+ me = self.obj if local is None else getattr(local, 'obj', local)
767
+ other = getattr(other, 'obj', other)
768
+ mine = me.transform.cells()
769
+ theirs = other.transform.cells()
770
+ aspect = self.scene.aspect
771
+ slope = all(getattr(getattr(o, 'physics', None), 'slope', True) for o in (me, other))
772
+ inside = bool(mine & theirs)
773
+ if inside:
774
+ best = _mtv_cells(mine, theirs, aspect)
775
+ if best is None:
776
+ return None
777
+ nx, ny = best[1], best[2]
778
+ contact = mine & theirs
779
+ else:
780
+ found = None
781
+ for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
782
+ touching = [q for q in mine if (q[0] + dx, q[1] + dy) in theirs]
783
+ if touching and (found is None or len(touching) > len(found[2])):
784
+ found = (-dx, -dy, touching)
785
+ if found is None:
786
+ return None
787
+ nx, ny, contact = found
788
+ n = len(mine)
789
+ center = (sum(q[0] for q in mine) / n, sum(q[1] for q in mine) / n)
790
+ point = _contact_point_cells(contact, nx, ny, center)
791
+ gx, gy = _contact_normal_cells(mine, theirs, point, nx, ny, inside, slope, aspect)
792
+ return math.degrees(math.atan2(gx, gy)) % 360
793
+
794
+ def tolocal(self, pixels):
795
+ rx = round(self._x + self.vx)
796
+ ry = round(self._y + self.vy)
797
+ local_pixels = set()
798
+ for gx, gy in pixels:
799
+ local_pixels.add((gx - rx, ry - gy))
800
+ return local_pixels
801
+
802
+ def toworld(self, pixels):
803
+ rx = round(self._x + self.vx)
804
+ ry = round(self._y + self.vy)
805
+ local_pixels = set()
806
+ for gx, gy in pixels:
807
+ local_pixels.add((gx + rx, ry + gy))
808
+ return local_pixels
809
+
810
+ def _rotator(self, angle_degrees):
811
+ angle = math.radians(angle_degrees)
812
+ cos_a = math.cos(angle)
813
+ sin_a = math.sin(angle)
814
+ if self._flip is None:
815
+ ma, mb, mc, md = cos_a, -sin_a, sin_a, cos_a
816
+ ja, jb, jc, jd = cos_a, sin_a, -sin_a, cos_a
817
+ else:
818
+ mirror = math.radians(2 * self._flip)
819
+ fa, fb, fc, fd = -math.cos(mirror), -math.sin(mirror), -math.sin(mirror), math.cos(mirror)
820
+ ma, mb = cos_a * fa - sin_a * fc, cos_a * fb - sin_a * fd
821
+ mc, md = sin_a * fa + cos_a * fc, sin_a * fb + cos_a * fd
822
+ ja, jb = fa * cos_a - fb * sin_a, fa * sin_a + fb * cos_a
823
+ jc, jd = fc * cos_a - fd * sin_a, fc * sin_a + fd * cos_a
824
+ ASPECT = self.aspect
825
+ floor = math.floor
826
+ ident = self._flip is None and angle_degrees % 360 == 0
827
+
828
+ def layout(source):
829
+ glyphs = [p for p, px in source.items() if px[0] != ' ']
830
+ if not glyphs:
831
+ return None
832
+ count = len(glyphs)
833
+ cx = sum(p[0] for p in glyphs) / count
834
+ cy = sum(p[1] for p in glyphs) / count
835
+ if ident:
836
+ xs = [p[0] for p in source]
837
+ ys = [p[1] for p in glyphs]
838
+ return glyphs, cx, cy, {p: p for p in glyphs}, {}, min(xs), max(xs), min(ys), max(ys)
839
+
840
+ spots = []
841
+ for ci, ri in glyphs:
842
+ u = (ci - cx) * ASPECT
843
+ v = ri - cy
844
+ fx = cx + (u * ma + v * mb) / ASPECT
845
+ fy = cy + (u * mc + v * md)
846
+ gx = floor(fx + 0.5 + 1e-7)
847
+ gy = floor(fy + 0.5 + 1e-7)
848
+ spots.append((((fx - gx) * ASPECT) ** 2 + (fy - gy) ** 2, ri, ci, fx, fy, gx, gy))
849
+ spots.sort()
850
+
851
+ placed = {}
852
+ loose = []
853
+ for spot in spots:
854
+ cell = (spot[5], spot[6])
855
+ if cell in placed:
856
+ loose.append(spot)
857
+ else:
858
+ placed[cell] = (spot[2], spot[1])
859
+ for _, ri, ci, fx, fy, gx, gy in loose:
860
+ reach_x = 2
861
+ reach_y = 1
862
+ best = None
863
+ while best is None:
864
+ for ny in range(gy - reach_y, gy + reach_y + 1):
865
+ for nx in range(gx - reach_x, gx + reach_x + 1):
866
+ if (nx, ny) in placed:
867
+ continue
868
+ rx = (nx - cx) * ASPECT
869
+ ry = ny - cy
870
+ hit = (floor((rx * ja + ry * jb) / ASPECT + cx + 0.5 + 1e-7),
871
+ floor(rx * jc + ry * jd + cy + 0.5 + 1e-7)) in source
872
+ cost = ((nx - fx) * ASPECT) ** 2 + (ny - fy) ** 2 - 0.02 * (nx - fx) * ASPECT + (0 if hit else 1)
873
+ key = (round(cost, 6), ny, -nx)
874
+ if best is None or key < best:
875
+ best = key
876
+ reach_x *= 2
877
+ reach_y *= 2
878
+ placed[(-best[2], best[1])] = (ci, ri)
879
+
880
+ where = {src: cell for cell, src in placed.items()}
881
+ extra = {}
882
+ for (ci, ri), a in where.items():
883
+ for dx, dy in _NEIGHBOURS_HALF:
884
+ b = where.get((ci + dx, ri + dy))
885
+ if b is None:
886
+ continue
887
+ ex = b[0] - a[0]
888
+ ey = b[1] - a[1]
889
+ if -1 <= ex <= 1 and -1 <= ey <= 1:
890
+ continue
891
+ steps = max(abs(ex), abs(ey))
892
+ for k in range(1, steps):
893
+ f = k / steps
894
+ cell = (floor(a[0] + ex * f + 0.5 + 1e-7), floor(a[1] + ey * f + 0.5 + 1e-7))
895
+ if cell not in placed and cell not in extra:
896
+ extra[cell] = (ci, ri) if f < 0.5 else (ci + dx, ri + dy)
897
+
898
+ around = {}
899
+ for group in (placed, extra):
900
+ for x, y in group:
901
+ for dx, dy in _NEIGHBOURS4:
902
+ c = (x + dx, y + dy)
903
+ around[c] = around.get(c, 0) + 1
904
+ for (nx, ny), n in around.items():
905
+ if n < 4 or (nx, ny) in placed or (nx, ny) in extra:
906
+ continue
907
+ rx = (nx - cx) * ASPECT
908
+ ry = ny - cy
909
+ ci = floor((rx * ja + ry * jb) / ASPECT + cx + 0.5 + 1e-7)
910
+ ri = floor(rx * jc + ry * jd + cy + 0.5 + 1e-7)
911
+ px = source.get((ci, ri))
912
+ if px is not None and px[0] != ' ':
913
+ extra[(nx, ny)] = (ci, ri)
914
+
915
+ min_x = min(c[0] for c in placed)
916
+ max_x = max(c[0] for c in placed)
917
+ min_y = min(c[1] for c in placed)
918
+ max_y = max(c[1] for c in placed)
919
+ for cell in extra:
920
+ if cell[0] < min_x:
921
+ min_x = cell[0]
922
+ if cell[0] > max_x:
923
+ max_x = cell[0]
924
+ if cell[1] < min_y:
925
+ min_y = cell[1]
926
+ if cell[1] > max_y:
927
+ max_y = cell[1]
928
+ for (ci, ri), px in source.items():
929
+ if px[0] != ' ':
930
+ continue
931
+ gx = floor(cx + (((ci - cx) * ASPECT) * ma + (ri - cy) * mb) / ASPECT + 0.5 + 1e-7)
932
+ if gx < min_x:
933
+ min_x = gx
934
+ if gx > max_x:
935
+ max_x = gx
936
+ return glyphs, cx, cy, placed, extra, min_x, max_x, min_y, max_y
937
+
938
+ def render(source, lay, record=False, size=(0, 0), maps=False):
939
+ glyphs, cx, cy, placed, extra, min_x, max_x, min_y, max_y = lay
940
+ new_pixels = {}
941
+ cells = {}
942
+ sources = {}
943
+ resample = not ident and len({px[1] for px in source.values() if px[0] != ' '}) > 1
944
+ for group in (placed, extra):
945
+ for cell, src in group.items():
946
+ chosen = source[src]
947
+ if resample:
948
+ rx = (cell[0] - cx) * ASPECT
949
+ ry = cell[1] - cy
950
+ near = source.get((floor((rx * ja + ry * jb) / ASPECT + cx + 0.5 + 1e-7),
951
+ floor(rx * jc + ry * jd + cy + 0.5 + 1e-7)))
952
+ if near is not None and near[0] == chosen[0]:
953
+ chosen = near
954
+ new_pixels[cell] = chosen
955
+ if maps:
956
+ cells[cell] = [src]
957
+ sources.setdefault(src, []).append(cell)
958
+ if any(px[0] == ' ' and px[1] and self._has_background(px[1]) for px in source.values()):
959
+ for ny in range(min_y, max_y + 1):
960
+ ry = ny - cy
961
+ for nx in range(min_x, max_x + 1):
962
+ if (nx, ny) in new_pixels:
963
+ continue
964
+ rx = (nx - cx) * ASPECT
965
+ ci = floor((rx * ja + ry * jb) / ASPECT + cx + 0.5 + 1e-7)
966
+ ri = floor(rx * jc + ry * jd + cy + 0.5 + 1e-7)
967
+ px = source.get((ci, ri))
968
+ if px is not None and px[0] == ' ' and px[1] and self._has_background(px[1]):
969
+ new_pixels[(nx, ny)] = px
970
+ if record:
971
+ self._rotmap = (cx, cy, min_x, min_y, ASPECT, ja, jb, jc, jd)
972
+ self._rotsize = size
973
+ self._rotorigin = (min_x, min_y)
974
+ if maps:
975
+ self._rotcells_v = cells
976
+ self._rotsrc_v = sources
977
+ return self.build_rows(new_pixels, min_x, max_x, min_y, max_y)
978
+
979
+ layout.ident = ident
980
+ return layout, render
981
+
982
+ def _vrender(self, layout, render, plain_layout):
983
+ styled_source = self.parse_pixels(self._vrtext)
984
+ if plain_layout is None:
985
+ styled_layout = layout(styled_source) if self._vrtext.splitlines() else None
986
+ elif [p for p, px in styled_source.items() if px[0] != ' '] == plain_layout[0]:
987
+ styled_layout = plain_layout
988
+ else:
989
+ styled_layout = layout(styled_source)
990
+ self.vtext = render(styled_source, styled_layout) if styled_layout is not None else ''
991
+
992
+ def _keeps_layout(self):
993
+ return (self._fade is not None or self._fg is not None or self._bg is not None
994
+ or self._alpha is not None or self._bgalpha is not None)
995
+
996
+ def _vrotate(self):
997
+ layout, render = self._rotator(self._rotation)
998
+ plain = self._plain_layout
999
+ if plain is None and not layout.ident and self._rtext.splitlines():
1000
+ plain = layout(self.parse_pixels(self._rtext))
1001
+ if self._keeps_layout():
1002
+ self._plain_layout = plain
1003
+ self._vrender(layout, render, plain)
1004
+ self._parse_pixels_key = None
1005
+ self._parse_pixels_val = None
1006
+
1007
+ def rotate(self, angle_degrees, init=False):
1008
+ if not init:
1009
+ rounded = round(angle_degrees * 20) / 20
1010
+ if self._rot_cache_angle == rounded:
1011
+ return
1012
+ self._rot_cache_angle = rounded
1013
+
1014
+ layout, render = self._rotator(angle_degrees)
1015
+ plain = self._rtext
1016
+ crows = plain.splitlines()
1017
+ plain_source = self.parse_pixels(plain)
1018
+ plain_layout = layout(plain_source) if crows else None
1019
+ self._plain_layout = plain_layout if not layout.ident and self._keeps_layout() else None
1020
+ self._rotangle = angle_degrees
1021
+ self._vrender(layout, render, plain_layout)
1022
+ if plain_layout is not None:
1023
+ self._text = render(plain_source, plain_layout, True, (max(len(r) for r in crows), len(crows)))
1024
+ self._rotcells_v = {}
1025
+ self._rotsrc_v = {}
1026
+ self._rotmaps_ok = False
1027
+ else:
1028
+ self._text = ''
1029
+ self._rotmap = None
1030
+ self._rotcells_v = {}
1031
+ self._rotsrc_v = {}
1032
+ self._rotmaps_ok = True
1033
+ self._rotsize = (0, 0)
1034
+ self._rotorigin = (0, 0)
1035
+ self._xlen_cache = None
1036
+ self._ylen_cache = None
1037
+ self._protate(angle_degrees, layout, render)
1038
+ self._parse_pixels_key = None
1039
+ self._parse_pixels_val = None
1040
+
1041
+ self.vx = self._rotorigin[0]
1042
+ self.vy = -self._rotorigin[1]
1043
+
1044
+ def _protate(self, angle_degrees, layout, render):
1045
+ if self._htext == self._rtext:
1046
+ self._ptext = self._text
1047
+ self._pvx = self._rotorigin[0]
1048
+ self._pvy = -self._rotorigin[1]
1049
+ self._pkey = None
1050
+ return
1051
+ key = (angle_degrees, self._htext, self._flip, self.aspect)
1052
+ if self._pkey == key:
1053
+ return
1054
+ self._pkey = key
1055
+ source = self.parse_pixels(self._htext)
1056
+ lay = layout(source) if self._htext.splitlines() else None
1057
+ if lay is None:
1058
+ self._ptext = ''
1059
+ self._pvx = 0
1060
+ self._pvy = 0
1061
+ else:
1062
+ self._ptext = render(source, lay)
1063
+ self._pvx = lay[5]
1064
+ self._pvy = -lay[7]
1065
+
1066
+ def _rotmaps(self):
1067
+ if self._rotmaps_ok:
1068
+ return
1069
+ self._rotmaps_ok = True
1070
+ plain = self._rtext
1071
+ if not plain.splitlines():
1072
+ return
1073
+ layout, render = self._rotator(self._rotangle)
1074
+ source = self.parse_pixels(plain)
1075
+ lay = layout(source)
1076
+ if lay is not None:
1077
+ render(source, lay, True, self._rotsize, True)
1078
+
1079
+ @property
1080
+ def _rotcells(self):
1081
+ self._rotmaps()
1082
+ return self._rotcells_v
1083
+
1084
+ @property
1085
+ def _rotsrc(self):
1086
+ self._rotmaps()
1087
+ return self._rotsrc_v
1088
+
1089
+ def _origin(self):
1090
+ return round(self._x + self.vx), round(self._y + self.vy)
1091
+
1092
+ def _cell_world(self, cell):
1093
+ m = self._rotmap
1094
+ ox, oy = self._origin()
1095
+ return ox + (cell[0] - m[2]), oy - (cell[1] - m[3])
1096
+
1097
+ def rtext_to_world(self, ci, ri):
1098
+ if self._rotmap is None:
1099
+ return None
1100
+ cells = self._rotsrc.get((ci, ri))
1101
+ if not cells:
1102
+ return None
1103
+ return self._cell_world(min(cells))
1104
+
1105
+ def worldcells(self):
1106
+ if self._rotmap is None:
1107
+ return set()
1108
+ return {self._cell_world(c) for c in self._rotcells}
1109
+
1110
+ def world_to_rtext_all(self, wx, wy):
1111
+ m = self._rotmap
1112
+ if m is None:
1113
+ return []
1114
+ ox, oy = self._origin()
1115
+ return self._rotcells.get((wx - ox + m[2], oy - wy + m[3]), [])
1116
+
1117
+ def world_to_rtext(self, wx, wy):
1118
+ found = self.world_to_rtext_all(wx, wy)
1119
+ return found[0] if found else None
1120
+
1121
+ def world_to_rtextf(self, wx, wy):
1122
+ m = self._rotmap
1123
+ if m is None:
1124
+ return None
1125
+ cx, cy, min_x, min_y, ASPECT, ja, jb, jc, jd = m
1126
+ ox, oy = self._origin()
1127
+ rx_n = (wx - ox + min_x - cx) * ASPECT
1128
+ ry_n = oy - wy + min_y - cy
1129
+ rel_x = (rx_n * ja + ry_n * jb) / ASPECT
1130
+ rel_y = rx_n * jc + ry_n * jd
1131
+ return rel_x + cx, rel_y + cy
1132
+
1133
+ def cutline(self, p0, p1):
1134
+ a = self.world_to_rtextf(*p0)
1135
+ b = self.world_to_rtextf(*p1)
1136
+ if a is None or b is None:
1137
+ return set()
1138
+ width, height = self._rotsize
1139
+ if (max(a[0], b[0]) < -1 or min(a[0], b[0]) > width
1140
+ or max(a[1], b[1]) < -1 or min(a[1], b[1]) > height):
1141
+ return set()
1142
+ steps = max(1, int(math.ceil(max(abs(b[0] - a[0]), abs(b[1] - a[1])) * 2)))
1143
+ pixels = self.parse_pixels(self.vrtext)
1144
+ walls = set()
1145
+ for i in range(steps + 1):
1146
+ u = i / steps
1147
+ c = (round(a[0] + (b[0] - a[0]) * u), round(a[1] + (b[1] - a[1]) * u))
1148
+ if c in pixels and pixels[c][0] != ' ':
1149
+ walls.add(c)
1150
+ return walls
1151
+
1152
+ def _clear(self, cells):
1153
+ styled = self.parse_pixels(self._vbase)
1154
+ hit = [c for c in cells if c in styled and styled[c][0] != ' ']
1155
+ if not hit:
1156
+ return False
1157
+ styled = dict(styled)
1158
+ for c in hit:
1159
+ styled[c] = (' ', '')
1160
+ ref = next((p for p, (char, _) in styled.items() if char != ' ' and self._rotsrc.get(p)), None)
1161
+ before = self.rtext_to_world(*ref) if ref is not None else None
1162
+ xs = [p[0] for p in styled]
1163
+ ys = [p[1] for p in styled]
1164
+ phys = getattr(self.obj, 'physics', None)
1165
+ old_mass = phys.mass if phys is not None else None
1166
+ rows = self.build_rows(styled, min(xs), max(xs), min(ys), max(ys))
1167
+ self._htext = self.scene.recomp.sub('', rows)
1168
+ self.vhtext = rows
1169
+ self.rtext = rows
1170
+ if old_mass and phys.mass != old_mass:
1171
+ k = phys.mass / old_mass
1172
+ phys.xvel *= k
1173
+ phys.yvel *= k
1174
+ phys.avel *= k
1175
+ after = self.rtext_to_world(ref[0] - min(xs), ref[1] - min(ys)) if ref is not None else None
1176
+ if before is not None and after is not None:
1177
+ if before[0] != after[0]:
1178
+ self.x = self._x + before[0] - after[0]
1179
+ if before[1] != after[1]:
1180
+ self.y = self._y + before[1] - after[1]
1181
+ return True
1182
+
1183
+ def cut(self, wx, wy, r=1, shape='circle'):
1184
+ return self.cutpoints([(wx, wy)], r, shape)
1185
+
1186
+ def cutpoints(self, points, r=1, shape='circle'):
1187
+ offsets = _footprint(r, self.aspect, shape)
1188
+ cells = []
1189
+ for wx, wy in points:
1190
+ for dx, dy in offsets:
1191
+ cells.extend(self.world_to_rtext_all(wx + dx, wy + dy))
1192
+ return self._clear(cells)
1193
+
1194
+ def _notify_whole(self, callback):
1195
+ if callable(callback):
1196
+ l = _argslen(callback)
1197
+ if l > 1: callback([self.obj], self.obj)
1198
+ elif l > 0: callback([self.obj])
1199
+ else: callback()
1200
+
1201
+ def _fragments(self, coords, diagonal=True, walls=()):
1202
+ walls = set(walls)
1203
+ neighbours = _NEIGHBOURS8 if diagonal else _NEIGHBOURS4
1204
+ remaining = set(coords)
1205
+ components = []
1206
+ while remaining:
1207
+ group = set()
1208
+ stack = [next(iter(remaining))]
1209
+ while stack:
1210
+ pos = stack.pop()
1211
+ if pos not in remaining:
1212
+ continue
1213
+ remaining.remove(pos)
1214
+ group.add(pos)
1215
+ cx, cy = pos
1216
+ for dx, dy in neighbours:
1217
+ nxt = (cx + dx, cy + dy)
1218
+ if nxt not in remaining:
1219
+ continue
1220
+ if dx and dy and (cx + dx, cy) in walls and (cx, cy + dy) in walls:
1221
+ continue
1222
+ stack.append(nxt)
1223
+ components.append(group)
1224
+ return components
1225
+
1226
+ def _spawn_from_components(self, components, pixels, callback=None):
1227
+ rotation = self._rotation
1228
+ ox, oy = self._x, self._y
1229
+ parent = getattr(self.obj, 'physics', None)
1230
+ motion = None
1231
+ if parent is not None:
1232
+ A = self.scene.aspect
1233
+ pcx, pcy, _ = parent.body()
1234
+ motion = (parent.xvel / parent.mass * A, parent.yvel / parent.mass, parent._omega(),
1235
+ parent.avel / parent.mass, pcx, pcy)
1236
+
1237
+ fading = dict(self._fade) if self._fade is not None else None
1238
+ spawns = []
1239
+ anchors = []
1240
+ for group in components:
1241
+ xs = [p[0] for p in group]
1242
+ ys = [p[1] for p in group]
1243
+ min_x, max_x = min(xs), max(xs) + 1
1244
+ min_y, max_y = min(ys), max(ys) + 1
1245
+ target = {self._cell_world(c) for p in group for c in self._rotsrc.get(p, ())}
1246
+
1247
+ kw = self.obj.kwargs()
1248
+ for key in ('mass', 'rtext', 'htext', 'vtext', 'vrtext', 'vhtext', 'vx', 'vy', 'xvel', 'yvel', 'avel', 'joints'):
1249
+ kw.pop(key, None)
1250
+ if parent is not None:
1251
+ kw.update(dynamic=parent.dynamic, bounciness=parent.bounciness, friction=parent.friction)
1252
+ if self._aspect is not None:
1253
+ kw['aspect'] = self._aspect
1254
+ for key in ('fg', 'bg', 'alpha', 'bgalpha'):
1255
+ value = getattr(self, key)
1256
+ if value is not None:
1257
+ kw[key] = value
1258
+ spawns.append({
1259
+ **kw,
1260
+ 'text': self.build_rows({p: pixels[p] for p in group}, min_x, max_x - 1, min_y, max_y - 1),
1261
+ 'x': ox + min_x,
1262
+ 'y': oy - min_y,
1263
+ 'rotation': rotation,
1264
+ 'flip': self._flip,
1265
+ })
1266
+ anchors.append(target)
1267
+
1268
+ obj_ref = self.obj
1269
+ def do_split(spawns=spawns, anchors=anchors, obj_ref=obj_ref, cb=callback, motion=motion, fading=fading):
1270
+ parts = []
1271
+ for kw, target in zip(spawns, anchors):
1272
+ part = obj_ref.scene.obj().create(**kw)
1273
+ ft = part.transform
1274
+ if fading is not None:
1275
+ ft._fade = dict(fading)
1276
+ placed = ft.worldcells()
1277
+ if target and placed:
1278
+ dx = round(sum(w[0] for w in target) / len(target) - sum(w[0] for w in placed) / len(placed))
1279
+ dy = round(sum(w[1] for w in target) / len(target) - sum(w[1] for w in placed) / len(placed))
1280
+ best = min(
1281
+ ((sx, sy) for sx in range(dx - 1, dx + 2) for sy in range(dy - 1, dy + 2)),
1282
+ key=lambda s: len({(wx + s[0], wy + s[1]) for wx, wy in placed} ^ target)
1283
+ )
1284
+ ft.x = ft.x + best[0]
1285
+ ft.y = ft.y + best[1]
1286
+ fp = getattr(part, 'physics', None)
1287
+ if fp is not None and motion is not None:
1288
+ vx, vy, omega, spin, pcx, pcy = motion
1289
+ fcx, fcy, _ = fp.body()
1290
+ A = obj_ref.scene.aspect
1291
+ rx, ry = (fcx - pcx) * A, fcy - pcy
1292
+ fp.xvel = (vx - omega * ry) / A * fp.mass
1293
+ fp.yvel = (vy + omega * rx) * fp.mass
1294
+ fp.avel = spin * fp.mass
1295
+ parts.append(part)
1296
+ if getattr(obj_ref, 'joint', None) is not None:
1297
+ joint.transfer(obj_ref, parts)
1298
+ if callable(cb):
1299
+ l = _argslen(cb)
1300
+ if l > 1: cb(parts, obj_ref)
1301
+ elif l > 0: cb(parts)
1302
+ else: cb()
1303
+ for calb, cl in list(self._onsplit.items()):
1304
+ if cl > 1: calb(parts, obj_ref)
1305
+ elif cl > 0: calb(parts)
1306
+ else: calb()
1307
+ obj_ref.discard()
1308
+ do_split()
1309
+ return do_split
1310
+
1311
+ def split(self, callback=None, diagonal=True):
1312
+ if getattr(self.obj, 'transform', None) is not self:
1313
+ return
1314
+ pixels = self.parse_pixels(self._vbase)
1315
+ coords = {pos for pos, (char, _) in pixels.items() if char != ' '}
1316
+ if not coords:
1317
+ self._notify_whole(callback)
1318
+ self.obj.discard()
1319
+ return
1320
+
1321
+ components = self._fragments(coords, diagonal)
1322
+ if len(components) < 2:
1323
+ self._notify_whole(callback)
1324
+ return
1325
+
1326
+ self._split_cb = self._spawn_from_components(components, pixels, callback)
1327
+ return self._split_cb
1328
+
1329
+ def split_at(self, wall_pixels, callback=None, diagonal=True):
1330
+ if getattr(self.obj, 'transform', None) is not self:
1331
+ return
1332
+ pixels = self.parse_pixels(self._vbase)
1333
+ coords = {pos for pos, (char, _) in pixels.items() if char != ' '}
1334
+
1335
+ valid_walls = set(wall_pixels) & coords
1336
+ if not valid_walls:
1337
+ self._notify_whole(callback)
1338
+ return
1339
+
1340
+ components = self._fragments(coords - valid_walls, diagonal, valid_walls)
1341
+ if len(components) < 2:
1342
+ self._notify_whole(callback)
1343
+ return
1344
+
1345
+ neighbours = _NEIGHBOURS8 if diagonal else _NEIGHBOURS4
1346
+ remaining = set(valid_walls)
1347
+ grew = True
1348
+ while remaining and grew:
1349
+ grew = False
1350
+ for wp in sorted(remaining):
1351
+ cx, cy = wp
1352
+ for comp in components:
1353
+ if any((cx + dx, cy + dy) in comp for dx, dy in neighbours):
1354
+ comp.add(wp)
1355
+ remaining.discard(wp)
1356
+ grew = True
1357
+ break
1358
+ if remaining:
1359
+ components.extend(self._fragments(remaining, diagonal))
1360
+
1361
+ self._split_cb = self._spawn_from_components(components, pixels, callback)
1362
+ return self._split_cb
1363
+
1364
+ def onsplit(self, cb, once=False):
1365
+ if once:
1366
+ l = _argslen(cb)
1367
+ fired = [False]
1368
+ orig = cb
1369
+ def wrapped(parts=None, obj_ref=None):
1370
+ if fired[0]: return
1371
+ fired[0] = True
1372
+ if wrapped in self._onsplit:
1373
+ del self._onsplit[wrapped]
1374
+ if l > 1: orig(parts, obj_ref)
1375
+ elif l > 0: orig(parts)
1376
+ else: orig()
1377
+ cb = wrapped
1378
+ self._onsplit[cb] = 2
1379
+ else:
1380
+ self._onsplit[cb] = _argslen(cb)
1381
+ return cb
1382
+
1383
+ def offsplit(self, cb):
1384
+ self._onsplit = {f:par for f,par in self._onsplit.items() if f is not cb}
1385
+
1386
+ def xlen(self, text=None):
1387
+ if text is None:
1388
+ if self._xlen_cache is not None:
1389
+ return self._xlen_cache
1390
+ text = self._text
1391
+ lens = []
1392
+ for row in text.splitlines():
1393
+ for i, char in enumerate(row):
1394
+ if char != ' ':
1395
+ lens.append(i)
1396
+ result = abs(max(lens) - (min(lens) - 1)) if lens else 0
1397
+ self._xlen_cache = result
1398
+ return result
1399
+ lens = []
1400
+ for row in text.splitlines():
1401
+ for i, char in enumerate(row):
1402
+ if char != ' ':
1403
+ lens.append(i)
1404
+ return abs(max(lens) - (min(lens) - 1)) if lens else 0
1405
+
1406
+ def ylen(self, text=None):
1407
+ if text is None:
1408
+ if self._ylen_cache is not None:
1409
+ return self._ylen_cache
1410
+ text = self._text
1411
+ count = 0
1412
+ for row in text.splitlines():
1413
+ if any(c != ' ' and c != '\n' for c in row):
1414
+ count += 1
1415
+ self._ylen_cache = count
1416
+ return count
1417
+ count = 0
1418
+ for row in text.splitlines():
1419
+ if any(c != ' ' and c != '\n' for c in row):
1420
+ count += 1
1421
+ return count
1422
+
1423
+ def xc(self, text=None):
1424
+ return self.xlen(text) / 2
1425
+
1426
+ def yc(self, text=None):
1427
+ return self.ylen(text) / 2
1428
+
1429
+ def extent(self, local=False):
1430
+ rows = self._text.splitlines()
1431
+ cols = [i for row in rows for i, char in enumerate(row) if char != ' ']
1432
+ px, py = (0, 0) if local else (self._x + self.vx, self._y + self.vy)
1433
+ if not cols:
1434
+ return px, py, 0, 0
1435
+ lo, hi = min(cols), max(cols) + 1
1436
+ return (px + (lo + hi) / 2, py - len(rows) / 2,
1437
+ (hi - lo) / 2, len(rows) / 2)
1438
+
1439
+ def center(self, local=False):
1440
+ return self.extent(local)[:2]
1441
+
1442
+ def forward(self, move=1, offset=0, angle=None):
1443
+ angle = math.radians((self._rotation if angle is None else angle) + offset)
1444
+ x = math.sin(angle) * move
1445
+ y = math.cos(angle) * self.aspect * move
1446
+ return (x, y)
1447
+
1448
+ def pivot(self, local=False):
1449
+ m = self._rotmap
1450
+ if m is None:
1451
+ return (0.0, 0.0) if local else (self._x + self.vx, self._y + self.vy)
1452
+ if local:
1453
+ return m[0], -m[1]
1454
+ ox, oy = self._origin()
1455
+ return ox + m[0] - m[2], oy - (m[1] - m[3])
1456
+
1457
+ def point(self, tx, ty, offset=0, origin=None):
1458
+ ox, oy = self.pivot() if origin is None else origin
1459
+ dx = tx - ox
1460
+ dy = ty - oy
1461
+ ASPECT = self.aspect
1462
+ angle = math.degrees(math.atan2(-dy, dx * ASPECT))+90
1463
+ return angle + offset
1464
+
1465
+ def aim(self, obj, offset=0):
1466
+ return self.point(*obj.transform.pivot(), offset=offset)
1467
+
1468
+ def alerp(self, angle):
1469
+ return (angle - self._rotation + 540) % 360 - 180
1470
+
1471
+ def bounce(self, normal, angle=None):
1472
+ if isinstance(normal, (tuple, list)):
1473
+ normal = math.degrees(math.atan2(normal[0], normal[1]))
1474
+ heading = self._rotation if angle is None else angle
1475
+ return (2 * normal - heading + 180) % 360
1476
+
1477
+ @property
1478
+ def animating(self):
1479
+ return self._anim is not None
1480
+
1481
+ def _frame(self, frame):
1482
+ self.rtext = self.vhtext if frame is None else frame
1483
+
1484
+ def animate(self, frames, delay=1, loop=True, callback=None, restore=False):
1485
+ frames = list(frames)
1486
+ if not frames:
1487
+ return self.unanimate()
1488
+ delays = list(delay) if isinstance(delay, (list, tuple)) else [delay] * len(frames)
1489
+ if len(delays) != len(frames):
1490
+ raise ValueError('delay must be a number or have one entry per frame')
1491
+ if any(d <= 0 for d in delays):
1492
+ raise ValueError('delay must be positive')
1493
+ passes = None if loop is True else max(1, int(loop))
1494
+ self._anim = {'frames': frames, 'delays': delays, 'index': 0, 'passes': passes,
1495
+ 'cb': callback, 'restore': restore, 'wait': delays[0]}
1496
+ self._frame(frames[0])
1497
+ return self
1498
+
1499
+ def unanimate(self, restore=True):
1500
+ was = self._anim is not None
1501
+ self._anim = None
1502
+ if was and restore:
1503
+ self.rtext = self.vhtext
1504
+ return self
1505
+
1506
+ def _tick(self):
1507
+ if self._fade is not None:
1508
+ self._tickfade()
1509
+ self._tickanim()
1510
+
1511
+ @property
1512
+ def fading(self):
1513
+ return self._fade is not None
1514
+
1515
+ def fade(self, alpha=0, time=1, delay=0, bgalpha=None, start=None, callback=None, discard=False, ease='linear'):
1516
+ if time < 0 or delay < 0:
1517
+ raise ValueError('time and delay must not be negative')
1518
+ curve = _easing(ease)
1519
+ a0 = 1.0 if self._alpha is None else self._alpha
1520
+ b0 = 1.0 if self._bgalpha is None else self._bgalpha
1521
+ if start is not None:
1522
+ a0, b0 = start if isinstance(start, (tuple, list)) else (start, start)
1523
+ self._fade = None
1524
+ self._applystyle(alpha=a0, bgalpha=b0)
1525
+ self._fade = {'a0': a0, 'b0': b0, 'a1': alpha, 'b1': alpha if bgalpha is None else bgalpha,
1526
+ 'time': time, 'delay': delay, 'elapsed': 0.0, 'cb': callback,
1527
+ 'discard': discard, 'ease': curve}
1528
+ if time <= 0 and delay <= 0:
1529
+ self._finishfade()
1530
+ return self
1531
+
1532
+ def unfade(self, finish=False):
1533
+ f = self._fade
1534
+ self._fade = None
1535
+ if f is not None and finish:
1536
+ self._fadeto(f, 1.0)
1537
+ return self
1538
+
1539
+ def _fadeto(self, f, progress):
1540
+ if progress >= 1:
1541
+ a, b = f['a1'], f['b1']
1542
+ else:
1543
+ p = f['ease'](progress)
1544
+ a = f['a0'] + (f['a1'] - f['a0']) * p
1545
+ b = f['b0'] + (f['b1'] - f['b0']) * p
1546
+ a = max(0.0, min(1.0, a))
1547
+ b = max(0.0, min(1.0, b))
1548
+ self._applystyle(alpha=None if a >= 1 else a, bgalpha=None if b >= 1 else b)
1549
+
1550
+ def _finishfade(self):
1551
+ f = self._fade
1552
+ self._fade = None
1553
+ self._fadeto(f, 1.0)
1554
+ cb = f['cb']
1555
+ if callable(cb):
1556
+ if _argslen(cb):
1557
+ cb(self.obj)
1558
+ else:
1559
+ cb()
1560
+ if f['discard'] and getattr(self.obj, 'transform', None) is self:
1561
+ self.obj.discard()
1562
+
1563
+ def _tickfade(self):
1564
+ f = self._fade
1565
+ dt = 1 / self.scene.framerate
1566
+ if f['delay'] > 0:
1567
+ f['delay'] -= dt
1568
+ if f['delay'] > 0:
1569
+ return
1570
+ dt = -f['delay']
1571
+ f['delay'] = 0
1572
+ f['elapsed'] += dt
1573
+ if f['elapsed'] >= f['time']:
1574
+ self._finishfade()
1575
+ else:
1576
+ self._fadeto(f, f['elapsed'] / f['time'])
1577
+
1578
+ def _tickanim(self):
1579
+ a = self._anim
1580
+ if a is None:
1581
+ return
1582
+ a['wait'] -= 1
1583
+ while a['wait'] <= 0 and self._anim is a:
1584
+ a['index'] += 1
1585
+ if a['index'] >= len(a['frames']):
1586
+ if a['passes'] is not None:
1587
+ a['passes'] -= 1
1588
+ if a['passes'] <= 0:
1589
+ self._anim = None
1590
+ if a['restore']:
1591
+ self.rtext = self.vhtext
1592
+ cb = a['cb']
1593
+ if callable(cb):
1594
+ if _argslen(cb):
1595
+ cb(self.obj)
1596
+ else:
1597
+ cb()
1598
+ return
1599
+ a['index'] = 0
1600
+ self._frame(a['frames'][a['index']])
1601
+ a['wait'] += a['delays'][a['index']]
1602
+
1603
+ def _lerpbase(self, key):
1604
+ if key in ('alpha', 'bgalpha'):
1605
+ return 1.0
1606
+ if key == 'fg':
1607
+ return tuple(self.scene.fgcolor)
1608
+ if key == 'bg':
1609
+ return tuple(self.scene.bgcolor)
1610
+ return None
1611
+
1612
+ def _lerpkeep(self, key, value):
1613
+ if key in ('alpha', 'bgalpha') and value is not None:
1614
+ value = max(0.0, value)
1615
+ return None if value >= 1 else value
1616
+ return value
1617
+
1618
+ def opacities(self):
1619
+ text = self.vtext
1620
+ cached = self._opacity_cache
1621
+ if cached is not None and cached[0] is text:
1622
+ return cached[1]
1623
+ found = {}
1624
+ if text and alphamark.search(text):
1625
+ recomp = self.scene.recomp
1626
+ for ri, row in enumerate(text.splitlines()):
1627
+ ci = 0
1628
+ active = ''
1629
+ for part in recomp.split(row):
1630
+ if not part:
1631
+ continue
1632
+ if recomp.match(part):
1633
+ active = '' if part in ansi.RESETS else active + part
1634
+ continue
1635
+ fa, _, ba = _ALPHAS(active)
1636
+ if fa is not None or ba is not None:
1637
+ own = _BGCODE.search(active) is not None
1638
+ value = (1.0 if ba is None else ba) if own else (1.0 if fa is None else fa)
1639
+ if value < 1.0:
1640
+ for k, char in enumerate(part):
1641
+ if own or char != ' ':
1642
+ found[(ci + k, ri)] = value
1643
+ ci += len(part)
1644
+ self._opacity_cache = (text, found)
1645
+ return found
1646
+
1647
+
1648
+ class rendering(_lerpable):
1649
+ _owned = ['show', 'renderspaces', 'z']
1650
+ _required = [transform]
1651
+ _default = True
1652
+ _settings = ('show', 'spaces', 'z')
1653
+ _aliases = {'spaces': 'renderspaces'}
1654
+
1655
+ def __init__(self, obj, **kwargs):
1656
+ self.obj = obj
1657
+ self.t = obj.transform
1658
+ self.scene = obj.scene
1659
+ self._litkey = None
1660
+ self._lit = ''
1661
+ self.show = kwargs.get('show', True)
1662
+ self.renderspaces = kwargs.get('renderspaces', False)
1663
+ self.z = kwargs.get('z', 0)
1664
+
1665
+ @property
1666
+ def z(self):
1667
+ return self._z
1668
+
1669
+ @z.setter
1670
+ def z(self, value):
1671
+ self._z = value
1672
+ if self._z > self.scene._lastz: self.scene._lastz = self._z
1673
+ if self._z < self.scene._firstz: self.scene._firstz = self._z
1674
+
1675
+ @property
1676
+ def spaces(self):
1677
+ return self.renderspaces
1678
+ @spaces.setter
1679
+ def spaces(self, value):
1680
+ self.renderspaces = value
1681
+
1682
+ def _shown(self):
1683
+ self.t._stylecheck()
1684
+ text = self.t.vtext
1685
+ sc = self.scene
1686
+ if not text or (not sc.lights and sc.ambient >= 1):
1687
+ return text
1688
+ lights = tuple((id(l), l._version) for l in sc.lights if l.on and l._affects(self.obj))
1689
+ key = (text, self.t._origin(), sc.ambient, sc.bgcolor, sc.fgcolor, lights)
1690
+ if self._litkey != key:
1691
+ self._litkey = key
1692
+ self._lit = light.shade(sc, self.obj, text)
1693
+ return self._lit
1694
+
1695
+ def state(self, camx, camy, xoff, yoff):
1696
+ if not self.show:
1697
+ return (0, 0, '', 0, 0)
1698
+ t = self.t
1699
+ return (
1700
+ int((t.x + t.vx - camx + 0.5) // 1),
1701
+ -int((t.y + t.vy - camy + 0.5) // 1),
1702
+ self._shown(),
1703
+ xoff,
1704
+ yoff,
1705
+ )
1706
+
1707
+ def front(self):
1708
+ self.z = self.scene._lastz +1
1709
+
1710
+ def back(self):
1711
+ self.z = self.scene._firstz -1
1712
+
1713
+
1714
+ class physics(_change_callback, _lerpable):
1715
+ _owned = [
1716
+ 'xvel', 'yvel', 'avel',
1717
+ 'dynamic',
1718
+ 'bounciness',
1719
+ 'friction',
1720
+ 'mass', 'automass',
1721
+ 'solid',
1722
+ 'hitbox',
1723
+ 'touch',
1724
+ 'phantom',
1725
+ 'gravity',
1726
+ 'drag',
1727
+ 'adrag',
1728
+ 'precision',
1729
+ 'rprecision',
1730
+ 'slope',
1731
+ ]
1732
+ _required = [transform]
1733
+ _settings = ('xvel', 'yvel', 'avel', 'dynamic', 'bounciness', 'friction', 'mass', 'automass', 'solid',
1734
+ 'hitbox', 'phantom', 'gravity', 'drag', 'adrag', 'precision', 'rprecision', 'slope')
1735
+ _running = 0
1736
+ def on_discard(self):
1737
+ self.scene.solids = [s for s in self.scene.solids if s is not self.obj]
1738
+ discarded = self.obj
1739
+ for other in self.scene.objs:
1740
+ if other is discarded or not hasattr(other, 'physics'):
1741
+ continue
1742
+ p = other.physics
1743
+ p._touch_callbacks = [
1744
+ cb for cb in p._touch_callbacks
1745
+ if not _closure_refs(cb, discarded)
1746
+ ]
1747
+ other._ghosts[:] = [
1748
+ g for g in other._ghosts
1749
+ if g is not discarded and not (getattr(g, '__closure__', None) and _closure_refs(g, discarded))
1750
+ ]
1751
+
1752
+ def __init__(self, obj, **kwargs):
1753
+ self.obj = obj
1754
+ self.t = obj.transform
1755
+ self.scene = obj.scene
1756
+ self._axises = ('x', 'y')
1757
+ self._xvel = kwargs.get('xvel', 0)
1758
+ self._yvel = kwargs.get('yvel', 0)
1759
+ self._avel = kwargs.get('avel', 0)
1760
+ self.dynamic = kwargs.get('dynamic', True)
1761
+ self.bounciness = kwargs.get('bounciness', 0)
1762
+ self.friction = kwargs.get('friction', 0)
1763
+ gravity = kwargs.get('gravity', None)
1764
+ self._gravity = None if gravity is None else tuple(gravity)
1765
+ if self._gravity is not None and len(self._gravity) != 2:
1766
+ raise ValueError('gravity must be a (x, y) pair or None')
1767
+ self._drag = kwargs.get('drag', None)
1768
+ self._adrag = kwargs.get('adrag', None)
1769
+ self._precision = kwargs.get('precision', None)
1770
+ self._rprecision = kwargs.get('rprecision', 3)
1771
+ if (self._precision is not None and self._precision <= 0) or self._rprecision <= 0:
1772
+ raise ValueError('precision and rprecision must be positive')
1773
+ self.mass = kwargs.get('mass', None)
1774
+ self.automass = kwargs.get('automass', self.mass is None)
1775
+ def masscal(prop='text'):
1776
+ if self.automass and prop.endswith('text'):
1777
+ self.mass = sum(1 for c in self.t._htext if c not in (' ', '\n'))
1778
+ if self.mass is None:
1779
+ self.mass = sum(1 for c in self.t._htext if c not in (' ', '\n'))
1780
+ self.t.onchange(masscal)
1781
+ self.solid = kwargs.get('solid', True)
1782
+ self.hitbox = kwargs.get('hitbox', False)
1783
+ self.phantom = kwargs.get('phantom', False)
1784
+ self.slope = kwargs.get('slope', True)
1785
+ self.touch = (False, False)
1786
+ self.sides = (False, False, False, False)
1787
+ self._touch_callbacks = []
1788
+ self._pixel_cache = None
1789
+ self._bounds_cache = None
1790
+ self._body_cache = None
1791
+ self._ext_cache = None
1792
+ self._good = None
1793
+ self._depenskip = 0
1794
+ def dirty(attr):
1795
+ self._dirty(attr)
1796
+ self.t.onchange(dirty)
1797
+
1798
+ def __getitem__(self, key):
1799
+ return getattr(self, key)
1800
+
1801
+ def __setitem__(self, key, value):
1802
+ setattr(self, key, value)
1803
+
1804
+ @property
1805
+ def solid(self):
1806
+ return self._solid
1807
+ @solid.setter
1808
+ def solid(self, value):
1809
+ self._solid = value
1810
+ if value:
1811
+ if not any(o is self.obj for o in self.scene.solids):
1812
+ self.scene.solids.append(self.obj)
1813
+ else:
1814
+ self.scene.solids = [o for o in self.scene.solids if o is not self.obj]
1815
+
1816
+ @property
1817
+ def hitbox(self):
1818
+ return self._hitbox
1819
+ @hitbox.setter
1820
+ def hitbox(self, value):
1821
+ old = getattr(self, '_hitbox', None)
1822
+ self._hitbox = value
1823
+ self._dirty()
1824
+ if old is not None and old != value:
1825
+ self._change('hitbox', old)
1826
+
1827
+ @property
1828
+ def xvel(self):
1829
+ return self._xvel
1830
+ @xvel.setter
1831
+ def xvel(self, value):
1832
+ old = self._xvel
1833
+ self._xvel = value
1834
+ self._change('xvel', old)
1835
+
1836
+ @property
1837
+ def yvel(self):
1838
+ return self._yvel
1839
+ @yvel.setter
1840
+ def yvel(self, value):
1841
+ old = self._yvel
1842
+ self._yvel = value
1843
+ self._change('yvel', old)
1844
+
1845
+ @property
1846
+ def avel(self):
1847
+ return self._avel
1848
+ @avel.setter
1849
+ def avel(self, value):
1850
+ old = self._avel
1851
+ self._avel = value
1852
+ self._change('avel', old)
1853
+
1854
+ @property
1855
+ def mass(self):
1856
+ return self._mass
1857
+ @mass.setter
1858
+ def mass(self, value):
1859
+ self._mass = max(1, value) if isinstance(value, (int, float)) else value
1860
+
1861
+ @property
1862
+ def gravity(self):
1863
+ return self.scene.gravity if self._gravity is None else self._gravity
1864
+ @gravity.setter
1865
+ def gravity(self, value):
1866
+ old = self._gravity
1867
+ if value is not None:
1868
+ value = tuple(value)
1869
+ if len(value) != 2:
1870
+ raise ValueError('gravity must be a (x, y) pair or None')
1871
+ self._gravity = value
1872
+ self._change('gravity', old)
1873
+
1874
+ @property
1875
+ def drag(self):
1876
+ return self.scene.drag if self._drag is None else self._drag
1877
+ @drag.setter
1878
+ def drag(self, value):
1879
+ old = self._drag
1880
+ self._drag = value
1881
+ self._change('drag', old)
1882
+
1883
+ @property
1884
+ def adrag(self):
1885
+ return self.drag if self._adrag is None else self._adrag
1886
+ @adrag.setter
1887
+ def adrag(self, value):
1888
+ old = self._adrag
1889
+ self._adrag = value
1890
+ self._change('adrag', old)
1891
+
1892
+ @property
1893
+ def precision(self):
1894
+ return self.scene.precision if self._precision is None else self._precision
1895
+ @precision.setter
1896
+ def precision(self, value):
1897
+ if value is not None and value <= 0:
1898
+ raise ValueError('precision must be positive or None')
1899
+ old = self._precision
1900
+ self._precision = value
1901
+ self._change('precision', old)
1902
+
1903
+ @property
1904
+ def rprecision(self):
1905
+ return self._rprecision
1906
+ @rprecision.setter
1907
+ def rprecision(self, value):
1908
+ if value <= 0:
1909
+ raise ValueError('rprecision must be positive')
1910
+ old = self._rprecision
1911
+ self._rprecision = value
1912
+ self._change('rprecision', old)
1913
+
1914
+ def _dirty(self, attr=None):
1915
+ self._pixel_cache = None
1916
+ self._bounds_cache = None
1917
+ self._body_cache = None
1918
+ if attr != 'x' and attr != 'y':
1919
+ self._ext_cache = None
1920
+
1921
+ def _extent(self):
1922
+ ext = self._ext_cache
1923
+ if ext is None:
1924
+ hitbox = self.hitbox
1925
+ text = self.t._htext if hitbox else self.t._ptext
1926
+ lo = hi = top = bottom = None
1927
+ for i, row in enumerate(text.splitlines()):
1928
+ if hitbox:
1929
+ if not row:
1930
+ continue
1931
+ a, b = 0, len(row) - 1
1932
+ else:
1933
+ if not row.strip(' '):
1934
+ continue
1935
+ a = len(row) - len(row.lstrip(' '))
1936
+ b = len(row.rstrip(' ')) - 1
1937
+ if lo is None or a < lo:
1938
+ lo = a
1939
+ if hi is None or b > hi:
1940
+ hi = b
1941
+ if top is None:
1942
+ top = i
1943
+ bottom = i
1944
+ ext = (lo, hi, top, bottom) if lo is not None else ()
1945
+ self._ext_cache = ext
1946
+ return ext
1947
+
1948
+ def _extent_bounds(self, x=None, y=None):
1949
+ ext = self._extent()
1950
+ if not ext:
1951
+ return None
1952
+ t = self.t
1953
+ x = t.x if x is None else x
1954
+ y = t.y if y is None else y
1955
+ if self.hitbox:
1956
+ ox = oy = 0
1957
+ else:
1958
+ ox, oy = t._pvx, t._pvy
1959
+ rx = round(x + ox)
1960
+ ry = round(y + oy)
1961
+ return (rx + ext[0], rx + ext[1], ry - ext[3], ry - ext[2])
1962
+
1963
+ def ontouch(self, callback, when=None, once=False):
1964
+ l = _argslen(callback)
1965
+ fired = [False]
1966
+ def wrapped(obj2, intersection):
1967
+ if once and fired[0]: return
1968
+ if when is None or when(obj2):
1969
+ if once: fired[0] = True
1970
+ if l > 1: callback(obj2, intersection)
1971
+ elif l > 0: callback(obj2)
1972
+ else: callback()
1973
+ if once and wrapped in self._touch_callbacks:
1974
+ self._touch_callbacks.remove(wrapped)
1975
+ self._touch_callbacks.append(wrapped)
1976
+ return callback
1977
+
1978
+ def offtouch(self, callback):
1979
+ self._touch_callbacks = [
1980
+ cb for cb in self._touch_callbacks if cb.__closure__ and
1981
+ cb.__closure__[0].cell_contents is not callback
1982
+ ]
1983
+
1984
+ def ghost(self, other):
1985
+ if not any(g is other for g in self.obj._ghosts):
1986
+ self.obj._ghosts.append(other)
1987
+
1988
+ def getpixels(self, x=None, y=None, text=None):
1989
+ default_call = x is None and y is None and text is None
1990
+ if default_call and self._pixel_cache is not None:
1991
+ return self._pixel_cache
1992
+
1993
+ x = self.t.x if x is None else x
1994
+ y = self.t.y if y is None else y
1995
+ if self.hitbox:
1996
+ text = self.t._htext
1997
+ ox = oy = 0
1998
+ elif text is None:
1999
+ text = self.t._ptext
2000
+ ox, oy = self.t._pvx, self.t._pvy
2001
+ else:
2002
+ ox, oy = self.t.vx, self.t.vy
2003
+ rx = round(x + ox)
2004
+ ry = round(y + oy)
2005
+ pixels = set()
2006
+ for i, row in enumerate(text.splitlines()):
2007
+ for j, char in enumerate(row):
2008
+ if (char != ' ' and char != '\n' and not self.hitbox) or self.hitbox:
2009
+ pixels.add((rx + j, ry - i))
2010
+
2011
+ if default_call:
2012
+ self._pixel_cache = pixels
2013
+ self._bounds_cache = None
2014
+ return pixels
2015
+
2016
+ def bounds(self, pixels=None):
2017
+ if pixels is None:
2018
+ if self._bounds_cache is not None:
2019
+ return self._bounds_cache
2020
+ if self._pixel_cache is None:
2021
+ result = self._extent_bounds()
2022
+ self._bounds_cache = result
2023
+ return result
2024
+ pixels = self._pixel_cache
2025
+ if not pixels:
2026
+ result = None
2027
+ else:
2028
+ xs = [p[0] for p in pixels]
2029
+ ys = [p[1] for p in pixels]
2030
+ result = (min(xs), max(xs), min(ys), max(ys))
2031
+ if pixels is self._pixel_cache:
2032
+ self._bounds_cache = result
2033
+ return result
2034
+
2035
+ def collides(self, objs, x=None, y=None, text=None, full=False):
2036
+ if text is None:
2037
+ b1 = self._extent_bounds(x, y)
2038
+ if b1 is None:
2039
+ return {}
2040
+ pixels1 = None
2041
+ else:
2042
+ pixels1 = self.getpixels(x, y, text)
2043
+ if not pixels1:
2044
+ return {}
2045
+ b1 = self.bounds(pixels1)
2046
+
2047
+ if not isinstance(objs, list):
2048
+ objs = [objs]
2049
+
2050
+ result = {}
2051
+ for obj2 in objs:
2052
+ if obj2 is self.obj:
2053
+ continue
2054
+ p2 = obj2.physics
2055
+ b2 = p2.bounds()
2056
+ if b2 is None or b2[1] < b1[0] or b2[0] > b1[1] or b2[3] < b1[2] or b2[2] > b1[3]:
2057
+ continue
2058
+ if pixels1 is None:
2059
+ pixels1 = self.getpixels(x, y)
2060
+ intersection = pixels1 & p2.getpixels()
2061
+ if intersection:
2062
+ result[obj2] = intersection
2063
+ if not self.phantom and not full:
2064
+ break
2065
+ return result
2066
+
2067
+ def overlaps(self, pixels=None):
2068
+ b1 = self.bounds() if pixels is None else self.bounds(pixels)
2069
+ found = {}
2070
+ if b1 is None:
2071
+ return found
2072
+ for obj2 in self.scene.solids:
2073
+ if obj2 is self.obj:
2074
+ continue
2075
+ p2 = getattr(obj2, 'physics', None)
2076
+ if p2 is None or p2.phantom or not p2.solid:
2077
+ continue
2078
+ b2 = p2.bounds()
2079
+ if b2 is None or b2[1] < b1[0] or b2[0] > b1[1] or b2[3] < b1[2] or b2[2] > b1[3]:
2080
+ continue
2081
+ if pixels is None:
2082
+ pixels = self.getpixels()
2083
+ inter = pixels & p2.getpixels()
2084
+ if inter and not self._ghosted(obj2, inter):
2085
+ found[obj2] = inter
2086
+ return found
2087
+
2088
+ def body(self):
2089
+ if self._body_cache is None:
2090
+ pixels = self.getpixels()
2091
+ n = len(pixels)
2092
+ if n == 0:
2093
+ self._body_cache = (0.0, 0.0, 1.0)
2094
+ else:
2095
+ A = self.scene.aspect
2096
+ cx = sum(p[0] for p in pixels) / n
2097
+ cy = sum(p[1] for p in pixels) / n
2098
+ spread = sum(((p[0] - cx) * A) ** 2 + (p[1] - cy) ** 2 for p in pixels) / n
2099
+ self._body_cache = (cx, cy, spread + (A * A + 1) / 12)
2100
+ return self._body_cache
2101
+
2102
+ def _invinertia(self):
2103
+ if self.hitbox or not self.slope or not self.dynamic or not self.solid or len(self.getpixels()) < 2:
2104
+ return 0.0
2105
+ return 1.0 / (self.mass * self.body()[2])
2106
+
2107
+ def _velocity(self):
2108
+ A = self.scene.aspect
2109
+ return self.xvel / self.mass * A, self.yvel / self.mass
2110
+
2111
+ def _omega(self):
2112
+ return -math.radians(self.avel / self.mass)
2113
+
2114
+ def _impulse(self, jx, jy, rx=0.0, ry=0.0):
2115
+ A = self.scene.aspect
2116
+ self.xvel += jx / A
2117
+ self.yvel += jy
2118
+ if rx or ry:
2119
+ self._spin(rx * jy - ry * jx)
2120
+
2121
+ def _spin(self, torque):
2122
+ inv = self._invinertia()
2123
+ if inv and torque:
2124
+ self.avel -= math.degrees(torque * inv) * self.mass
2125
+
2126
+ def _arm(self, point):
2127
+ A = self.scene.aspect
2128
+ cx, cy, _ = self.body()
2129
+ return (point[0] - cx) * A, point[1] - cy
2130
+
2131
+ def _shifted(self, dx, dy):
2132
+ return {(p[0] + dx, p[1] + dy) for p in self.getpixels()}
2133
+
2134
+ def _ghosted(self, other, intersection=None):
2135
+ for owner, partner in ((self.obj, other), (other, self.obj)):
2136
+ for ghost in list(getattr(owner, '_ghosts', ())):
2137
+ if ghost is partner:
2138
+ return True
2139
+ if callable(ghost):
2140
+ count = _argslen(ghost)
2141
+ if count > 1:
2142
+ result = ghost(partner, intersection)
2143
+ elif count > 0:
2144
+ result = ghost(partner)
2145
+ else:
2146
+ result = ghost()
2147
+ if result:
2148
+ return True
2149
+ if intersection:
2150
+ jt = getattr(self.obj, 'joint', None)
2151
+ if jt is not None and jt._seam(other, intersection):
2152
+ return True
2153
+ return False
2154
+
2155
+ def _blocked(self, dx, dy, exclude=()):
2156
+ b = self.bounds()
2157
+ hits = []
2158
+ if b is None:
2159
+ return hits
2160
+ shifted = None
2161
+ for obj2 in self.scene.solids:
2162
+ if obj2 is self.obj or obj2 in exclude:
2163
+ continue
2164
+ p2 = getattr(obj2, 'physics', None)
2165
+ if p2 is None or p2.phantom or not p2.solid:
2166
+ continue
2167
+ b2 = p2.bounds()
2168
+ if b2 is None or b2[1] < b[0] + dx or b2[0] > b[1] + dx or b2[3] < b[2] + dy or b2[2] > b[3] + dy:
2169
+ continue
2170
+ if shifted is None:
2171
+ shifted = self._shifted(dx, dy)
2172
+ inter = shifted & p2.getpixels()
2173
+ if inter and not self._ghosted(obj2, inter):
2174
+ hits.append(obj2)
2175
+ return hits
2176
+
2177
+ def cluster(self, start, dx, dy):
2178
+ members = [start]
2179
+ seen = {id(start.obj), id(self.obj)}
2180
+ queue = [start]
2181
+ while queue:
2182
+ body = queue.pop()
2183
+ if not body.dynamic:
2184
+ return None
2185
+ for obj2 in body._blocked(dx, dy, exclude=[m.obj for m in members] + [self.obj]):
2186
+ p2 = obj2.physics
2187
+ if id(obj2) in seen:
2188
+ continue
2189
+ if not p2.dynamic:
2190
+ return None
2191
+ seen.add(id(obj2))
2192
+ members.append(p2)
2193
+ queue.append(p2)
2194
+ return members
2195
+
2196
+ def _solve(self, other, group, point, nx, ny, restitution=None):
2197
+ A = self.scene.aspect
2198
+ ra = self._arm(point)
2199
+ va = self._velocity()
2200
+ wa = self._omega()
2201
+ vax = va[0] - wa * ra[1]
2202
+ vay = va[1] + wa * ra[0]
2203
+
2204
+ if group is None:
2205
+ rb = (0.0, 0.0)
2206
+ vbx = vby = 0.0
2207
+ invmb = 0.0
2208
+ invib = 0.0
2209
+ e_other = other.bounciness
2210
+ mu_other = other.friction
2211
+ single = None
2212
+ else:
2213
+ single = group[0] if len(group) == 1 else None
2214
+ total = sum(m.mass for m in group)
2215
+ invmb = 1.0 / total
2216
+ if single is not None:
2217
+ rb = single._arm(point)
2218
+ vb = single._velocity()
2219
+ wb = single._omega()
2220
+ vbx = vb[0] - wb * rb[1]
2221
+ vby = vb[1] + wb * rb[0]
2222
+ invib = single._invinertia()
2223
+ else:
2224
+ rb = (0.0, 0.0)
2225
+ vbx = sum(m.xvel / m.mass * A * m.mass for m in group) / total
2226
+ vby = sum(m.yvel for m in group) / total
2227
+ invib = 0.0
2228
+ e_other = max(m.bounciness for m in group)
2229
+ mu_other = max(m.friction for m in group)
2230
+
2231
+ vn = (vax - vbx) * nx + (vay - vby) * ny
2232
+ if vn >= 0:
2233
+ return False
2234
+
2235
+ e = max(self.bounciness, e_other) if restitution is None else restitution
2236
+ if -vn < 0.25:
2237
+ e = 0.0
2238
+
2239
+ inva = 1.0 / self.mass
2240
+ ia = self._invinertia()
2241
+ can = (ra[0] * ny - ra[1] * nx) ** 2 * ia
2242
+ cbn = (rb[0] * ny - rb[1] * nx) ** 2 * invib
2243
+ denom = inva + invmb + can + cbn
2244
+ gravity = self.gravity
2245
+ boost = e * max(0.0, -(gravity[0] * A * nx + gravity[1] * ny))
2246
+ j = (-(1 + e) * vn + boost) / denom
2247
+
2248
+ self._impulse(j * nx, j * ny, *ra)
2249
+ self._apply_other(group, single, -j * nx, -j * ny, rb)
2250
+
2251
+ mu = max(self.friction, mu_other)
2252
+ if mu:
2253
+ tx, ty = -ny, nx
2254
+ va = self._velocity()
2255
+ wa = self._omega()
2256
+ vax = va[0] - wa * ra[1]
2257
+ vay = va[1] + wa * ra[0]
2258
+ if group is None:
2259
+ vbx = vby = 0.0
2260
+ elif single is not None:
2261
+ vb = single._velocity()
2262
+ wb = single._omega()
2263
+ vbx = vb[0] - wb * rb[1]
2264
+ vby = vb[1] + wb * rb[0]
2265
+ else:
2266
+ vbx = sum(m.xvel * A / 1.0 for m in group) / total
2267
+ vby = sum(m.yvel for m in group) / total
2268
+ vt = (vax - vbx) * tx + (vay - vby) * ty
2269
+ cat = (ra[0] * ty - ra[1] * tx) ** 2 * ia
2270
+ cbt = (rb[0] * ty - rb[1] * tx) ** 2 * invib
2271
+ jt = -vt / (inva + invmb + cat + cbt)
2272
+ limit = mu * j
2273
+ jt = max(-limit, min(limit, jt))
2274
+ self._impulse(jt * tx, jt * ty, *ra)
2275
+ self._apply_other(group, single, -jt * tx, -jt * ty, rb)
2276
+ return True
2277
+
2278
+ def _apply_other(self, group, single, jx, jy, rb):
2279
+ if group is None:
2280
+ return
2281
+ if single is not None:
2282
+ single._impulse(jx, jy, *rb)
2283
+ return
2284
+ total = sum(m.mass for m in group)
2285
+ for m in group:
2286
+ share = m.mass / total
2287
+ m._impulse(jx * share, jy * share)
2288
+
2289
+ def _contact_point(self, pixels, nx=None, ny=None):
2290
+ return _contact_point_cells(pixels, nx, ny, self.body()[:2])
2291
+
2292
+ def _contact_normal(self, other, point, nx, ny, inside):
2293
+ return _contact_normal_cells(self.getpixels(), other.getpixels(), point, nx, ny, inside,
2294
+ self.slope and other.slope, self.scene.aspect)
2295
+
2296
+ def normal(self, other, local=None):
2297
+ return self.t.normal(other, local)
2298
+
2299
+ def _mtv(self, other_pixels):
2300
+ return _mtv_cells(self.getpixels(), other_pixels, self.scene.aspect)
2301
+
2302
+ def _remember(self):
2303
+ self._good = (self.t.x, self.t.y, self.t.rotation)
2304
+
2305
+ def _settle(self, dx, dy):
2306
+ if dx and self.xvel * dx < 0:
2307
+ self.xvel = 0
2308
+ if dy and self.yvel * dy < 0:
2309
+ self.yvel = 0
2310
+
2311
+ def _tunnels(self, found, dx, dy):
2312
+ cx, cy, _ = self.body()
2313
+ for obj2 in found:
2314
+ p2 = obj2.physics
2315
+ if p2.dynamic:
2316
+ continue
2317
+ b = p2.bounds()
2318
+ if b is None:
2319
+ continue
2320
+ mx, my = (b[0] + b[1]) / 2, (b[2] + b[3]) / 2
2321
+ if dx and (cx - mx) * (cx + dx - mx) < 0:
2322
+ return True
2323
+ if dy and (cy - my) * (cy + dy - my) < 0:
2324
+ return True
2325
+ return False
2326
+
2327
+ def _unstick(self):
2328
+ t = self.t
2329
+ found = self.overlaps()
2330
+ good = self._good
2331
+ if good is not None:
2332
+ x, y, r = t.x, t.y, t.rotation
2333
+ if r != good[2]:
2334
+ t.rotation = good[2]
2335
+ if not self.overlaps():
2336
+ self.avel = 0
2337
+ self._remember()
2338
+ return
2339
+ t.x, t.y = good[0], good[1]
2340
+ if not self.overlaps():
2341
+ self.xvel = self.yvel = self.avel = 0
2342
+ self._remember()
2343
+ return
2344
+ t.x, t.y = x, y
2345
+ if t.rotation != r:
2346
+ t.rotation = r
2347
+ for dx, dy in _offsets(6, self.scene.aspect):
2348
+ if not self._tunnels(found, dx, dy) and not self.overlaps(self._shifted(dx, dy)):
2349
+ t.x += dx
2350
+ t.y += dy
2351
+ self._settle(dx, dy)
2352
+ self._remember()
2353
+ return
2354
+ for dx, dy in ((0, 1), (1, 0), (-1, 0), (0, -1)):
2355
+ for k in range(7, 129):
2356
+ if not self._tunnels(found, dx * k, dy * k) and not self.overlaps(self._shifted(dx * k, dy * k)):
2357
+ t.x += dx * k
2358
+ t.y += dy * k
2359
+ self._settle(dx, dy)
2360
+ self._remember()
2361
+ return
2362
+ self._depenskip = 4
2363
+
2364
+ def _depenetrate(self):
2365
+ if self.phantom:
2366
+ return
2367
+ if self._depenskip > 0:
2368
+ self._depenskip -= 1
2369
+ return
2370
+ jt = getattr(self.obj, 'joint', None)
2371
+ pinned, owned = jt._hierarchy() if jt is not None else ((), ())
2372
+ budget = _RELIEF if pinned else None
2373
+ def hits():
2374
+ found = self.overlaps()
2375
+ if owned:
2376
+ return {o: v for o, v in found.items() if not any(o is d for d in owned)}
2377
+ return found
2378
+ seen = set()
2379
+ for _ in range(12):
2380
+ found = hits()
2381
+ if not found:
2382
+ self._remember()
2383
+ return
2384
+ key = (round(self.t.x, 4), round(self.t.y, 4), frozenset((id(o), round(o.transform.x, 4), round(o.transform.y, 4)) for o in found))
2385
+ if key in seen:
2386
+ break
2387
+ seen.add(key)
2388
+ obj2 = max(found, key=lambda o: len(found[o]))
2389
+ p2 = obj2.physics
2390
+ best = self._mtv(p2.getpixels())
2391
+ if best is None:
2392
+ break
2393
+ _, dx, dy, k = best
2394
+ if budget is not None:
2395
+ k = min(k, budget)
2396
+ budget -= k
2397
+ movable = p2.dynamic and not any(obj2 is a for a in pinned)
2398
+ if movable:
2399
+ mine_share = int(min(k, max(1, math.ceil(k * p2.mass / (self.mass + p2.mass)))))
2400
+ theirs_share = k - mine_share
2401
+ else:
2402
+ mine_share, theirs_share = k, 0
2403
+ if movable:
2404
+ if theirs_share and any(o is not self.obj for o in p2.overlaps(p2._shifted(-dx * theirs_share, -dy * theirs_share))):
2405
+ mine_share, theirs_share = k, 0
2406
+ if mine_share and any(o is not obj2 for o in self.overlaps(self._shifted(dx * mine_share, dy * mine_share))):
2407
+ if any(o is not self.obj for o in p2.overlaps(p2._shifted(-dx * k, -dy * k))):
2408
+ break
2409
+ mine_share, theirs_share = 0, k
2410
+ self.t.x += dx * mine_share
2411
+ self.t.y += dy * mine_share
2412
+ if theirs_share:
2413
+ p2.t.x -= dx * theirs_share
2414
+ p2.t.y -= dy * theirs_share
2415
+ for body, sign in ((self, 1), (p2, -1)):
2416
+ if body is p2 and not theirs_share:
2417
+ continue
2418
+ if dx and body.xvel * dx * sign < 0:
2419
+ body.xvel = 0
2420
+ if dy and body.yvel * dy * sign < 0:
2421
+ body.yvel = 0
2422
+ if budget is not None and budget <= 0:
2423
+ if not hits():
2424
+ self._remember()
2425
+ return
2426
+ if hits():
2427
+ self._unstick()
2428
+ else:
2429
+ self._remember()
2430
+
2431
+ @property
2432
+ def grounded(self):
2433
+ return self.sides[2]
2434
+
2435
+ @property
2436
+ def ceiling(self):
2437
+ return self.sides[3]
2438
+
2439
+ def _probe(self):
2440
+ touch = [False, False]
2441
+ sides = [False, False, False, False]
2442
+ if self.solid and not self.phantom:
2443
+ mine = None
2444
+ for ia in (0, 1):
2445
+ for s in (1, -1):
2446
+ dx, dy = (s, 0) if ia == 0 else (0, s)
2447
+ for obj2 in self._blocked(dx, dy):
2448
+ p2 = obj2.physics
2449
+ theirs = p2.getpixels()
2450
+ if mine is None:
2451
+ mine = self.getpixels()
2452
+ if not mine.isdisjoint(theirs):
2453
+ continue
2454
+ contact = [p for p in mine if (p[0] + dx, p[1] + dy) in theirs]
2455
+ if not contact:
2456
+ continue
2457
+ touch[ia] = True
2458
+ sides[ia * 2 + (0 if s == -1 else 1)] = True
2459
+ group = self.cluster(p2, dx, dy) if p2.dynamic else None
2460
+ point = self._contact_point(contact, -dx, -dy)
2461
+ cn = self._contact_normal(p2, point, -dx, -dy, False)
2462
+ self._solve(p2, group, point, cn[0], cn[1])
2463
+ self.touch = tuple(touch)
2464
+ self.sides = tuple(sides)
2465
+ if abs(self._xvel) < 1e-9: self._xvel = 0
2466
+ if abs(self._yvel) < 1e-9: self._yvel = 0
2467
+ if abs(self._avel) < 1e-9: self._avel = 0
2468
+
2469
+ def physics(self, axises=None):
2470
+ physics._running += 1
2471
+ try:
2472
+ self._physics(axises)
2473
+ finally:
2474
+ physics._running -= 1
2475
+
2476
+ def _physics(self, axises=None):
2477
+ axises = self._axises if axises is None else axises
2478
+ actualmass = self.mass
2479
+ gravity = self.gravity
2480
+ drag = self.drag
2481
+ precision = self.precision
2482
+ self._dirty('x')
2483
+
2484
+ for ia, a in enumerate(axises):
2485
+ self[a + 'vel'] *= drag
2486
+ self[a + 'vel'] += gravity[ia] * actualmass
2487
+ c = 1 if self[a + 'vel'] > 0 else -1 if self[a + 'vel'] < 0 else 0
2488
+ ch = abs(self[a + 'vel']) / actualmass
2489
+ d = (c, 0) if ia == 0 else (0, c)
2490
+
2491
+ while ch > 0:
2492
+ step = min(precision, ch)
2493
+ t = {a: self.t[a] + c * step}
2494
+ collisions = self.collides(self.scene.solids, full=True, **t)
2495
+
2496
+ physical = []
2497
+ for obj2, intersection in list(collisions.items()):
2498
+ obj2p = getattr(obj2, 'physics', None)
2499
+ if obj2p is None:
2500
+ continue
2501
+ for cb in list(self._touch_callbacks):
2502
+ cb(obj2, intersection)
2503
+ if getattr(self.obj, 'physics', None) is not self:
2504
+ return
2505
+ for cb in list(obj2p._touch_callbacks):
2506
+ cb(self.obj, intersection)
2507
+ if getattr(self.obj, 'physics', None) is not self:
2508
+ return
2509
+ if getattr(obj2, 'physics', None) is not obj2p:
2510
+ continue
2511
+ if self.solid and not self.phantom and not obj2p.phantom and not self._ghosted(obj2, intersection):
2512
+ physical.append((obj2, obj2p, intersection))
2513
+
2514
+ if physical:
2515
+ for obj2, obj2p, intersection in physical:
2516
+ if getattr(obj2, 'physics', None) is not obj2p:
2517
+ continue
2518
+ group = self.cluster(obj2p, d[0], d[1]) if obj2p.dynamic else None
2519
+ point = self._contact_point(intersection, -d[0], -d[1])
2520
+ cn = self._contact_normal(obj2p, point, -d[0], -d[1], True)
2521
+ self._solve(obj2p, group, point, cn[0], cn[1])
2522
+ break
2523
+
2524
+ self.t[a] += c * step
2525
+ ch -= step
2526
+
2527
+ self._depenetrate()
2528
+
2529
+ self.avel *= self.adrag
2530
+ degrees = self.avel / actualmass
2531
+ if degrees:
2532
+ pieces = max(1, int(math.ceil(abs(degrees) / self._rprecision)))
2533
+ da = degrees / pieces
2534
+ check = self.solid and not self.phantom and not self.hitbox
2535
+ for _ in range(pieces):
2536
+ self.t.rotation += da
2537
+ hit = self.overlaps() if check else None
2538
+ if not hit:
2539
+ continue
2540
+ contacts = []
2541
+ for obj2, intersection in hit.items():
2542
+ mtv = self._mtv(obj2.physics.getpixels())
2543
+ if mtv is None:
2544
+ continue
2545
+ contacts.append((obj2.physics, self._contact_point(intersection, mtv[1], mtv[2]), mtv[1], mtv[2]))
2546
+ self.t.rotation -= da
2547
+ applied = False
2548
+ for obj2p, point, nx, ny in contacts:
2549
+ group = [obj2p] if obj2p.dynamic else None
2550
+ if self._solve(obj2p, group, point, nx, ny):
2551
+ applied = True
2552
+ if applied:
2553
+ break
2554
+ if contacts:
2555
+ self.avel = 0
2556
+ break
2557
+ self.t.rotation += da
2558
+
2559
+ self._depenetrate()
2560
+ self._probe()
2561
+
2562
+ class pen(_lerpable):
2563
+ _owned = ['penchar', 'penx', 'peny', 'pradius', 'pshape']
2564
+ _required = [transform]
2565
+ _settings = ('char', 'x', 'y', 'radius', 'shape')
2566
+ _aliases = {'char': 'penchar', 'x': 'penx', 'y': 'peny', 'radius': 'pradius', 'shape': 'pshape'}
2567
+
2568
+ def __init__(self, obj, **kwargs):
2569
+ self.obj = obj
2570
+ self.scene = obj.scene
2571
+ self._x = kwargs.get('penx', 0)
2572
+ self._y = kwargs.get('peny', 0)
2573
+ self._px = self._x
2574
+ self._py = self._y
2575
+ self.char = kwargs.get('penchar', '█')
2576
+ self.pradius = kwargs.get('pradius', 1)
2577
+ self.pshape = kwargs.get('pshape', 'circle')
2578
+ self.active = False
2579
+ self.current_obj = None
2580
+ self._pixels = {}
2581
+ self._path = []
2582
+ self._hits = {}
2583
+ self._ox = 0
2584
+ self._oy = 0
2585
+ self._pcut = None
2586
+ self._cut_walls = {}
2587
+
2588
+ @property
2589
+ def x(self):
2590
+ return self._x
2591
+ @x.setter
2592
+ def x(self, value):
2593
+ self._x = value
2594
+
2595
+ @property
2596
+ def y(self):
2597
+ return self._y
2598
+ @y.setter
2599
+ def y(self, value):
2600
+ self._y = value
2601
+
2602
+ @property
2603
+ def penchar(self):
2604
+ return self.char
2605
+ @penchar.setter
2606
+ def penchar(self, value):
2607
+ self.char = value
2608
+
2609
+ @property
2610
+ def penx(self):
2611
+ return self._x
2612
+ @penx.setter
2613
+ def penx(self, value):
2614
+ self._x = value
2615
+
2616
+ @property
2617
+ def peny(self):
2618
+ return self._y
2619
+ @peny.setter
2620
+ def peny(self, value):
2621
+ self._y = value
2622
+
2623
+ @property
2624
+ def radius(self):
2625
+ return self._pradius
2626
+ @radius.setter
2627
+ def radius(self, value):
2628
+ self.pradius = value
2629
+
2630
+ @property
2631
+ def shape(self):
2632
+ return self._pshape
2633
+ @shape.setter
2634
+ def shape(self, value):
2635
+ self.pshape = value
2636
+
2637
+ @property
2638
+ def pradius(self):
2639
+ return self._pradius
2640
+ @pradius.setter
2641
+ def pradius(self, value):
2642
+ # same meaning as `r` in cut/cutcommit (1 = a single cell). Values below 1 are treated as 1.
2643
+ self._pradius = max(1, value)
2644
+
2645
+ @property
2646
+ def pshape(self):
2647
+ return self._pshape
2648
+ @pshape.setter
2649
+ def pshape(self, value):
2650
+ if value not in _SHAPES:
2651
+ raise ValueError('shape must be one of: ' + ', '.join(_SHAPES))
2652
+ self._pshape = value
2653
+
2654
+ def footprint(self, r=None, shape=None):
2655
+ return _footprint(self._pradius if r is None else r, self.scene.aspect, self._pshape if shape is None else shape)
2656
+
2657
+ @property
2658
+ def char(self):
2659
+ return self._char
2660
+ @char.setter
2661
+ def char(self, value):
2662
+ self._char = value
2663
+ self._pchar = value
2664
+
2665
+ def down(self, x=None, y=None, char=None, **kwargs):
2666
+ if self.active:
2667
+ return self.current_obj
2668
+ self.active = True
2669
+ self._pixels = {}
2670
+ if x is not None: self._x = x
2671
+ if y is not None: self._y = y
2672
+ self._ox = round(self._x)
2673
+ self._oy = round(self._y)
2674
+ self._px = self._ox
2675
+ self._py = self._oy
2676
+ self._pchar = char or self.char
2677
+ self._pixels = {}
2678
+ self._brush(0, 0)
2679
+ self._path = [(0, 0)]
2680
+ self._hits = {(0, 0): 1}
2681
+ for key, value in (('text', self._pchar), ('x', self._ox), ('y', self._oy)):
2682
+ kwargs.setdefault(key, value)
2683
+ self.current_obj = self.scene.obj().create(**kwargs)
2684
+ if self._pradius > 1:
2685
+ self._rebuild()
2686
+ return self.current_obj
2687
+
2688
+ def trail(self):
2689
+ return [(self._ox + x, self._oy + y) for (x, y) in self._pixels]
2690
+
2691
+ def commit(self, **kwargs):
2692
+ if self.current_obj is None or self.current_obj not in self.scene.objs:
2693
+ return None
2694
+ return self.current_obj.create(**kwargs)
2695
+
2696
+ def up(self, **kwargs):
2697
+ finished = self.commit(**kwargs)
2698
+ self.active = False
2699
+ self.current_obj = None
2700
+ self._pixels = {}
2701
+ return finished
2702
+
2703
+ def drop(self):
2704
+ if self.current_obj is not None and self.current_obj in self.scene.objs:
2705
+ self.current_obj.discard()
2706
+ self.active = False
2707
+ self.current_obj = None
2708
+ self._pixels = {}
2709
+
2710
+ def move(self, x, y, rebuild=True):
2711
+ self._x = x
2712
+ self._y = y
2713
+ if self.active:
2714
+ self._plot(rebuild)
2715
+
2716
+ def _plot(self, rebuild=True):
2717
+ nx = round(self._x)
2718
+ ny = round(self._y)
2719
+ for (ix, iy) in self._line(self._px, self._py, nx, ny):
2720
+ self._stamp(ix - self._ox, iy - self._oy)
2721
+ self._px = nx
2722
+ self._py = ny
2723
+ if rebuild:
2724
+ self._rebuild()
2725
+
2726
+ def _stamp(self, x, y):
2727
+ path = self._path
2728
+ if path and path[-1] == (x, y):
2729
+ return
2730
+ path.append((x, y))
2731
+ self._brush(x, y)
2732
+ self._hits[(x, y)] = self._hits.get((x, y), 0) + 1
2733
+ if len(path) < 3 or self._pradius > 1:
2734
+ return
2735
+ ax, ay = path[-3]
2736
+ bx, by = path[-2]
2737
+ if abs(x - ax) == 1 and abs(y - ay) == 1 and (bx, by) in ((ax, y), (x, ay)) and self._hits[(bx, by)] == 1:
2738
+ del self._pixels[(bx, by)]
2739
+ del self._hits[(bx, by)]
2740
+ del path[-2]
2741
+
2742
+ def _brush(self, x, y):
2743
+ for dx, dy in self.footprint():
2744
+ self._pixels[(x + dx, y + dy)] = self._pchar
2745
+
2746
+ def _line(self, x0, y0, x1, y1):
2747
+ dx = abs(x1 - x0)
2748
+ dy = -abs(y1 - y0)
2749
+ sx = 1 if x0 < x1 else -1
2750
+ sy = 1 if y0 < y1 else -1
2751
+ err = dx + dy
2752
+ points = []
2753
+ while True:
2754
+ points.append((x0, y0))
2755
+ if x0 == x1 and y0 == y1:
2756
+ return points
2757
+ e2 = 2 * err
2758
+ if e2 >= dy:
2759
+ err += dy
2760
+ x0 += sx
2761
+ if e2 <= dx:
2762
+ err += dx
2763
+ y0 += sy
2764
+
2765
+ def _rebuild(self):
2766
+ if not self._pixels or self.current_obj is None or self.current_obj not in self.scene.objs:
2767
+ return
2768
+
2769
+ xs = [p[0] for p in self._pixels]
2770
+ ys = [p[1] for p in self._pixels]
2771
+ min_x, max_x = min(xs), max(xs)
2772
+ min_y, max_y = min(ys), max(ys)
2773
+
2774
+ rows = []
2775
+ for row_y in range(max_y, min_y - 1, -1):
2776
+ row = ''
2777
+ for col_x in range(min_x, max_x + 1):
2778
+ row += self._pixels.get((col_x, row_y), ' ')
2779
+ rows.append(row)
2780
+
2781
+ t = self.current_obj.transform
2782
+ t.x = self._ox + min_x
2783
+ t.y = self._oy + max_y
2784
+ t.text = '\n'.join(rows)
2785
+
2786
+ def cut(self, wx, wy, r=0, when=True, shape=None):
2787
+ shape = self._pshape if shape is None else shape
2788
+ end = (round(wx), round(wy))
2789
+ start = self._pcut if self._pcut is not None else end
2790
+ self._pcut = end
2791
+ cut = False
2792
+ for o in list(self.scene.objs):
2793
+ if o is self.obj or not hasattr(o, 'transform') or not (when(o) if callable(when) else when):
2794
+ continue
2795
+ t = o.transform
2796
+ if r == 0:
2797
+ walls = t.cutline(start, end)
2798
+ if walls:
2799
+ entry = self._cut_walls.setdefault(id(o), (o, set()))
2800
+ if entry[1] is not None:
2801
+ entry[1].update(walls)
2802
+ cut = True
2803
+ else:
2804
+ for ix, iy in self._line(*start, *end):
2805
+ if t.cut(ix, iy, r=r, shape=shape):
2806
+ self._cut_walls.setdefault(id(o), (o, None))
2807
+ cut = True
2808
+ return cut
2809
+
2810
+ def cutcommit(self, r=0, callback=None, diagonal=True, keep=False, **kwargs):
2811
+ cut_objs = []
2812
+ for o, wall_pixels in list(self._cut_walls.values()):
2813
+ if o not in self.scene.objs:
2814
+ continue
2815
+ o.create(**kwargs)
2816
+ if r == 0 and wall_pixels:
2817
+ o.transform.split_at(wall_pixels, callback=callback, diagonal=diagonal)
2818
+ else:
2819
+ o.transform.split(callback=callback, diagonal=diagonal)
2820
+ cut_objs.append(o)
2821
+ self._cut_walls = {}
2822
+ if not keep:
2823
+ self._pcut = None
2824
+ return cut_objs
2825
+
2826
+ def cancel(self):
2827
+ self._pcut = None
2828
+ self._cut_walls = {}
2829
+
2830
+ _CONDITIONS = {
2831
+ 'physical': lambda o, p: p is not None,
2832
+ 'solid': lambda o, p: p is not None and bool(p.solid) and not p.phantom,
2833
+ 'phantom': lambda o, p: p is not None and bool(p.phantom),
2834
+ 'dynamic': lambda o, p: p is not None and bool(p.dynamic),
2835
+ 'static': lambda o, p: p is not None and not p.dynamic,
2836
+ }
2837
+
2838
+ def _condition(name):
2839
+ negate = name.startswith('!')
2840
+ check = _CONDITIONS.get(name[1:] if negate else name)
2841
+ if check is None:
2842
+ raise ValueError('unknown affect condition: ' + name)
2843
+ return lambda o: check(o, getattr(o, 'physics', None)) != negate
2844
+
2845
+ def _matches(rule, o):
2846
+ if rule is None:
2847
+ return True
2848
+ if isinstance(rule, str):
2849
+ return _condition(rule)(o)
2850
+ if hasattr(rule, 'transform'):
2851
+ return rule is o
2852
+ if callable(rule):
2853
+ return bool(rule(o))
2854
+ return any(_matches(r, o) for r in rule)
2855
+
2856
+ def _compile(rule):
2857
+ if rule is None:
2858
+ return None
2859
+ ids = set()
2860
+ checks = []
2861
+ def walk(r):
2862
+ if isinstance(r, str):
2863
+ checks.append(_condition(r))
2864
+ elif hasattr(r, 'transform'):
2865
+ ids.add(id(r))
2866
+ elif callable(r):
2867
+ checks.append(lambda o, r=r: bool(r(o)))
2868
+ else:
2869
+ for item in r:
2870
+ walk(item)
2871
+ walk(rule)
2872
+ return lambda o: id(o) in ids or any(c(o) for c in checks)
2873
+
2874
+ class force(_lerpable):
2875
+ _owned = ['affect', 'cap', 'force', 'position', 'radius', 'torque', 'shield']
2876
+ _required = [transform]
2877
+ _settings = ('affect', 'cap', 'strength', 'position', 'radius', 'torque', 'shield')
2878
+ _aliases = {'strength': 'force'}
2879
+
2880
+ def __init__(self, obj, **kwargs):
2881
+ self.obj = obj
2882
+ self.t = obj.transform
2883
+ self.scene = obj.scene
2884
+ self.affect = kwargs.get('affect', None)
2885
+ self.cap = kwargs.get('cap', None)
2886
+ self.force = kwargs.get('force', 0)
2887
+ self.position = kwargs.get('position', None)
2888
+ self.radius = kwargs.get('radius', None)
2889
+ self.torque = kwargs.get('torque', True)
2890
+ self.shield = kwargs.get('shield', True)
2891
+
2892
+ @property
2893
+ def strength(self):
2894
+ return self.force
2895
+ @strength.setter
2896
+ def strength(self, value):
2897
+ self.force = value
2898
+
2899
+ @staticmethod
2900
+ def _resolve(value, o):
2901
+ if callable(value):
2902
+ return value(o) if _argslen(value) else value()
2903
+ return value
2904
+
2905
+ @staticmethod
2906
+ def _center(o):
2907
+ return o.transform.extent()[:2]
2908
+
2909
+ def targets(self):
2910
+ accept = _compile(self.affect)
2911
+ me = self.obj
2912
+ return [
2913
+ o for o in self.scene.objs
2914
+ if o is not me and hasattr(o, 'transform') and hasattr(o, 'physics') and o.physics.dynamic
2915
+ and (accept is None or accept(o))
2916
+ ]
2917
+
2918
+ @staticmethod
2919
+ def _shadowed(cells, box, point, direction, reach, aspect):
2920
+ x, y = point
2921
+ step_x = direction[0] * 0.25 / aspect
2922
+ step_y = direction[1] * 0.25
2923
+ min_x, max_x, min_y, max_y = box
2924
+ for i in range(1, int(reach / 0.25) + 1):
2925
+ cell = (math.floor(x + step_x * i), math.ceil(y + step_y * i))
2926
+ if cell[0] < min_x or cell[0] > max_x or cell[1] < min_y or cell[1] > max_y:
2927
+ return False
2928
+ if cell in cells:
2929
+ return True
2930
+ return False
2931
+
2932
+ def apply(self, x=None, y=None):
2933
+ affected = []
2934
+ A = self.scene.aspect
2935
+ for o in self.targets():
2936
+ p = o.physics
2937
+ cells = p.getpixels()
2938
+ if not cells:
2939
+ continue
2940
+ if self.position is None:
2941
+ px, py = self._center(self.obj)
2942
+ else:
2943
+ px, py = self._resolve(self.position, o)
2944
+ if x is not None: px = x
2945
+ if y is not None: py = y
2946
+
2947
+ value = self._resolve(self.force, o)
2948
+ vector = isinstance(value, (tuple, list))
2949
+ if vector:
2950
+ fx, fy = value
2951
+ magnitude = math.hypot(fx * A, fy)
2952
+ if not magnitude:
2953
+ continue
2954
+ travel = (fx * A / magnitude, fy / magnitude)
2955
+ elif not value:
2956
+ continue
2957
+ aimed = self.position is not None or x is not None or y is not None or self.radius is not None
2958
+ uniform = vector and not aimed
2959
+
2960
+ faces = []
2961
+ for X, Y in cells:
2962
+ for nx, ny in _NEIGHBOURS4:
2963
+ if (X + nx, Y + ny) in cells:
2964
+ continue
2965
+ ex = X + 0.5 + 0.5 * nx
2966
+ ey = Y - 0.5 + 0.5 * ny
2967
+ dx = (ex - px) * A
2968
+ dy = ey - py
2969
+ dist = math.hypot(dx, dy)
2970
+ if self.radius is not None and dist > self.radius:
2971
+ continue
2972
+ if uniform or (vector and dist < 1e-9):
2973
+ ux, uy = travel
2974
+ elif dist < 1e-9:
2975
+ continue
2976
+ else:
2977
+ ux, uy = dx / dist, dy / dist
2978
+ cosine = -(nx * ux + ny * uy)
2979
+ if cosine <= 1e-9:
2980
+ continue
2981
+ faces.append((ex, ey, dist, ux, uy, cosine * (1.0 if nx else A)))
2982
+ if vector and aimed and self.radius is None and faces:
2983
+ nearest = min(f[2] for f in faces)
2984
+ faces = [f for f in faces if f[2] <= nearest + 1e-9]
2985
+
2986
+ xs = [c[0] for c in cells]
2987
+ ys = [c[1] for c in cells]
2988
+ box = (min(xs), max(xs), min(ys), max(ys))
2989
+ cx, cy, _ = p.body()
2990
+ weight_sum = jx_sum = jy_sum = torque_sum = 0.0
2991
+ for ex, ey, dist, ux, uy, weight in faces:
2992
+ if vector:
2993
+ jx, jy = fx * A, fy
2994
+ else:
2995
+ strength = value / (4 * math.pi * max(dist, 0.5))
2996
+ if self.cap is not None:
2997
+ strength = max(-self.cap, min(self.cap, strength))
2998
+ jx, jy = strength * ux, strength * uy
2999
+ reach = 1e9 if uniform else dist - 0.5
3000
+ if self.shield and self._shadowed(cells, box, (ex, ey), (-ux, -uy), reach, A):
3001
+ continue
3002
+ weight_sum += weight
3003
+ jx_sum += weight * jx
3004
+ jy_sum += weight * jy
3005
+ torque_sum += weight * ((ex - cx - 0.5) * A * jy - (ey - cy + 0.5) * jx)
3006
+ if weight_sum <= 0:
3007
+ continue
3008
+
3009
+ p._impulse(jx_sum / weight_sum, jy_sum / weight_sum)
3010
+ if self.torque:
3011
+ p._spin(torque_sum / weight_sum)
3012
+ affected.append(o)
3013
+ return affected
3014
+
3015
+ def _wrap(angle):
3016
+ return (angle + 180) % 360 - 180
3017
+
3018
+ def _sequence(value):
3019
+ return value if isinstance(value, (list, tuple)) else tuple(value)
3020
+
3021
+ def _band(value, keep=0.0):
3022
+ if value == 'keep':
3023
+ low = high = keep
3024
+ elif isinstance(value, (int, float)):
3025
+ low = high = value
3026
+ else:
3027
+ values = [keep if v == 'keep' else v for v in value]
3028
+ low, high = min(values), max(values)
3029
+ if low < 0:
3030
+ raise ValueError('radius must not be negative')
3031
+ return low, high
3032
+
3033
+ def _arc(value):
3034
+ if value is None:
3035
+ return None
3036
+ values = list(value)
3037
+ low, high = min(values), max(values)
3038
+ if high - low >= 360:
3039
+ return None
3040
+ return (low + high) / 2, (high - low) / 2
3041
+
3042
+ def _check_radius(value):
3043
+ if value is None:
3044
+ return 0
3045
+ if not isinstance(value, (int, float)) and value != 'keep':
3046
+ value = _sequence(value)
3047
+ _band(value)
3048
+ return value
3049
+
3050
+ def _check_range(value):
3051
+ if value is None:
3052
+ return None
3053
+ value = _sequence(value)
3054
+ _arc(value)
3055
+ return value
3056
+
3057
+ _KINDS = ('ball', 'hinge', 'fixed', 'slider')
3058
+
3059
+ def _check_kind(value):
3060
+ if value not in _KINDS:
3061
+ raise ValueError('kind must be one of: ' + ', '.join(_KINDS))
3062
+ return value
3063
+
3064
+ def _check_rest(value):
3065
+ if value is None or value == 'keep' or isinstance(value, (int, float)):
3066
+ return value
3067
+ raise ValueError("rest must be None, 'keep' or an angle")
3068
+
3069
+ def _check_slide(value):
3070
+ if value is None:
3071
+ return None
3072
+ if isinstance(value, (int, float)):
3073
+ return (0.0, value) if value >= 0 else (value, 0.0)
3074
+ values = list(value)
3075
+ return (min(values), max(values))
3076
+
3077
+ def _check_elasticity(value):
3078
+ if value <= 0:
3079
+ raise ValueError('elasticity must be positive')
3080
+ return value
3081
+
3082
+ def _check_durability(value):
3083
+ if value < 0:
3084
+ raise ValueError('durability must not be negative')
3085
+ return value
3086
+
3087
+ _PARAMS = {
3088
+ 'xoffset': (0, None),
3089
+ 'yoffset': (0, None),
3090
+ 'radius': (0, _check_radius),
3091
+ 'range': (None, _check_range),
3092
+ 'elasticity': (1, _check_elasticity),
3093
+ 'durability': (0, _check_durability),
3094
+ 'physical': (True, bool),
3095
+ 'vel': (0, None),
3096
+ 'collide': (False, bool),
3097
+ 'kind': ('ball', _check_kind),
3098
+ 'rest': (None, _check_rest),
3099
+ 'axis': (0, None),
3100
+ 'slide': (None, _check_slide),
3101
+ 'pinx': (0, None),
3102
+ 'piny': (0, None),
3103
+ }
3104
+ _GEOMETRY = frozenset(('xoffset', 'yoffset', 'pinx', 'piny', 'radius', 'range', 'physical', 'kind', 'rest', 'axis', 'slide'))
3105
+ _POSE = frozenset(('xoffset', 'yoffset', 'pinx', 'piny', 'kind', 'axis'))
3106
+ _SWEEPS = 4
3107
+ _RELIEF = 1
3108
+ _SPINPIECES = 4
3109
+ _KEEP = object()
3110
+
3111
+ class _param():
3112
+ def __init__(self, name, check):
3113
+ self.name = name
3114
+ self.check = check
3115
+
3116
+ def __get__(self, inst, owner=None):
3117
+ if inst is None:
3118
+ return self
3119
+ return inst._value(self.name)
3120
+
3121
+ def __set__(self, inst, value):
3122
+ if self.check is not None:
3123
+ value = self.check(value)
3124
+ inst._store(self.name, value)
3125
+
3126
+ def __delete__(self, inst):
3127
+ inst._clear(self.name)
3128
+
3129
+ def _withparams(cls):
3130
+ for name, (_, check) in _PARAMS.items():
3131
+ setattr(cls, name, _param(name, check))
3132
+ return cls
3133
+
3134
+ def _ghost_hold(owner, partner):
3135
+ refs = owner.__dict__.setdefault('_ghostrefs', {})
3136
+ entry = refs.get(id(partner))
3137
+ if entry is None:
3138
+ added = not any(g is partner for g in owner._ghosts)
3139
+ if added:
3140
+ owner._ghosts.append(partner)
3141
+ refs[id(partner)] = [1, added]
3142
+ else:
3143
+ entry[0] += 1
3144
+
3145
+ def _ghost_drop(owner, partner):
3146
+ refs = owner.__dict__.get('_ghostrefs')
3147
+ entry = refs.get(id(partner)) if refs else None
3148
+ if entry is None:
3149
+ return
3150
+ entry[0] -= 1
3151
+ if entry[0] > 0:
3152
+ return
3153
+ del refs[id(partner)]
3154
+ if entry[1]:
3155
+ owner._ghosts[:] = [g for g in owner._ghosts if g is not partner]
3156
+
3157
+ class _jointlist(collections.abc.MutableSequence):
3158
+ def __init__(self, owner):
3159
+ self._owner = owner
3160
+
3161
+ def _items(self):
3162
+ owner = self._owner
3163
+ return [edge.other(owner).obj for edge in owner._edges.values()]
3164
+
3165
+ def __len__(self):
3166
+ return len(self._owner._edges)
3167
+
3168
+ def __iter__(self):
3169
+ return iter(self._items())
3170
+
3171
+ def __contains__(self, item):
3172
+ return id(item) in self._owner._edges
3173
+
3174
+ def __getitem__(self, index):
3175
+ return self._items()[index]
3176
+
3177
+ def __setitem__(self, index, value):
3178
+ items = self._items()
3179
+ items[index] = value
3180
+ self._owner._assign(items)
3181
+
3182
+ def __delitem__(self, index):
3183
+ items = self._items()
3184
+ del items[index]
3185
+ self._owner._assign(items)
3186
+
3187
+ def __eq__(self, other):
3188
+ if isinstance(other, (list, tuple, _jointlist)):
3189
+ return self._items() == list(other)
3190
+ return NotImplemented
3191
+
3192
+ __hash__ = None
3193
+
3194
+ def __repr__(self):
3195
+ return repr(self._items())
3196
+
3197
+ def __iadd__(self, items):
3198
+ self.extend(items)
3199
+ return self
3200
+
3201
+ def insert(self, index, item):
3202
+ items = self._items()
3203
+ items.insert(index, item)
3204
+ self._owner._assign(items)
3205
+
3206
+ def append(self, item):
3207
+ self._owner._assign(self._items() + [item])
3208
+
3209
+ def extend(self, items):
3210
+ self._owner._assign(self._items() + list(items))
3211
+
3212
+ def remove(self, item):
3213
+ self._owner.remove(item)
3214
+
3215
+ def pop(self, index=-1):
3216
+ item = self._items()[index]
3217
+ self._owner.remove(item)
3218
+ return item
3219
+
3220
+ def clear(self):
3221
+ self._owner._assign(())
3222
+
3223
+ class _edge():
3224
+ def __init__(self, origin, follower):
3225
+ self.origin = origin
3226
+ self.follower = follower
3227
+ self.alive = True
3228
+ self.held = False
3229
+ self.engaged = False
3230
+ self.pose = None
3231
+ self._collide = None
3232
+
3233
+ def other(self, comp):
3234
+ return self.follower if comp is self.origin else self.origin
3235
+
3236
+ @property
3237
+ def collide(self):
3238
+ return self._collide
3239
+
3240
+ @collide.setter
3241
+ def collide(self, value):
3242
+ self._collide = None if value is None else bool(value)
3243
+ self.rewire()
3244
+
3245
+ @property
3246
+ def colliding(self):
3247
+ return self.follower.collide if self._collide is None else self._collide
3248
+
3249
+ @property
3250
+ def physical(self):
3251
+ return self.follower.physical
3252
+
3253
+ def anchor(self):
3254
+ return self.origin._anchor(self.follower.xoffset, self.follower.yoffset)
3255
+
3256
+ def pin(self):
3257
+ f = self.follower
3258
+ if not (f.pinx or f.piny):
3259
+ return f.t.extent()[:2]
3260
+ return f._anchor(f.pinx, f.piny)
3261
+
3262
+ @property
3263
+ def pinned(self):
3264
+ return bool(self.follower.pinx or self.follower.piny)
3265
+
3266
+ def rewire(self):
3267
+ want = self.alive and not self.colliding
3268
+ if want == self.held:
3269
+ return
3270
+ self.held = want
3271
+ a, b = self.origin.obj, self.follower.obj
3272
+ if want:
3273
+ _ghost_hold(a, b)
3274
+ _ghost_hold(b, a)
3275
+ else:
3276
+ _ghost_drop(a, b)
3277
+ _ghost_drop(b, a)
3278
+
3279
+ def _geometry(self):
3280
+ f = self.follower
3281
+ A = f.scene.aspect
3282
+ ax, ay = self.anchor()
3283
+ cx, cy = self.pin()
3284
+ dx = (cx - ax) * A
3285
+ dy = cy - ay
3286
+ return A, ax, ay, cx, cy, dx, dy, math.hypot(dx, dy)
3287
+
3288
+ def capture(self):
3289
+ dx, dy, dist = self._geometry()[5:]
3290
+ rotation = self.origin.t.rotation
3291
+ rel = _wrap(math.degrees(math.atan2(dx, dy)) - rotation) if dist > 1e-9 else 0.0
3292
+ self.pose = (dist, rel, _wrap(self.follower.t.rotation - rotation))
3293
+ return self.pose
3294
+
3295
+ def error(self, motor):
3296
+ f = self.follower
3297
+ pose = self.pose or self.capture()
3298
+ A, ax, ay, cx, cy, dx, dy, dist = self._geometry()
3299
+ rotation = self.origin.t.rotation
3300
+ kind = f.kind
3301
+ if kind == 'slider':
3302
+ heading = math.radians(rotation + f.axis)
3303
+ ux, uy = math.sin(heading), math.cos(heading)
3304
+ target = dx * ux + dy * uy + (f.vel if motor else 0)
3305
+ slide = f.slide
3306
+ if slide is not None:
3307
+ target = min(max(target, slide[0]), slide[1])
3308
+ ex = ax + target * ux / A - cx
3309
+ ey = ay + target * uy - cy
3310
+ miss = math.hypot(ex * A, ey)
3311
+ return (0.0, 0.0, 0.0) if miss < 1e-9 else (ex, ey, miss)
3312
+ if kind == 'fixed':
3313
+ heading = math.radians(rotation + pose[1])
3314
+ ex = ax + pose[0] * math.sin(heading) / A - cx
3315
+ ey = ay + pose[0] * math.cos(heading) - cy
3316
+ miss = math.hypot(ex * A, ey)
3317
+ return (0.0, 0.0, 0.0) if miss < 1e-9 else (ex, ey, miss)
3318
+ low, high = _band(f.radius, pose[0])
3319
+ arc = _arc(f.range)
3320
+ base = 0.0 if f.rest is None else pose[1] if f.rest == 'keep' else f.rest
3321
+ mid = arc[0] + base if arc else base
3322
+ if dist < 1e-9:
3323
+ rel = mid
3324
+ else:
3325
+ rel = _wrap(math.degrees(math.atan2(dx, dy)) - rotation)
3326
+ target_rel = rel + (f.vel if motor else 0)
3327
+ if arc is not None:
3328
+ off = _wrap(target_rel - mid)
3329
+ if abs(off) > arc[1]:
3330
+ target_rel = mid + math.copysign(arc[1], off)
3331
+ target_dist = min(max(dist, low), high)
3332
+ if abs(target_dist - dist) < 1e-9 and abs(_wrap(target_rel - rel)) < 1e-9:
3333
+ return 0.0, 0.0, dist
3334
+ heading = math.radians(rotation + target_rel)
3335
+ ex = ax + target_dist * math.sin(heading) / A - cx
3336
+ ey = ay + target_dist * math.cos(heading) - cy
3337
+ if math.hypot(ex * A, ey) < 1e-9:
3338
+ return 0.0, 0.0, dist
3339
+ return ex, ey, dist
3340
+
3341
+ def beyond(self):
3342
+ f = self.follower
3343
+ pose = self.pose or self.capture()
3344
+ A, ax, ay, cx, cy, dx, dy, dist = self._geometry()
3345
+ rotation = self.origin.t.rotation
3346
+ if f.kind == 'slider':
3347
+ if f.slide is None:
3348
+ return False
3349
+ heading = math.radians(rotation + f.axis)
3350
+ s = dx * math.sin(heading) + dy * math.cos(heading)
3351
+ return s < f.slide[0] - 1e-6 or s > f.slide[1] + 1e-6
3352
+ arc = _arc(f.range)
3353
+ if arc is None or f.kind == 'fixed' or dist < 1e-9:
3354
+ return False
3355
+ base = 0.0 if f.rest is None else pose[1] if f.rest == 'keep' else f.rest
3356
+ rel = _wrap(math.degrees(math.atan2(dx, dy)) - rotation)
3357
+ return abs(_wrap(rel - arc[0] - base)) > arc[1] + 1e-6
3358
+
3359
+ def spin(self):
3360
+ kind = self.follower.kind
3361
+ if kind == 'ball':
3362
+ return None
3363
+ pose = self.pose or self.capture()
3364
+ rotation = self.origin.t.rotation
3365
+ if kind == 'hinge':
3366
+ dx, dy, dist = self._geometry()[5:]
3367
+ rel = _wrap(math.degrees(math.atan2(dx, dy)) - rotation) if dist > 1e-9 else pose[1]
3368
+ return rotation + pose[2] + _wrap(rel - pose[1])
3369
+ return rotation + pose[2]
3370
+
3371
+ def pull(self, ex, ey):
3372
+ o, f = self.origin, self.follower
3373
+ if o.movable and f.movable:
3374
+ total = o.mass + f.mass
3375
+ wc, wp = o.mass / total, f.mass / total
3376
+ elif f.movable:
3377
+ wc, wp = 1.0, 0.0
3378
+ elif o.movable:
3379
+ wc, wp = 0.0, 1.0
3380
+ else:
3381
+ return
3382
+ A = f.scene.aspect
3383
+ k = min(1.0, 1.0 / f.elasticity)
3384
+ damp = 1.0 - (1.0 - math.sqrt(k)) ** 2
3385
+ pinned = self.pinned and f.kind == 'ball'
3386
+ fkick = pinned
3387
+ okick = pinned or bool(f.xoffset or f.yoffset)
3388
+ fpoint = self.pin()
3389
+ opoint = self.anchor()
3390
+ fvx, fvy = f._pointvel(fpoint) if fkick else f.velocity
3391
+ ovx, ovy = o._pointvel(opoint) if okick else o.velocity
3392
+ rx, ry = fvx - ovx, fvy - ovy
3393
+ fx = fy = 0.0
3394
+ ux, uy = ex * A, ey
3395
+ n = math.hypot(ux, uy)
3396
+ full = f.kind in ('fixed', 'slider') or n <= 1e-9
3397
+ if not full:
3398
+ low, high = _band(f.radius, (self.pose or self.capture())[0])
3399
+ full = high <= 1e-9
3400
+ if full:
3401
+ fx = -rx * damp
3402
+ fy = -ry * damp
3403
+ else:
3404
+ along = (rx * A * ux + ry * uy) / n
3405
+ if along < 0 or f.kind != 'ball' or high - low <= 1e-9:
3406
+ fx = -along * ux / n / A * damp
3407
+ fy = -along * uy / n * damp
3408
+ for comp, point, w, kick in ((f, fpoint, wc, fkick), (o, opoint, -wp, okick)):
3409
+ p = getattr(comp.obj, 'physics', None)
3410
+ if p is not None and p.dynamic:
3411
+ if w:
3412
+ gx = (ex * k + fx) * w
3413
+ gy = (ey * k + fy) * w
3414
+ if kick:
3415
+ comp._kick(point, gx, gy)
3416
+ else:
3417
+ p.xvel += gx * p.mass
3418
+ p.yvel += gy * p.mass
3419
+ else:
3420
+ comp._push(ex * w * k, ey * w * k)
3421
+
3422
+ @_withparams
3423
+ class joint(_lerpable):
3424
+ _angles = frozenset(('rest', 'axis'))
3425
+ _owned = ['joints', 'xoffset', 'yoffset', 'pinx', 'piny', 'jradius', 'range', 'elasticity',
3426
+ 'durability', 'physical', 'vel', 'collide', 'kind', 'rest', 'axis', 'slide', 'preset']
3427
+ _required = [transform]
3428
+ _settings = ('preset', 'joints', 'xoffset', 'yoffset', 'pinx', 'piny', 'radius', 'range', 'elasticity',
3429
+ 'durability', 'physical', 'vel', 'collide', 'kind', 'rest', 'axis', 'slide')
3430
+ _aliases = {'radius': 'jradius'}
3431
+ _watch = frozenset(('x', 'y', 'rotation', 'text'))
3432
+ _active = False
3433
+ presets = {
3434
+ 'ball': {'kind': 'ball'},
3435
+ 'rod': {'kind': 'ball', 'radius': 'keep'},
3436
+ 'rope': {'kind': 'ball', 'radius': (0, 'keep'), 'physical': False},
3437
+ 'spring': {'kind': 'ball', 'radius': 'keep', 'elasticity': 8},
3438
+ 'hinge': {'kind': 'hinge', 'radius': 'keep'},
3439
+ 'fixed': {'kind': 'fixed'},
3440
+ 'slider': {'kind': 'slider'},
3441
+ 'neck': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-45, 45)},
3442
+ 'spine': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-25, 25)},
3443
+ 'shoulder': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-170, 170)},
3444
+ 'elbow': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-150, 0)},
3445
+ 'wrist': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-70, 70)},
3446
+ 'hip': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-100, 100)},
3447
+ 'knee': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (0, 140)},
3448
+ 'ankle': {'kind': 'hinge', 'radius': 'keep', 'rest': 'keep', 'range': (-40, 40)},
3449
+ }
3450
+
3451
+ def __init__(self, obj, **kwargs):
3452
+ self.obj = obj
3453
+ self.t = obj.transform
3454
+ self.scene = obj.scene
3455
+ self._params = {}
3456
+ self._edges = {}
3457
+ self._onbreak = []
3458
+ self._preset = None
3459
+ self._stamp = None
3460
+ self._spun = None
3461
+ self._joints = _jointlist(self)
3462
+ if kwargs.get('preset') is not None:
3463
+ self.apply(kwargs['preset'])
3464
+ for name in _PARAMS:
3465
+ key = 'jradius' if name == 'radius' else name
3466
+ if key in kwargs:
3467
+ setattr(self, name, kwargs[key])
3468
+ def change(attr):
3469
+ self._on_change(attr)
3470
+ self._pcb = change
3471
+ self.t.onchange(change)
3472
+ def sibling(other):
3473
+ theirs = getattr(other, 'joint', None)
3474
+ if theirs is None or theirs is self:
3475
+ return False
3476
+ for mine in self._edges.values():
3477
+ if mine.follower is not self or not mine.alive or mine.colliding:
3478
+ continue
3479
+ shared = theirs._edges.get(id(mine.origin.obj))
3480
+ if shared is not None and shared.follower is theirs and shared.alive and not shared.colliding:
3481
+ return True
3482
+ return False
3483
+ self._ghostcb = sibling
3484
+ obj._ghosts.append(sibling)
3485
+ self._assign(kwargs.get('joints', ()))
3486
+
3487
+ def __getitem__(self, key):
3488
+ return getattr(self, key)
3489
+
3490
+ def __setitem__(self, key, value):
3491
+ setattr(self, key, value)
3492
+
3493
+ def _value(self, name):
3494
+ if name in self._params:
3495
+ return self._params[name]
3496
+ return _PARAMS[name][0]
3497
+
3498
+ def _store(self, name, value):
3499
+ self._params[name] = value
3500
+ self._sync({name})
3501
+
3502
+ def _clear(self, name):
3503
+ if name in self._params:
3504
+ del self._params[name]
3505
+ self._sync({name})
3506
+
3507
+ def _sync(self, names):
3508
+ edges = [e for e in self._edges.values() if e.follower is self]
3509
+ if 'collide' in names:
3510
+ for edge in edges:
3511
+ edge.rewire()
3512
+ if _POSE & names:
3513
+ for edge in edges:
3514
+ edge.capture()
3515
+ if _GEOMETRY & names:
3516
+ for edge in edges:
3517
+ self._realign(edge)
3518
+
3519
+ @staticmethod
3520
+ def mirrored(spec):
3521
+ if isinstance(spec, str):
3522
+ spec = joint.presets[spec]
3523
+ spec = dict(spec)
3524
+ if spec.get('range') is not None:
3525
+ spec['range'] = tuple(-v for v in spec['range'])
3526
+ if spec.get('rest') not in (None, 'keep'):
3527
+ spec['rest'] = -spec['rest']
3528
+ if spec.get('axis'):
3529
+ spec['axis'] = -spec['axis']
3530
+ if spec.get('xoffset'):
3531
+ spec['xoffset'] = -spec['xoffset']
3532
+ return spec
3533
+
3534
+ @property
3535
+ def preset(self):
3536
+ return self._preset
3537
+
3538
+ @preset.setter
3539
+ def preset(self, value):
3540
+ self.apply(value)
3541
+
3542
+ def apply(self, preset=None, **settings):
3543
+ spec = {}
3544
+ if preset is not None:
3545
+ if isinstance(preset, str):
3546
+ if preset not in joint.presets:
3547
+ raise KeyError('unknown joint preset: ' + preset)
3548
+ spec.update(joint.presets[preset])
3549
+ self._preset = preset
3550
+ else:
3551
+ spec.update(preset)
3552
+ self._preset = None
3553
+ spec.update(settings)
3554
+ for key in spec:
3555
+ if key not in _PARAMS and key != 'jradius':
3556
+ raise KeyError('unknown joint setting: ' + str(key))
3557
+ for key, value in spec.items():
3558
+ setattr(self, 'radius' if key == 'jradius' else key, value)
3559
+ return self
3560
+
3561
+ def attach(self, item, preset=None, link_collide=_KEEP, at=None, **settings):
3562
+ if getattr(item, 'transform', None) is None:
3563
+ try:
3564
+ items = list(item)
3565
+ except TypeError:
3566
+ raise TypeError('a jointed object needs a transform component') from None
3567
+ for each in items:
3568
+ if getattr(each, 'transform', None) is None:
3569
+ raise TypeError('a jointed object needs a transform component')
3570
+ if each is self.obj:
3571
+ raise ValueError('an object cannot be jointed to itself')
3572
+ return [self.attach(each, preset, link_collide, at, **settings) for each in items]
3573
+ if getattr(item, 'joint', None) is None:
3574
+ item.set(joint)
3575
+ item.joint.apply(preset, **settings)
3576
+ if at is not None:
3577
+ if id(item) not in self._edges:
3578
+ self._connect(item)
3579
+ self.anchor_at(item, at)
3580
+ item.joint._realign(self._edges[id(item)])
3581
+ elif id(item) not in self._edges:
3582
+ self._joints.append(item)
3583
+ if link_collide is not _KEEP:
3584
+ self._edges[id(item)].collide = link_collide
3585
+ return item.joint
3586
+
3587
+ def _local(self, point):
3588
+ # world point -> (xoffset, yoffset) in this object's frame (inverse of _anchor)
3589
+ cx, cy = self.t.extent()[:2]
3590
+ A = self.scene.aspect
3591
+ r = math.radians(self.t.rotation)
3592
+ dx, dy = (point[0] - cx) * A, point[1] - cy
3593
+ return (dx * math.cos(r) - dy * math.sin(r)) / A, dx * math.sin(r) + dy * math.cos(r)
3594
+
3595
+ def _seam(self, other, cells):
3596
+ edge = self._edges.get(id(other))
3597
+ if edge is None or not edge.alive or not edge.colliding:
3598
+ return False
3599
+ A = self.scene.aspect
3600
+ points = (edge.anchor(), edge.pin())
3601
+ frontier = [c for c in cells
3602
+ if any(math.hypot((c[0] + 0.5 - p[0]) * A, c[1] - 0.5 - p[1]) <= 1.5 for p in points)]
3603
+ if not frontier:
3604
+ return False
3605
+ seen = set(frontier)
3606
+ while frontier:
3607
+ x, y = frontier.pop()
3608
+ for dx in (-1, 0, 1):
3609
+ for dy in (-1, 0, 1):
3610
+ n = (x + dx, y + dy)
3611
+ if n in cells and n not in seen:
3612
+ seen.add(n)
3613
+ frontier.append(n)
3614
+ return len(seen) == len(cells)
3615
+
3616
+ def _connect(self, item):
3617
+ # link self -> item without moving anything (offsets are expected to be set afterwards)
3618
+ edge = _edge(self, item.joint)
3619
+ self._edges[id(item)] = edge
3620
+ item.joint._edges[id(self.obj)] = edge
3621
+ edge.rewire()
3622
+ return edge
3623
+
3624
+ def anchor_at(self, item, point):
3625
+ # join self and item at one world point: the anchor sits on self and the pin on item,
3626
+ # so the pair meets at that point (e.g. the edge of two sticks) instead of at item's centre
3627
+ edge = self._edges.get(id(item))
3628
+ if edge is None or edge.origin is not self:
3629
+ raise KeyError('object is not a follower of this joint')
3630
+ f = edge.follower
3631
+ f._params['xoffset'], f._params['yoffset'] = self._local(point)
3632
+ f._params['pinx'], f._params['piny'] = f._local(point)
3633
+ edge.capture()
3634
+ return edge
3635
+
3636
+ def group(self):
3637
+ # every object connected to this one through joints (this one included)
3638
+ seen = {id(self.obj): self.obj}
3639
+ queue = [self]
3640
+ for comp in queue:
3641
+ for edge in list(comp._edges.values()):
3642
+ if not edge.alive:
3643
+ continue
3644
+ other = edge.other(comp)
3645
+ if id(other.obj) not in seen:
3646
+ seen[id(other.obj)] = other.obj
3647
+ queue.append(other)
3648
+ return list(seen.values())
3649
+
3650
+ def teleport(self, dx, dy, stop=True):
3651
+ # move the whole connected group rigidly. Moving members one by one lets the joints see
3652
+ # a huge stretch (the first member is already there, the others are not) and fling them.
3653
+ members = self.group()
3654
+ joint._active = True
3655
+ try:
3656
+ for o in members:
3657
+ t = o.transform
3658
+ t.x += dx
3659
+ t.y += dy
3660
+ p = getattr(o, 'physics', None)
3661
+ if stop and p is not None:
3662
+ p.xvel = p.yvel = p.avel = 0
3663
+ finally:
3664
+ joint._active = False
3665
+ return members
3666
+
3667
+ @staticmethod
3668
+ def transfer(old, parts):
3669
+ # `old` is being split into `parts` (and then discarded): hand each of its joints to the
3670
+ # part that holds the joint point, so that part stays joined to the same partner.
3671
+ jt = getattr(old, 'joint', None)
3672
+ if jt is None or not jt._edges:
3673
+ return
3674
+ A = old.scene.aspect
3675
+ cells = []
3676
+ for part in parts:
3677
+ t = getattr(part, 'transform', None)
3678
+ cells.append(((wx + 0.5, wy - 0.5) for wx, wy in t.worldcells()) if t is not None else ())
3679
+ cells = [list(c) for c in cells]
3680
+ plan = []
3681
+ for edge in list(jt._edges.values()):
3682
+ if not edge.alive:
3683
+ continue
3684
+ as_origin = edge.origin is jt
3685
+ point = edge.anchor() if as_origin else edge.pin()
3686
+ best, bd = None, None
3687
+ for part, pts in zip(parts, cells):
3688
+ if not pts:
3689
+ continue
3690
+ d = min(((px - point[0]) * A) ** 2 + (py - point[1]) ** 2 for px, py in pts)
3691
+ if bd is None or d < bd - 1e-9 or (abs(d - bd) <= 1e-9 and len(pts) > len(cells[parts.index(best)])):
3692
+ best, bd = part, d
3693
+ if best is not None:
3694
+ plan.append((edge, as_origin, point, best))
3695
+ for edge, as_origin, point, part in plan:
3696
+ if getattr(part, 'joint', None) is None:
3697
+ part.set(joint)
3698
+ pj = part.joint
3699
+ if as_origin:
3700
+ f = edge.follower
3701
+ new = pj._connect(f.obj)
3702
+ f._params['xoffset'], f._params['yoffset'] = pj._local(point)
3703
+ else:
3704
+ o = edge.origin
3705
+ new = o._connect(part)
3706
+ pj._params.update(jt._params)
3707
+ pj._preset = jt._preset
3708
+ pj._onbreak = list(jt._onbreak)
3709
+ pj._params['pinx'], pj._params['piny'] = pj._local(point)
3710
+ new._collide = edge._collide
3711
+ new.rewire()
3712
+ new.capture()
3713
+
3714
+ def link(self, item):
3715
+ edge = self._edges.get(id(item))
3716
+ if edge is None:
3717
+ raise KeyError('object is not jointed')
3718
+ return edge
3719
+
3720
+ def repose(self, item=None):
3721
+ for edge in list(self._edges.values()):
3722
+ if item is None or edge.other(self).obj is item:
3723
+ edge.capture()
3724
+ return self
3725
+
3726
+ @staticmethod
3727
+ def rope(scene, start=None, end=None, segments=10, char='o', anchor=None, tail=None, pin=None,
3728
+ preset='rope', selfcollide=False, anchoroffset=(0, 0), tailoffset=(0, 0), **kwargs):
3729
+ if segments < 1:
3730
+ raise ValueError('a rope needs at least one segment')
3731
+ def endpoint(item, offset):
3732
+ if getattr(item, 'joint', None) is None:
3733
+ item.set(joint)
3734
+ x, y = item.joint._anchor(*offset)
3735
+ return x - 0.5, y + 0.5
3736
+ if start is None:
3737
+ if anchor is None:
3738
+ raise ValueError('a rope needs a start point or an anchor')
3739
+ start = endpoint(anchor, anchoroffset)
3740
+ if end is None:
3741
+ if tail is None:
3742
+ raise ValueError('a rope needs an end point or a tail')
3743
+ end = endpoint(tail, tailoffset)
3744
+ chars = char if callable(char) else (lambda i: char)
3745
+ pin = anchor is None if pin is None else pin
3746
+ parts = []
3747
+ last = max(1, segments - 1)
3748
+ for i in range(segments):
3749
+ k = i / last if segments > 1 else 0
3750
+ x = start[0] + (end[0] - start[0]) * k
3751
+ y = start[1] + (end[1] - start[1]) * k
3752
+ seg = dict(kwargs)
3753
+ seg['dynamic'] = not (i == 0 and pin) and seg.get('dynamic', True)
3754
+ parts.append(scene.obj().create(chars(i), x, y, **seg))
3755
+ links = list(zip(parts, parts[1:]))
3756
+ if anchor is not None:
3757
+ links.insert(0, (anchor, parts[0]))
3758
+ if tail is not None:
3759
+ links.append((parts[-1], tail))
3760
+ for parent, child in links:
3761
+ if getattr(parent, 'joint', None) is None:
3762
+ parent.set(joint)
3763
+ ends = {}
3764
+ if parent is anchor and child is parts[0] and any(anchoroffset):
3765
+ ends = {'xoffset': anchoroffset[0], 'yoffset': anchoroffset[1]}
3766
+ elif child is tail and parent is parts[-1] and any(tailoffset):
3767
+ ends = {'pinx': tailoffset[0], 'piny': tailoffset[1]}
3768
+ parent.joint.attach(child, preset, **ends)
3769
+ if not selfcollide:
3770
+ for i, a in enumerate(parts):
3771
+ for b in parts[i + 2:]:
3772
+ if not any(g is b for g in a._ghosts):
3773
+ a._ghosts.append(b)
3774
+ if not any(g is a for g in b._ghosts):
3775
+ b._ghosts.append(a)
3776
+ return parts
3777
+
3778
+ @property
3779
+ def jradius(self):
3780
+ return self.radius
3781
+
3782
+ @jradius.setter
3783
+ def jradius(self, value):
3784
+ self.radius = value
3785
+
3786
+ @property
3787
+ def joints(self):
3788
+ return self._joints
3789
+
3790
+ @joints.setter
3791
+ def joints(self, value):
3792
+ self._assign(value)
3793
+
3794
+ @property
3795
+ def movable(self):
3796
+ p = getattr(self.obj, 'physics', None)
3797
+ return p is None or p.dynamic
3798
+
3799
+ @property
3800
+ def mass(self):
3801
+ p = getattr(self.obj, 'physics', None)
3802
+ return p.mass if p is not None and p.dynamic else 1.0
3803
+
3804
+ @property
3805
+ def velocity(self):
3806
+ p = getattr(self.obj, 'physics', None)
3807
+ if p is None or not p.dynamic:
3808
+ return 0.0, 0.0
3809
+ return p.xvel / p.mass, p.yvel / p.mass
3810
+
3811
+ def remove(self, item):
3812
+ edge = self._edges.get(id(item))
3813
+ if edge is None:
3814
+ raise ValueError('object is not jointed')
3815
+ self._unlink(edge)
3816
+
3817
+ def anchor(self, other):
3818
+ edge = self._edges.get(id(other))
3819
+ if edge is None:
3820
+ raise KeyError('object is not jointed')
3821
+ return edge.anchor()
3822
+
3823
+ def onbreak(self, cb):
3824
+ self._onbreak.append(cb)
3825
+ return cb
3826
+
3827
+ def offbreak(self, cb):
3828
+ self._onbreak = [f for f in self._onbreak if f is not cb]
3829
+
3830
+ def on_discard(self):
3831
+ self.t.offchange(self._pcb)
3832
+ self.obj._ghosts[:] = [g for g in self.obj._ghosts if g is not self._ghostcb]
3833
+ for edge in list(self._edges.values()):
3834
+ self._unlink(edge)
3835
+
3836
+ def _hierarchy(self):
3837
+ up = []
3838
+ down = []
3839
+ for e in self._edges.values():
3840
+ if not e.alive or e.follower.kind == 'ball':
3841
+ continue
3842
+ if e.follower is self:
3843
+ up.append(e.origin.obj)
3844
+ else:
3845
+ down.append(e.follower.obj)
3846
+ return up, down
3847
+
3848
+ def _accepted(self, spec):
3849
+ items = []
3850
+ seen = set()
3851
+ for item in spec:
3852
+ if item is self.obj:
3853
+ raise ValueError('an object cannot be jointed to itself')
3854
+ if getattr(item, 'transform', None) is None:
3855
+ raise TypeError('a jointed object needs a transform component')
3856
+ if id(item) not in seen:
3857
+ seen.add(id(item))
3858
+ items.append(item)
3859
+ return items
3860
+
3861
+ def _assign(self, spec):
3862
+ items = self._accepted(spec)
3863
+ wanted = {id(item) for item in items}
3864
+ for edge in [e for e in self._edges.values() if id(e.other(self).obj) not in wanted]:
3865
+ self._unlink(edge)
3866
+ ordered = {}
3867
+ fresh = []
3868
+ for item in items:
3869
+ edge = self._edges.get(id(item))
3870
+ if edge is None:
3871
+ if getattr(item, 'joint', None) is None:
3872
+ item.set(joint)
3873
+ edge = _edge(self, item.joint)
3874
+ item.joint._edges[id(self.obj)] = edge
3875
+ edge.capture()
3876
+ fresh.append(edge)
3877
+ ordered[id(item)] = edge
3878
+ self._edges = ordered
3879
+ for edge in fresh:
3880
+ edge.rewire()
3881
+ for edge in fresh:
3882
+ self._realign(edge)
3883
+
3884
+ def _unlink(self, edge):
3885
+ edge.alive = False
3886
+ edge.rewire()
3887
+ for comp in (edge.origin, edge.follower):
3888
+ other = edge.other(comp)
3889
+ if comp._edges.get(id(other.obj)) is edge:
3890
+ del comp._edges[id(other.obj)]
3891
+ f = edge.follower
3892
+ p = getattr(f.obj, 'physics', None)
3893
+ if p is not None and f.kind != 'ball' and not any(e.follower is f for e in f._edges.values()):
3894
+ p.avel = 0
3895
+ f._spun = None
3896
+
3897
+ def _break(self, edge):
3898
+ o, f = edge.origin, edge.follower
3899
+ self._unlink(edge)
3900
+ for comp, other in ((o, f.obj), (f, o.obj)):
3901
+ for cb in list(comp._onbreak):
3902
+ if _argslen(cb):
3903
+ cb(other)
3904
+ else:
3905
+ cb()
3906
+
3907
+ def _anchor(self, xoffset, yoffset):
3908
+ cx, cy = self.t.extent()[:2]
3909
+ A = self.scene.aspect
3910
+ r = math.radians(self.t.rotation)
3911
+ u = xoffset * A
3912
+ v = yoffset
3913
+ return (cx + (u * math.cos(r) + v * math.sin(r)) / A,
3914
+ cy + (-u * math.sin(r) + v * math.cos(r)))
3915
+
3916
+ def _move(self, dx, dy):
3917
+ o = self.obj
3918
+ t = o.transform
3919
+ p = getattr(o, 'physics', None)
3920
+ x, y = t.x, t.y
3921
+ if p is not None and p.dynamic and p.solid and not p.phantom:
3922
+ def depth(px, py):
3923
+ return sum(len(v) for v in p.overlaps(p.getpixels(px, py)).values())
3924
+ inside = depth(x, y)
3925
+ steps = min(200, max(1, int(math.ceil(max(abs(dx), abs(dy))))))
3926
+ sx, sy = dx / steps, dy / steps
3927
+ for _ in range(steps):
3928
+ moved = False
3929
+ if sx:
3930
+ d = depth(x + sx, y)
3931
+ if d == 0 or d < inside:
3932
+ x += sx
3933
+ inside = d
3934
+ moved = True
3935
+ if sy:
3936
+ d = depth(x, y + sy)
3937
+ if d == 0 or d < inside:
3938
+ y += sy
3939
+ inside = d
3940
+ moved = True
3941
+ if not moved:
3942
+ break
3943
+ else:
3944
+ x, y = x + dx, y + dy
3945
+ if x != t.x:
3946
+ t.x = x
3947
+ if y != t.y:
3948
+ t.y = y
3949
+
3950
+ def _push(self, dx, dy):
3951
+ p = getattr(self.obj, 'physics', None)
3952
+ if p is not None and p.dynamic:
3953
+ p.xvel += dx * p.mass
3954
+ p.yvel += dy * p.mass
3955
+ else:
3956
+ self._move(dx, dy)
3957
+
3958
+ def _pointvel(self, point):
3959
+ p = getattr(self.obj, 'physics', None)
3960
+ if p is None or not p.dynamic:
3961
+ return 0.0, 0.0
3962
+ A = self.scene.aspect
3963
+ cx, cy, _ = p.body()
3964
+ rx, ry = (point[0] - cx - 0.5) * A, point[1] - cy + 0.5
3965
+ om = p._omega()
3966
+ return p.xvel / p.mass - om * ry / A, p.yvel / p.mass + om * rx
3967
+
3968
+ def _kick(self, point, gx, gy):
3969
+ # change the velocity of `point` on this body by (gx, gy), spreading it between
3970
+ # linear and angular motion so the point itself moves by about that much
3971
+ p = self.obj.physics
3972
+ if p._invinertia():
3973
+ A = self.scene.aspect
3974
+ cx, cy, spread = p.body()
3975
+ rx, ry = (point[0] - cx - 0.5) * A, point[1] - cy + 0.5
3976
+ n = math.hypot(gx * A, gy)
3977
+ if n > 1e-12:
3978
+ cross = (rx * gy - ry * gx * A) / n
3979
+ s = 1.0 / (1.0 + cross * cross / spread)
3980
+ gx *= s
3981
+ gy *= s
3982
+ p._spin((rx * gy - ry * gx * A) * p.mass)
3983
+ p.xvel += gx * p.mass
3984
+ p.yvel += gy * p.mass
3985
+
3986
+ def _absorb(self, ex, ey):
3987
+ p = getattr(self.obj, 'physics', None)
3988
+ if p is None or not p.dynamic:
3989
+ return
3990
+ A = self.scene.aspect
3991
+ ux, uy = ex * A, ey
3992
+ n = math.hypot(ux, uy)
3993
+ if n < 1e-9:
3994
+ return
3995
+ ux /= n
3996
+ uy /= n
3997
+ along = (p.xvel / p.mass * A) * ux + (p.yvel / p.mass) * uy
3998
+ if along < 0:
3999
+ p.xvel -= along * ux / A * p.mass
4000
+ p.yvel -= along * uy * p.mass
4001
+
4002
+ def _order(self):
4003
+ depth = {id(self): 0}
4004
+ queue = [self]
4005
+ seen = set()
4006
+ result = []
4007
+ for comp in queue:
4008
+ for edge in list(comp._edges.values()):
4009
+ if edge.physical or edge in seen:
4010
+ continue
4011
+ seen.add(edge)
4012
+ other = edge.other(comp)
4013
+ if id(other) not in depth:
4014
+ depth[id(other)] = depth[id(comp)] + 1
4015
+ queue.append(other)
4016
+ result.append((edge, comp, other))
4017
+ elif depth[id(comp)] <= depth[id(other)]:
4018
+ result.append((edge, comp, other))
4019
+ else:
4020
+ result.append((edge, other, comp))
4021
+ return result
4022
+
4023
+ def _orient(self, edge):
4024
+ angle = edge.spin()
4025
+ if angle is None:
4026
+ return False
4027
+ t = self.t
4028
+ p = getattr(self.obj, 'physics', None)
4029
+ solid = p is not None and p.dynamic and p.solid and not p.phantom and not p.hitbox
4030
+ turn = _wrap(angle - t.rotation)
4031
+ step = 0 if self._spun is None else _wrap(angle - self._spun)
4032
+ self._spun = angle
4033
+ if p is not None and p.dynamic:
4034
+ want = step * p.mass if edge.physical and abs(step) <= p.rprecision * _SPINPIECES else 0
4035
+ if p.avel != want:
4036
+ p.avel = want
4037
+ if abs(turn) < 1e-6:
4038
+ return False
4039
+ t.rotation = angle
4040
+ if solid and p.overlaps():
4041
+ physics._running += 1
4042
+ try:
4043
+ p._depenetrate()
4044
+ finally:
4045
+ physics._running -= 1
4046
+ return True
4047
+
4048
+ def _fix(self, edge, near, far, motor, strict):
4049
+ ex, ey, dist = edge.error(motor)
4050
+ f = edge.follower
4051
+ if strict and f.durability and dist > f.durability:
4052
+ self._break(edge)
4053
+ return False
4054
+ moved = False
4055
+ if ex or ey:
4056
+ mover = far if far.movable else near
4057
+ if mover.movable:
4058
+ dx, dy = (ex, ey) if mover is f else (-ex, -ey)
4059
+ mover._absorb(dx, dy)
4060
+ mover._move(dx, dy)
4061
+ moved = True
4062
+ if f._orient(edge):
4063
+ moved = True
4064
+ return moved
4065
+
4066
+ def _settle(self, motor=False, strict=True, stamp=None):
4067
+ if joint._active:
4068
+ return
4069
+ joint._active = True
4070
+ try:
4071
+ order = self._order()
4072
+ if stamp is not None:
4073
+ self._stamp = stamp
4074
+ for edge, near, far in order:
4075
+ near._stamp = far._stamp = stamp
4076
+ for sweep in range(_SWEEPS):
4077
+ moved = False
4078
+ first = sweep == 0
4079
+ for edge, near, far in order:
4080
+ if edge.alive and self._fix(edge, near, far, first and motor, strict and first):
4081
+ moved = True
4082
+ if not moved:
4083
+ break
4084
+ finally:
4085
+ joint._active = False
4086
+
4087
+ def _realign(self, edge):
4088
+ if edge.alive and not edge.physical:
4089
+ edge.origin._settle(strict=False)
4090
+
4091
+ def _on_change(self, attr):
4092
+ if joint._active or physics._running or not self._edges or attr not in self._watch:
4093
+ return
4094
+ self._settle()
4095
+
4096
+ def step(self):
4097
+ if joint._active or not self._edges:
4098
+ return
4099
+ owned = [e for e in list(self._edges.values()) if e.origin is self]
4100
+ for edge in owned:
4101
+ if edge.alive and edge.physical:
4102
+ ex, ey, dist = edge.error(False)
4103
+ f = edge.follower
4104
+ if f.durability and dist > f.durability:
4105
+ self._break(edge)
4106
+ else:
4107
+ if ex or ey:
4108
+ if edge.beyond():
4109
+ self._fix(edge, self, f, False, False)
4110
+ else:
4111
+ edge.engaged = True
4112
+ edge.pull(ex, ey)
4113
+ elif edge.engaged:
4114
+ edge.engaged = False
4115
+ edge.pull(0.0, 0.0)
4116
+ f._orient(edge)
4117
+ tick = self.scene._ticks
4118
+ if self._stamp != tick and any(e.alive and not e.physical for e in owned):
4119
+ self._settle(motor=True, stamp=tick)
4120
+
4121
+
4122
+ class light(_lerpable):
4123
+ _angles = frozenset(('direction',))
4124
+ _colors = frozenset(('color',))
4125
+ _owned = ['light']
4126
+ _required = [transform]
4127
+ _watch = frozenset(('x', 'y', 'rotation', 'text', 'rtext', 'htext', 'aspect'))
4128
+ _settings = ('color', 'alpha', 'radius', 'falloff', 'shadows', 'shadowalpha', 'selfshadow',
4129
+ 'cone', 'direction', 'offset', 'mix', 'affect', 'on', 'glow', 'length', 'focus', 'translucent')
4130
+ _stamps = {}
4131
+
4132
+ def on_discard(self):
4133
+ self.scene.lights = [l for l in self.scene.lights if l is not self]
4134
+
4135
+ def __init__(self, obj, **kwargs):
4136
+ self.obj = obj
4137
+ self.t = obj.transform
4138
+ self.scene = obj.scene
4139
+ self._version = 0
4140
+ self._sig = None
4141
+ self._occ = None
4142
+ self._thin = {}
4143
+ self._boxes = []
4144
+ self._origin = (0.0, 0.0)
4145
+ self._heading = None
4146
+ self._normal = (0.0, 1.0)
4147
+ self._color = (255, 255, 255)
4148
+ self._alpha = 1.0
4149
+ self._radius = 15
4150
+ self._falloff = 1.0
4151
+ self._shadows = True
4152
+ self._shadowalpha = 1.0
4153
+ self._selfshadow = False
4154
+ self._translucent = True
4155
+ self._cone = None
4156
+ self._direction = 0
4157
+ self._offset = (0, 0)
4158
+ self._mix = 0.35
4159
+ self._affect = None
4160
+ self._on = True
4161
+ self._glow = False
4162
+ self._length = 0.0
4163
+ self._focus = 0.0
4164
+ self._glowkey = None
4165
+ self._glowrows = []
4166
+ self._cfg = kwargs.get('light', True)
4167
+ self.light = self._cfg
4168
+ def cb(attr):
4169
+ if attr in self._watch:
4170
+ self._touch()
4171
+ self._cb = cb
4172
+ self.t.onchange(cb)
4173
+ self.scene.lights.append(self)
4174
+
4175
+ def __getitem__(self, key):
4176
+ return getattr(self, key)
4177
+
4178
+ def __setitem__(self, key, value):
4179
+ setattr(self, key, value)
4180
+
4181
+ def _touch(self):
4182
+ self._version += 1
4183
+ self._occ = None
4184
+
4185
+ @property
4186
+ def light(self):
4187
+ return self._cfg
4188
+ @light.setter
4189
+ def light(self, value):
4190
+ self._cfg = value
4191
+ if isinstance(value, dict):
4192
+ for key, val in value.items():
4193
+ if key not in self._settings:
4194
+ raise TypeError('unknown light setting: ' + str(key))
4195
+ setattr(self, key, val)
4196
+
4197
+ @property
4198
+ def color(self):
4199
+ return self._color
4200
+ @color.setter
4201
+ def color(self, value):
4202
+ rgb = self.scene.styler.rgb(value)
4203
+ if rgb is None:
4204
+ raise ValueError('unknown light color: ' + str(value))
4205
+ self._color = rgb
4206
+ self._touch()
4207
+
4208
+ @property
4209
+ def alpha(self):
4210
+ return self._alpha
4211
+ @alpha.setter
4212
+ def alpha(self, value):
4213
+ self._alpha = max(0.0, value)
4214
+ self._touch()
4215
+
4216
+ @property
4217
+ def radius(self):
4218
+ return self._radius
4219
+ @radius.setter
4220
+ def radius(self, value):
4221
+ if value <= 0:
4222
+ raise ValueError('radius must be positive')
4223
+ self._radius = value
4224
+ self._touch()
4225
+
4226
+ @property
4227
+ def falloff(self):
4228
+ return self._falloff
4229
+ @falloff.setter
4230
+ def falloff(self, value):
4231
+ if value <= 0:
4232
+ raise ValueError('falloff must be positive')
4233
+ self._falloff = value
4234
+ self._touch()
4235
+
4236
+ @property
4237
+ def shadows(self):
4238
+ return self._shadows
4239
+ @shadows.setter
4240
+ def shadows(self, value):
4241
+ self._shadows = bool(value)
4242
+ self._touch()
4243
+
4244
+ @property
4245
+ def shadowalpha(self):
4246
+ return self._shadowalpha
4247
+ @shadowalpha.setter
4248
+ def shadowalpha(self, value):
4249
+ self._shadowalpha = max(0.0, min(1.0, value))
4250
+ self._touch()
4251
+
4252
+ @property
4253
+ def selfshadow(self):
4254
+ return self._selfshadow
4255
+ @selfshadow.setter
4256
+ def selfshadow(self, value):
4257
+ self._selfshadow = bool(value)
4258
+ self._touch()
4259
+
4260
+ @property
4261
+ def translucent(self):
4262
+ return self._translucent
4263
+ @translucent.setter
4264
+ def translucent(self, value):
4265
+ self._translucent = bool(value)
4266
+ self._touch()
4267
+
4268
+ @property
4269
+ def cone(self):
4270
+ return self._cone
4271
+ @cone.setter
4272
+ def cone(self, value):
4273
+ if value is not None and not 0 < value < 360:
4274
+ raise ValueError('cone must be between 0 and 360 degrees or None')
4275
+ self._cone = value
4276
+ self._touch()
4277
+
4278
+ @property
4279
+ def direction(self):
4280
+ return self._direction
4281
+ @direction.setter
4282
+ def direction(self, value):
4283
+ self._direction = value
4284
+ self._touch()
4285
+
4286
+ @property
4287
+ def offset(self):
4288
+ return self._offset
4289
+ @offset.setter
4290
+ def offset(self, value):
4291
+ x, y = value
4292
+ self._offset = (x, y)
4293
+ self._touch()
4294
+
4295
+ @property
4296
+ def mix(self):
4297
+ return self._mix
4298
+ @mix.setter
4299
+ def mix(self, value):
4300
+ self._mix = max(0.0, min(1.0, value))
4301
+ self._touch()
4302
+
4303
+ @property
4304
+ def affect(self):
4305
+ return self._affect
4306
+ @affect.setter
4307
+ def affect(self, value):
4308
+ self._affect = value
4309
+ self._touch()
4310
+
4311
+ @property
4312
+ def on(self):
4313
+ return self._on
4314
+ @on.setter
4315
+ def on(self, value):
4316
+ self._on = bool(value)
4317
+ self._touch()
4318
+
4319
+ @property
4320
+ def glow(self):
4321
+ return self._glow
4322
+ @glow.setter
4323
+ def glow(self, value):
4324
+ self._glow = bool(value)
4325
+ self._touch()
4326
+
4327
+ @property
4328
+ def length(self):
4329
+ return self._length
4330
+ @length.setter
4331
+ def length(self, value):
4332
+ if value < 0:
4333
+ raise ValueError('length must not be negative')
4334
+ self._length = float(value)
4335
+ self._touch()
4336
+
4337
+ @property
4338
+ def focus(self):
4339
+ return self._focus
4340
+ @focus.setter
4341
+ def focus(self, value):
4342
+ self._focus = max(0.0, min(1.0, value))
4343
+ self._touch()
4344
+
4345
+ def _affects(self, o):
4346
+ return _matches(self._affect, o)
4347
+
4348
+ def origin(self):
4349
+ cx, cy = self.t.extent()[:2]
4350
+ A = self.scene.aspect
4351
+ r = math.radians(self.t.rotation)
4352
+ u = self._offset[0] * A
4353
+ v = self._offset[1]
4354
+ return (cx + (u * math.cos(r) + v * math.sin(r)) / A,
4355
+ cy + (-u * math.sin(r) + v * math.cos(r)))
4356
+
4357
+ def _casters(self):
4358
+ ox, oy = self._origin
4359
+ span = self._radius + self._length / 2
4360
+ reach_x = span / self.scene.aspect + 2
4361
+ reach_y = span + 2
4362
+ found = []
4363
+ pool = list(self.scene.solids)
4364
+ listed = {id(o) for o in pool}
4365
+ pool.extend(o for o in self.scene.objs
4366
+ if id(o) not in listed and getattr(getattr(o, 'transform', None), '_shadow', None) is not None)
4367
+ for o in pool:
4368
+ if o is self.obj:
4369
+ continue
4370
+ t = getattr(o, 'transform', None)
4371
+ if t is None:
4372
+ continue
4373
+ p = getattr(o, 'physics', None)
4374
+ if t._shadow is None and (p is None or p.phantom or not p.solid):
4375
+ continue
4376
+ if t._shadow is None or (t._shadow == 'physics' and p is not None):
4377
+ b = p.bounds()
4378
+ else:
4379
+ cells = t.cells()
4380
+ b = (min(c[0] for c in cells), max(c[0] for c in cells),
4381
+ min(c[1] for c in cells), max(c[1] for c in cells)) if cells else None
4382
+ if b is None or b[1] < ox - reach_x or b[0] > ox + reach_x or b[3] < oy - reach_y or b[2] > oy + reach_y:
4383
+ continue
4384
+ found.append((o, t))
4385
+ return found
4386
+
4387
+ def _prepare(self):
4388
+ self._origin = self.origin()
4389
+ occ = {}
4390
+ thin = {}
4391
+ boxes = []
4392
+ if self._shadows:
4393
+ for o, t in self._casters():
4394
+ cells = t.cells()
4395
+ ops = t.opacities() if self._translucent else None
4396
+ if ops:
4397
+ ox, oy = t._origin()
4398
+ for pixel in cells:
4399
+ value = ops.get((pixel[0] - ox, oy - pixel[1]))
4400
+ if value is None:
4401
+ occ[pixel] = id(o)
4402
+ thin.pop(pixel, None)
4403
+ elif pixel in occ and pixel not in thin:
4404
+ continue
4405
+ else:
4406
+ old = thin.get(pixel)
4407
+ thin[pixel] = value if old is None else 1 - (1 - old) * (1 - value)
4408
+ occ[pixel] = id(o)
4409
+ else:
4410
+ for pixel in cells:
4411
+ occ[pixel] = id(o)
4412
+ if thin:
4413
+ for pixel in cells:
4414
+ thin.pop(pixel, None)
4415
+ if cells:
4416
+ xs = [c[0] for c in cells]
4417
+ ys = [c[1] for c in cells]
4418
+ boxes.append((min(xs), max(xs), min(ys), max(ys)))
4419
+ self._occ = occ
4420
+ self._thin = thin
4421
+ self._boxes = boxes
4422
+
4423
+ def step(self):
4424
+ if not self._on:
4425
+ return
4426
+ self._origin = self.origin()
4427
+ sig = ()
4428
+ if self._shadows:
4429
+ sig = tuple((id(o), o.transform._x, o.transform._y, o.transform.vx, o.transform.vy,
4430
+ o.transform._pvx, o.transform._pvy, hash(o.transform._text),
4431
+ hash(o.transform._ptext), hash(o.transform._htext), hash(o.transform.vtext),
4432
+ o.transform._shadow,
4433
+ getattr(getattr(o, 'physics', None), 'hitbox', None))
4434
+ for o, t in self._casters())
4435
+ if sig != self._sig:
4436
+ self._sig = sig
4437
+ self._touch()
4438
+
4439
+ def _shadowed(self, cx, cy, owner, origin=None):
4440
+ occ = self._occ
4441
+ if not occ:
4442
+ return 0.0
4443
+ ox, oy = origin or self._origin
4444
+ dx = cx - ox
4445
+ dy = cy - oy
4446
+ hit = False
4447
+ first = 1.0
4448
+ last = 0.0
4449
+ for bx0, bx1, by0, by1 in self._boxes:
4450
+ lo = 0.0
4451
+ hi = 1.0
4452
+ if dx:
4453
+ a = (bx0 - ox) / dx
4454
+ b = (bx1 + 1 - ox) / dx
4455
+ if a > b:
4456
+ a, b = b, a
4457
+ if a > lo:
4458
+ lo = a
4459
+ if b < hi:
4460
+ hi = b
4461
+ elif ox < bx0 or ox > bx1 + 1:
4462
+ continue
4463
+ if dy:
4464
+ a = (by0 - 1 - oy) / dy
4465
+ b = (by1 - oy) / dy
4466
+ if a > b:
4467
+ a, b = b, a
4468
+ if a > lo:
4469
+ lo = a
4470
+ if b < hi:
4471
+ hi = b
4472
+ elif oy < by0 - 1 or oy > by1:
4473
+ continue
4474
+ if lo <= hi + 1e-9:
4475
+ hit = True
4476
+ if lo < first:
4477
+ first = lo
4478
+ if hi > last:
4479
+ last = hi
4480
+ if not hit:
4481
+ return 0.0
4482
+ n = max(1, int(math.ceil(max(abs(cx - ox), abs(cy - oy)) * 2)))
4483
+ me = id(self.obj)
4484
+ thin = self._thin
4485
+ crossed = None
4486
+ for i in range(max(1, int(first * n) - 1), min(n, int(last * n) + 3)):
4487
+ f = i / n
4488
+ key = (math.floor(ox + (cx - ox) * f), math.ceil(oy + (cy - oy) * f))
4489
+ who = occ.get(key)
4490
+ if who is not None and who != me and (self._selfshadow or who != owner):
4491
+ opacity = thin.get(key)
4492
+ if opacity is None:
4493
+ return 1.0
4494
+ if crossed is None:
4495
+ crossed = {}
4496
+ if opacity > crossed.get(who, 0.0):
4497
+ crossed[who] = opacity
4498
+ if crossed is None:
4499
+ return 0.0
4500
+ clear = 1.0
4501
+ for opacity in crossed.values():
4502
+ clear *= 1 - opacity
4503
+ return 1 - clear
4504
+
4505
+ def _facing(self):
4506
+ angle = self.t.rotation + self._direction
4507
+ if angle != self._heading:
4508
+ self._heading = angle
4509
+ radians = math.radians(angle)
4510
+ self._normal = (math.sin(radians), math.cos(radians))
4511
+ return self._normal
4512
+
4513
+ def _foot(self, dx, dy):
4514
+ if not self._length:
4515
+ return 0.0, 0.0
4516
+ nx, ny = self._facing()
4517
+ half = self._length / 2
4518
+ s = max(-half, min(half, dx * ny - dy * nx))
4519
+ return s * ny, -s * nx
4520
+
4521
+ def _source(self, dx, dy, origin=None):
4522
+ if not self._length:
4523
+ return origin
4524
+ ox, oy = origin or self._origin
4525
+ qx, qy = self._foot(dx, dy)
4526
+ return (ox + qx / self.scene.aspect, oy + qy)
4527
+
4528
+ def _level(self, dx, dy):
4529
+ qx, qy = self._foot(dx, dy)
4530
+ dx -= qx
4531
+ dy -= qy
4532
+ dist = math.hypot(dx, dy)
4533
+ if dist >= self._radius:
4534
+ return 0.0
4535
+ amount = self._alpha * (1 - dist / self._radius) ** self._falloff
4536
+ if dist > 0.5:
4537
+ if self._cone is not None:
4538
+ diff = abs(_wrap(math.degrees(math.atan2(dx, dy)) - (self.t.rotation + self._direction)))
4539
+ half = self._cone / 2
4540
+ if diff >= half:
4541
+ return 0.0
4542
+ amount *= min(1.0, (half - diff) / (self._cone * 0.1))
4543
+ if self._focus:
4544
+ nx, ny = self._facing()
4545
+ amount *= 1 - self._focus + self._focus * max(0.0, (dx * nx + dy * ny) / dist)
4546
+ return max(amount, 0.0)
4547
+
4548
+ def contribution(self, cx, cy, owner=None):
4549
+ if self._occ is None:
4550
+ self._prepare()
4551
+ ox, oy = self._origin
4552
+ dx = (cx - ox) * self.scene.aspect
4553
+ dy = cy - oy
4554
+ amount = self._level(dx, dy)
4555
+ if amount <= 0:
4556
+ return 0.0
4557
+ if self._shadows:
4558
+ blocked = self._shadowed(cx, cy, owner, self._source(dx, dy))
4559
+ if blocked:
4560
+ amount *= 1 - self._shadowalpha * blocked
4561
+ return amount
4562
+
4563
+ def glowing(self):
4564
+ return self._glow and self._on
4565
+
4566
+ def _stamp(self):
4567
+ aspect = self.scene.aspect
4568
+ angular = self._cone is not None or self._focus or self._length
4569
+ key = (self._radius, self._alpha, self._falloff, aspect, self._cone, self._length, self._focus,
4570
+ round((self.t.rotation + self._direction) % 360) if angular else 0)
4571
+ stamp = light._stamps.get(key)
4572
+ if stamp is None:
4573
+ if len(light._stamps) > 64:
4574
+ light._stamps.clear()
4575
+ span = self._radius + self._length / 2
4576
+ reach_x = int(span / aspect) + 1
4577
+ reach_y = int(span) + 1
4578
+ rows = []
4579
+ for j in range(-reach_y, reach_y + 1):
4580
+ amounts = [self._level(i * aspect, j) for i in range(-reach_x, reach_x + 1)]
4581
+ lit = [k for k, a in enumerate(amounts) if a > 0]
4582
+ if lit:
4583
+ rows.append((j, lit[0] - reach_x, amounts[lit[0]:lit[-1] + 1]))
4584
+ stamp = light._stamps[key] = (key, rows, reach_x, reach_y)
4585
+ return stamp
4586
+
4587
+ def _spot(self):
4588
+ if self._occ is None:
4589
+ self._prepare()
4590
+ return int(math.floor(self._origin[0])), int(math.ceil(self._origin[1]))
4591
+
4592
+ def glowbox(self, camx, camy, xoff, yoff, view):
4593
+ gx, gy = self._spot()
4594
+ _, _, reach_x, reach_y = self._stamp()
4595
+ return (max(view[0], gx - reach_x - camx + xoff),
4596
+ max(view[1], camy - gy - reach_y - yoff),
4597
+ min(view[2], gx + reach_x - camx + xoff + 1),
4598
+ min(view[3], camy - gy + reach_y - yoff + 1))
4599
+
4600
+ def glowkey(self, camx, camy, xoff, yoff, view):
4601
+ gx, gy = self._spot()
4602
+ return (gx, gy, self._stamp()[0], self._version if self._occ else 0,
4603
+ self._color, self._mix, camx, camy, xoff, yoff, view)
4604
+
4605
+ def glowrows(self, camx, camy, xoff, yoff, view):
4606
+ key = self.glowkey(camx, camy, xoff, yoff, view)
4607
+ if key == self._glowkey:
4608
+ return self._glowrows
4609
+ gx, gy = self._spot()
4610
+ rows = self._stamp()[1]
4611
+ aspect = self.scene.aspect
4612
+ origin = (gx + 0.5, gy - 0.5)
4613
+ out = []
4614
+ for j, i0, amounts in rows:
4615
+ y = camy - gy - j - yoff
4616
+ if y < view[1] or y >= view[3]:
4617
+ continue
4618
+ x = gx + i0 - camx + xoff
4619
+ lo = max(view[0] - x, 0)
4620
+ hi = min(view[2] - x, len(amounts))
4621
+ if lo >= hi:
4622
+ continue
4623
+ if lo or hi < len(amounts):
4624
+ amounts = amounts[lo:hi]
4625
+ x += lo
4626
+ if self._occ:
4627
+ cy = gy + j - 0.5
4628
+ base = gx + i0 + lo
4629
+ softened = []
4630
+ for k, a in enumerate(amounts):
4631
+ if a:
4632
+ blocked = self._shadowed(base + k + 0.5, cy, None,
4633
+ self._source((base + k + 0.5 - origin[0]) * aspect, j, origin))
4634
+ if blocked:
4635
+ a *= 1 - self._shadowalpha * blocked
4636
+ softened.append(a)
4637
+ amounts = softened
4638
+ out.append((y, x, amounts))
4639
+ self._glowkey = key
4640
+ self._glowrows = out
4641
+ return out
4642
+
4643
+ @staticmethod
4644
+ def shade(scene, obj, text):
4645
+ lights = [l for l in scene.lights if l.on and l._affects(obj)]
4646
+ ambient = max(0.0, min(1.0, scene.ambient))
4647
+ if not lights and ambient >= 1:
4648
+ return text
4649
+ t = obj.transform
4650
+ pixels = t.parse_pixels(text)
4651
+ ox, oy = t._origin()
4652
+ fgdef = tuple(scene.fgcolor)
4653
+ bgdef = tuple(scene.bgcolor)
4654
+ owner = id(obj)
4655
+ shaded = {}
4656
+ changed = False
4657
+ for (ci, ri), (char, style) in pixels.items():
4658
+ hasbg = bool(style) and t._has_background(style)
4659
+ if char == ' ' and not hasbg:
4660
+ shaded[(ci, ri)] = (char, style)
4661
+ continue
4662
+ total = 0.0
4663
+ mixsum = 0.0
4664
+ red = green = blue = 0.0
4665
+ for l in lights:
4666
+ c = l.contribution(ox + ci + 0.5, oy - ri - 0.5, owner)
4667
+ if c > 0:
4668
+ total += c
4669
+ mixsum += c * l._mix
4670
+ red += c * l._color[0]
4671
+ green += c * l._color[1]
4672
+ blue += c * l._color[2]
4673
+ level = min(1.0, ambient + total)
4674
+ if level >= 1.0 and total <= 0:
4675
+ shaded[(ci, ri)] = (char, style)
4676
+ continue
4677
+ if total > 0:
4678
+ tint = (red / total, green / total, blue / total)
4679
+ amount = (mixsum / total) * min(1.0, total)
4680
+ else:
4681
+ tint = None
4682
+ amount = 0.0
4683
+
4684
+ def blend(base):
4685
+ out = [bgdef[k] + (base[k] - bgdef[k]) * level for k in range(3)]
4686
+ if tint is not None:
4687
+ out = [out[k] + (tint[k] - out[k]) * amount for k in range(3)]
4688
+ return tuple(max(0, min(255, int(round(v)))) for v in out)
4689
+
4690
+ fa, back, ba = _ALPHAS(style) if style else (None, False, None)
4691
+ fgm = _FGCODE.search(style)
4692
+ fg = tuple(int(g) for g in fgm.groups()) if fgm else fgdef
4693
+ newstyle = _BGCODE.sub('', _FGCODE.sub('', style))
4694
+ bgm = _BGCODE.search(style)
4695
+ bgold = bgnew = bgdef
4696
+ if bgm:
4697
+ bgold = tuple(int(g) for g in bgm.groups())
4698
+ if ba is not None:
4699
+ bgnew = _rebake(bgdef, blend(_unbake(bgold, bgdef, ba)), ba)
4700
+ else:
4701
+ bgnew = blend(bgold)
4702
+ newstyle += ansi.bg(*bgnew)
4703
+ if char != ' ':
4704
+ if fa is not None:
4705
+ refold, refnew = (bgdef, bgdef) if back or not bgm else (bgold, bgnew)
4706
+ fgnew = _rebake(refnew, blend(_unbake(fg, refold, fa)), fa)
4707
+ else:
4708
+ fgnew = blend(fg)
4709
+ newstyle += ansi.fg(*fgnew)
4710
+ elif fgm:
4711
+ newstyle += fgm.group(0)
4712
+ shaded[(ci, ri)] = (char, newstyle)
4713
+ changed = True
4714
+ if not changed:
4715
+ return text
4716
+ xs = [p[0] for p in shaded]
4717
+ ys = [p[1] for p in shaded]
4718
+ return t.build_rows(shaded, min(xs), max(xs), min(ys), max(ys))