exp-runtime-shs 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Josh Mcdaniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ # Only authored component source, metadata and license.
2
+ global-exclude *
3
+ include LICENSE README.md pyproject.toml MANIFEST.in
4
+ recursive-include src/exp_runtime *.py
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: exp-runtime-shs
3
+ Version: 0.5.0
4
+ Summary: Surviving High School adapter for EXP Runtime
5
+ License: MIT License
6
+
7
+ Copyright (c) 2025 Josh Mcdaniel
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+
27
+ Requires-Python: >=3.10
28
+ Description-Content-Type: text/markdown
29
+ License-File: LICENSE
30
+ Requires-Dist: exp-runtime-engine[fonts]==0.5.0
31
+ Dynamic: license-file
32
+
33
+ # EXP Runtime — shs
34
+
35
+ Surviving High School adapter for EXP Runtime. This is a component of the single
36
+ [EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
37
+ and released together with its other wheels. Python imports use the shared
38
+ `exp_runtime` namespace.
39
+
40
+ Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
41
+ `exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
42
+ the desktop player with both games. Individual component wheels also support
43
+ selective installs. See the project's `docs/SDK.md` for APIs and examples.
44
+
45
+ No original game content is included. Users supply their own game files.
46
+ The inherited MIT license and attribution are included in LICENSE; see
47
+ [provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
@@ -0,0 +1,15 @@
1
+ # EXP Runtime — shs
2
+
3
+ Surviving High School adapter for EXP Runtime. This is a component of the single
4
+ [EXP Runtime project](https://github.com/joshrmcdaniel/exp-runtime), maintained
5
+ and released together with its other wheels. Python imports use the shared
6
+ `exp_runtime` namespace.
7
+
8
+ Install `exp-runtime[tools]` for file tools, `exp-runtime[engine,shs]` or
9
+ `exp-runtime[engine,cod]` for a headless game, or `exp-runtime[player]` for
10
+ the desktop player with both games. Individual component wheels also support
11
+ selective installs. See the project's `docs/SDK.md` for APIs and examples.
12
+
13
+ No original game content is included. Users supply their own game files.
14
+ The inherited MIT license and attribution are included in LICENSE; see
15
+ [provenance](https://github.com/joshrmcdaniel/exp-runtime/blob/main/docs/PROVENANCE.md).
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "exp-runtime-shs"
7
+ version = "0.5.0"
8
+ description = "Surviving High School adapter for EXP Runtime"
9
+ readme = "README.md"
10
+ license = {file = "LICENSE"}
11
+ requires-python = ">=3.10"
12
+ dependencies = ["exp-runtime-engine[fonts]==0.5.0"]
13
+
14
+ [tool.setuptools]
15
+ include-package-data = false
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["src"]
19
+ include = ["exp_runtime", "exp_runtime.*"]
20
+ namespaces = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """Surviving High School native game contracts."""
@@ -0,0 +1,33 @@
1
+ """Android 1.0.9 music cues from SHS09SoundEngine.playMusic in classes.dex.
2
+
3
+ The inspected iOS SHSSoundManager constructor uses the same cue table
4
+ (00074d74..000754ac); see docs/IPA.md for source availability limits.
5
+ Some script IDs name positions within another MP3, not separate assets.
6
+ Resolve them only for music playback; resource lookup and VM IDs stay exact.
7
+ """
8
+ from ..audio import MusicCue
9
+
10
+
11
+ # SHSEngine::resetResList (iOS 0003a3c8): these original music IDs were
12
+ # downloaded outside the IPA. Their Android 1.0.9 counterparts share the
13
+ # same IDs/cues. No other cross-platform resource equivalence is implied.
14
+ IOS_DOWNLOADED_MUSIC = (8201, 8205, 8207, 8209, 8212, 8217, 8219, 8221, 8223, 8224)
15
+
16
+
17
+ _MUSIC_REDIRECTS = {
18
+ 8202: MusicCue(8201, 2800),
19
+ 8204: MusicCue(8203, 720),
20
+ 8206: MusicCue(8205, 28280),
21
+ 8208: MusicCue(8207, 3270),
22
+ 8211: MusicCue(8210, 6000),
23
+ 8213: MusicCue(8212, 4000),
24
+ 8214: MusicCue(8212, 39200),
25
+ 8216: MusicCue(8215, 1320),
26
+ 8218: MusicCue(8217, 1920),
27
+ 8220: MusicCue(8219, 10600),
28
+ 8225: MusicCue(8224, 29200),
29
+ }
30
+
31
+
32
+ def music_cue(resource_id):
33
+ return _MUSIC_REDIRECTS.get(resource_id, MusicCue(resource_id))
@@ -0,0 +1,50 @@
1
+ """Extract the base game's built-in story into an ordinary local CSPUD episode.
2
+
3
+ Only resource IDs and container structure live in the engine. Story text,
4
+ bytecode and localized titles are copied from the player's validated APK/IPA.
5
+ """
6
+ import struct
7
+
8
+ from ..content import ContentError, ExpArchive, _zip_read
9
+
10
+
11
+ FOOTBALL_SCRIPTS = (*range(25001, 25022), 25023)
12
+ FOOTBALL_SOURCE = 'football-star'
13
+ FOOTBALL_NAME = 'Football_Star.exp'
14
+ FOOTBALL_ALIASES = ['Football Season', 'Football_Season.exp', 'footballseason', 'footballstar']
15
+
16
+
17
+ def extract_football(apk, asset_root='assets/Assets/', *, ios=False):
18
+ """Return deterministic EXP bytes, or None when no base story is present."""
19
+ if asset_root + '25001' not in apk.namelist():
20
+ return None
21
+ # Imported lazily: menu strings share the asset parser, not a pygame UI.
22
+ from ..menu import MenuStrings
23
+ titles = MenuStrings.parse(_zip_read(apk, asset_root + ('12' if ios else '13')))
24
+ # Native built-in episode key is zero; FUN_0009794c packs (pack, episode).
25
+ metadata = bytearray(struct.pack('>HH', 0, 0))
26
+ for index in range(193, 198):
27
+ title = titles[index].encode('utf-8')
28
+ if not title or len(title) > 65535:
29
+ raise ContentError('Invalid built-in episode title')
30
+ metadata += struct.pack('>H', len(title)) + title
31
+ records = {1: bytes(metadata)}
32
+ for resource_id in range(25001, 25022) if ios else FOOTBALL_SCRIPTS:
33
+ data = _zip_read(apk, f'{asset_root}{resource_id}')
34
+ if not data.startswith(b'kiwi'):
35
+ raise ContentError(f'Built-in story resource {resource_id} is not KiWi')
36
+ records[resource_id] = data
37
+ # Literal records preserve every byte, address and script ID. Art/audio
38
+ # keep their native base namespace, just as they do for external episodes.
39
+ index, bodies = bytearray(), bytearray()
40
+ start = 9 + 6 * len(records)
41
+ for resource_id, data in sorted(records.items()):
42
+ index += struct.pack('>HI', resource_id, start + len(bodies))
43
+ bodies += struct.pack('>III', len(data), len(data), 0) + data
44
+ result = b'CSPUD' + struct.pack('>I', len(records)) + index + bodies
45
+ ExpArchive(result).programs() # Validate every copied script before publishing.
46
+ return bytes(result)
47
+
48
+
49
+ def football_source():
50
+ return dict(builtin=FOOTBALL_SOURCE, aliases=FOOTBALL_ALIASES[:])
@@ -0,0 +1,25 @@
1
+ """Validation of the supported Surviving High School Android package."""
2
+ from zipfile import ZipFile
3
+
4
+ NATIVE_MEMBER = 'lib/armeabi/libshs09.so'
5
+ NATIVE_SHA256 = 'b17aa4c71bc46666d414cafae6fac92bbcd755f3dcccd73975cf05f4a119665b'
6
+ BUILTIN = 'football-star'
7
+
8
+
9
+ def extract_builtin(package, asset_root, *, ios):
10
+ from .builtin_episode import FOOTBALL_NAME, extract_football, football_source
11
+ data = extract_football(package, asset_root, ios=ios)
12
+ return None if data is None else (data, FOOTBALL_NAME, football_source())
13
+
14
+
15
+ def _inspect_apk(apk: ZipFile):
16
+ from ..content import ContentError, digest, _zip_read
17
+ names = apk.namelist()
18
+ if len(names) != len(set(names)):
19
+ raise ContentError('APK contains ambiguous duplicate member names')
20
+ actual = digest(_zip_read(apk, NATIVE_MEMBER))
21
+ if actual != NATIVE_SHA256:
22
+ raise ContentError(f'Unsupported game APK native library ({actual}); '
23
+ 'the currently supported profile is SHS Android 1.0.9')
24
+ return actual
25
+
@@ -0,0 +1,319 @@
1
+ """Native service-94 art, fonts, panels, play targets and feedback."""
2
+ from dataclasses import replace
3
+ from functools import lru_cache
4
+ from io import BytesIO
5
+ import math
6
+
7
+ from .. import graphics as pygame
8
+ from ..desktop_pause import draw_pause_gear
9
+
10
+ from ..atlas import AtlasFont, SpriteAtlas
11
+ from .football import PLAY_FRAMES, TARGET_CENTERS
12
+ from .football_layout import (FONT, HELP_STYLE, LABEL_STYLE, TITLE_STYLE, clamp,
13
+ countdown_motion, feedback_motion, feedback_text,
14
+ footer_id, heading_id, help_motion, legend_codes,
15
+ target_position, team_name, yard_glyphs, score_strip)
16
+ from ..menu import MenuStrings
17
+ from ..ui_assets import ImagePack, Raster, read_ui
18
+
19
+
20
+ class FootballRenderer:
21
+ def __init__(self, resources, text, art):
22
+ self.resources, self.text, self.art = resources, text, art
23
+ self.canvas = pygame.Surface((320, 480)).convert(32)
24
+
25
+ @lru_cache(maxsize=1)
26
+ def atlas(self):
27
+ return SpriteAtlas.parse(read_ui(self.resources, 290))
28
+
29
+ @lru_cache(maxsize=154)
30
+ def frame(self, index):
31
+ r = self.atlas().raster(index)
32
+ return pygame.image.frombytes(r.pixels, (r.width, r.height), 'RGBA').convert_alpha()
33
+
34
+ @lru_cache(maxsize=10)
35
+ def image(self, asset):
36
+ data = read_ui(self.resources, asset)
37
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
38
+ return pygame.image.load(BytesIO(data)).convert_alpha()
39
+ raster = ImagePack.parse(data).images[0]
40
+ return pygame.image.frombytes(raster.pixels, (raster.width, raster.height), raster.mode).convert_alpha()
41
+
42
+ @lru_cache(maxsize=1)
43
+ def strings(self):
44
+ return MenuStrings.load(self.resources)
45
+
46
+ @lru_cache(maxsize=1)
47
+ def feedback_font(self):
48
+ # Unlike grid glyph sheets, 540 is a PNG; 541 uses the CS metrics.
49
+ image = self.image(540)
50
+ return AtlasFont.parse(read_ui(self.resources, 541),
51
+ Raster(*image.get_size(), pygame.image.tobytes(image, 'RGBA')))
52
+
53
+ @lru_cache(maxsize=128)
54
+ def feedback_image(self, text):
55
+ font, atlas = self.feedback_font(), self.image(540)
56
+ lines = font.wrap(text, 300)
57
+ width = max((font.width(line) for line in lines), default=0)
58
+ image = pygame.Surface((max(1, width), max(1, font.text_height(lines))), pygame.SRCALPHA)
59
+ for row, line in enumerate(lines):
60
+ x = (width - font.width(line)) // 2
61
+ for char in line:
62
+ glyph = font.glyph(char)
63
+ if glyph is not None and char != ' ':
64
+ gx, gy, gw = glyph
65
+ image.blit(atlas, (x, row * (font.height + font.line_gap)), (gx, gy, gw, font.height))
66
+ x += font.char_width(char) + font.tracking
67
+ return image
68
+
69
+ @lru_cache(maxsize=1)
70
+ def score_font(self):
71
+ # loadImageFont(523,522) at SHS iOS 00088094. Both supplied asset
72
+ # profiles contain the same CS glyph sheet in semantic roles 524/525.
73
+ raster = SpriteAtlas.parse(read_ui(self.resources, 524)).raster(0)
74
+ return (AtlasFont.parse(read_ui(self.resources, 525), raster),
75
+ pygame.image.frombytes(raster.pixels, (raster.width, raster.height), 'RGBA').convert_alpha())
76
+
77
+ @lru_cache(maxsize=64)
78
+ def score_text(self, text):
79
+ font, atlas = self.score_font()
80
+ image = pygame.Surface((max(1, font.width(text)), font.height), pygame.SRCALPHA)
81
+ x = 0
82
+ for char in text:
83
+ glyph = font.glyph(char)
84
+ if glyph is not None and char != ' ':
85
+ gx, gy, gw = glyph
86
+ image.blit(atlas, (x, 0), (gx, gy, gw, font.height))
87
+ x += font.char_width(char) + font.tracking
88
+ return image
89
+
90
+ def scoreboard(self, game):
91
+ font, _ = self.score_font()
92
+ home, away = (team_name(self.strings(), selector) for selector in game.teams)
93
+ frame, offset = score_strip(font.width(home), font.width(away))
94
+ literals = self.atlas().literals(frame)
95
+ center = 253 + min(x for _, x, _ in literals) + offset
96
+ self.composite(frame, 253, 50)
97
+ for text, x, width in ((home, center - 28 - font.width(home), 0),
98
+ (str(game.home), center - 24, 17), ('-', center - 3, 6),
99
+ (str(game.away), center + 7, 17), (away, center + 28, 0)):
100
+ image = self.score_text(text)
101
+ self.canvas.blit(image, (x + int((width - font.width(text)) / 2) if width else x, 42))
102
+
103
+ def blit(self, image, x, y, *, scale=1., scale_y=None, alpha=1., angle=0., center=False):
104
+ scale_y = scale if scale_y is None else scale_y
105
+ if scale <= 0 or scale_y <= 0 or alpha <= 0:
106
+ return
107
+ if scale != 1 or scale_y != 1:
108
+ image = pygame.transform.smoothscale(image, (max(1, round(image.get_width() * scale)),
109
+ max(1, round(image.get_height() * scale_y))))
110
+ if angle:
111
+ image = pygame.transform.rotate(image, -angle)
112
+ if alpha < 1:
113
+ image = image.copy()
114
+ image.set_alpha(round(255 * clamp(alpha)))
115
+ self.canvas.blit(image, image.get_rect(center=(round(x), round(y))) if center else (round(x), round(y)))
116
+
117
+ def node(self, index, x, y, **transform):
118
+ """0009936c positions the returned sprite at x+w/2, y+h/2.
119
+
120
+ Play and glyph composites each contain one literal. Their serialized
121
+ negative origin is replaced by this node placement; applying it again
122
+ would shift the art out of its fixed touch box.
123
+ """
124
+ literals = self.atlas().literals(index)
125
+ if len(literals) != 1:
126
+ raise ValueError('Football node requires a single-literal sprite')
127
+ image = self.frame(literals[0][0])
128
+ self.blit(image, x + image.get_width() / 2, y + image.get_height() / 2,
129
+ center=True, **transform)
130
+
131
+ def composite(self, index, x, y):
132
+ # Field chalk uses the direct composite path, retaining child offsets.
133
+ for ref, dx, dy in self.atlas().literals(index):
134
+ self.blit(self.frame(ref), x + dx, y + dy)
135
+
136
+ def label(self, text, x, y, *, width=320, style=LABEL_STYLE, center=False):
137
+ """Native line-box origin, with per-line horizontal centering."""
138
+ layout = self.text.layout(FONT, text, width, style)
139
+ if center:
140
+ offsets = {line.start: -line.width / 2 for line in layout.lines}
141
+ glyphs = tuple(replace(g, x=g.x + offsets[line.start]) for line in layout.lines
142
+ for g in layout.glyphs if line.start <= g.index < line.end)
143
+ layout = replace(layout, glyphs=glyphs)
144
+ self.text.draw_layout(self.canvas, FONT, layout, x, y)
145
+
146
+ @staticmethod
147
+ def hit(position):
148
+ # 000bc2ec: inclusive fixed bounds, independent of target zoom.
149
+ px, py = position
150
+ return next((i for i, (x, y) in enumerate(TARGET_CENTERS)
151
+ if x - 35 <= px <= x + 35 and y - 53 <= py <= y + 17), None)
152
+
153
+ def field(self, game):
154
+ # 000b8410 / 000b82f4: field camera, turf, authored chalk and haze.
155
+ position = game.camera_position
156
+ y = -position * 45
157
+ x = (y * -.40613848) % 320
158
+ y = -((-y) % 480)
159
+ for dx, dy in ((0, 0), (-320, 0), (0, 480), (-320, 480)):
160
+ self.blit(self.frame(86), x + dx, y + dy)
161
+ frames = (-83, -65, -69, -73, -77, -79, -76, -72, -68, -64, -84)
162
+ start = int(position) - 6
163
+ for yard in range(start, start + 14):
164
+ if 0 <= yard <= 100 and yard % 5 == 0:
165
+ y = 240 + (yard - position) * 45
166
+ self.composite(frames[yard // 10] if yard % 10 == 0 else -80,
167
+ int(y * -.4354067), int(y - 56))
168
+ x, y = -(int(game.visual_ms * .02) % 320), int(game.visual_ms * .05) % 480
169
+ for dx, dy in ((0, 0), (0, -480), (320, 0), (320, -480)):
170
+ self.blit(self.frame(75), x + dx, y + dy, scale=4, alpha=.14705883)
171
+
172
+ def hud_digits(self, text, x, y, width=0, *, clock=False):
173
+ """000b6818 / 000b68b4 / 000b9e90: measured and drawn spacing differ."""
174
+ indices = [128 + int(c) if c.isdigit() else {'-': 138, '+': 139, ':': 150}[c] for c in text]
175
+ measure = sum(self.frame(i).get_width() for i in indices) + len(indices) - 1
176
+ x += int((width - measure) / 2)
177
+ for char, index in zip(text, indices):
178
+ image = self.frame(index)
179
+ w, h = image.get_size()
180
+ if clock:
181
+ height = 4 if char == ':' else h
182
+ native_y = int(480 - y - height + h * 1.5)
183
+ native_y -= native_y % 2
184
+ self.blit(image, x - 1 - w / 2, 480 - (native_y - 5) - h)
185
+ x += w + {'1': 2, ':': 4}.get(char, 0)
186
+ else:
187
+ height = {'-': 6, '+': 7, ':': 8}.get(char, h)
188
+ self.blit(image, x + 2 - w / 2, y + height - 2 * h)
189
+ x += w + 3
190
+
191
+ def hud(self, game):
192
+ strings = self.strings()
193
+ self.node(30, 10, 13)
194
+ self.node(153 if game.defense else 152, 19, 4)
195
+ # BM labels draw downward from local zero; center alignment adds
196
+ # half the measured line height (14), unlike ordinary sprite anchors.
197
+ self.label(strings[heading_id(game)], 109, 7, center=True)
198
+ self.blit(self.image(701 + min(game.down, 3)), 99, 33, center=True)
199
+ distance = int(100 - game.hud_position if game.defense else game.hud_position)
200
+ self.hud_digits(str(max(0, min(100, distance))), 186, 37, 20)
201
+ seconds = max(0, math.ceil(game.remaining_ms / 1000))
202
+ self.hud_digits(f'{seconds // 60}:{seconds % 60:02}', 165, 37, clock=True)
203
+ self.scoreboard(game)
204
+
205
+ def targets(self, game):
206
+ for target, (x, y) in zip(game.targets, TARGET_CENTERS):
207
+ if not target.visible:
208
+ continue
209
+ scale = target.scale
210
+ if target.phase == 2:
211
+ for growth, alpha in ((scale, 1 - .8 * scale), (scale + .2, 1 - 1.25 * scale)):
212
+ # Native changes the ripple anchor to top-left after
213
+ # setting a center position; its sheet is half-height.
214
+ self.blit(self.frame(127), x - 2 - growth * 33, y - 7 - growth * 16.5,
215
+ scale=growth, scale_y=growth * .5, alpha=alpha)
216
+ if target.phase in (3, 6):
217
+ alpha = math.sin(game.visual_ms / 1000 * 4) * .3 + .7
218
+ if getattr(self.resources.library, 'kind', 'apk') == 'ipa':
219
+ # ItemSlot::drawSelectionRing (0008efa8): the iOS asset is
220
+ # frame 125, rotating at 360 degrees/s then halved in Y.
221
+ shadow = pygame.transform.rotate(self.frame(125), -(game.visual_ms * .36 % 360))
222
+ self.blit(shadow, x, y, scale_y=.5, alpha=alpha, center=True)
223
+ else:
224
+ # 000c0ac0: 110ms per exported Android shadow frame.
225
+ shadow = self.image(720 + (target.elapsed_ms // 110) % 4)
226
+ self.blit(shadow, x, 600 - y, scale=1.3, scale_y=1.2, alpha=alpha / 1.5, center=True)
227
+ elif target.phase == 7 and target.elapsed_ms >= 100:
228
+ progress = (target.elapsed_ms - 100) / 500
229
+ for growth in (progress, min(1., 1.3 - progress), min(1., 1.5 - progress)):
230
+ self.blit(self.frame(126), x - 2 - growth * 33, y - 7 - growth * 16.5,
231
+ scale=growth, scale_y=growth * .5, alpha=1 - progress)
232
+ self.node(PLAY_FRAMES[target.code], *target_position(target, (x, y)), scale=scale, alpha=scale)
233
+ if abs(target.code) == 4:
234
+ self.node(-1, x, y - 38, scale=scale, alpha=scale)
235
+ elif abs(target.code) != 5:
236
+ for frame, gx, gy in yard_glyphs(target.yards, x, y - 38):
237
+ self.node(frame, gx, gy, scale=scale, alpha=scale)
238
+
239
+ def effects(self, game):
240
+ """000b2494 / 000b4bd0: selected play squashes and spins away."""
241
+ for effect in game.effects:
242
+ remaining = effect.remaining_ms
243
+ if effect.selected:
244
+ if remaining >= 1000:
245
+ r = (remaining - 1000) / 500
246
+ sx, sy, alpha, angle = 1 + (1 - r) * 3, .2 + r * .8, 1., 1.
247
+ else:
248
+ r = remaining / 1000
249
+ sx, sy, alpha, angle = r * 4, .2 - r * .2, r, r * 180
250
+ _, dx, dy = self.atlas().literals(effect.frame)[0]
251
+ self.node(effect.frame, effect.x + dx, effect.y + dy,
252
+ scale=sx, scale_y=sy, alpha=alpha, angle=angle)
253
+ else:
254
+ r = remaining / 1300
255
+ scale = r * effect.scale
256
+ self.node(effect.frame, effect.x, effect.y - 18 * scale,
257
+ scale=scale, alpha=r)
258
+
259
+ def help(self, game):
260
+ strings = self.strings()
261
+ coach = self.frame(game.field_variant)
262
+ m = help_motion(game, coach.get_width())
263
+ self.node(64, m.x, m.y, scale=m.scale, alpha=m.alpha)
264
+ if m.alpha > 0:
265
+ self.node(153 if game.defense else 152, m.x + 6, m.y - 9,
266
+ scale=m.scale, alpha=m.alpha)
267
+ # BM labels follow the moving panel, but do not inherit its scale.
268
+ self.label(strings[77 if game.defense else 76], m.x + 97, m.y - 6,
269
+ width=166, style=TITLE_STYLE, center=True)
270
+ self.label(game.instruction, m.x + 25, m.y + 26, width=256, style=HELP_STYLE)
271
+ self.label(strings[166], m.x + 18, m.y + 183)
272
+ best = self.text.layout(FONT, strings[165], 320, LABEL_STYLE)
273
+ self.label(strings[165], m.x + 289 - best.width, m.y + 183)
274
+ codes = legend_codes(game)
275
+ spacing = 217 // (len(codes) - 1) if len(codes) > 1 else 0
276
+ for i, code in enumerate(codes):
277
+ frame = self.frame(self.atlas().literals(PLAY_FRAMES[code])[0][0])
278
+ x = m.x + 45 + i * spacing - 35 + frame.get_width() / 2
279
+ # 000be110 sets anchor (.5,0) after node placement.
280
+ bottom = m.y + 141 + frame.get_height() / 2
281
+ s = m.scale / 1.1
282
+ self.blit(frame, x - frame.get_width() * s / 2, bottom - frame.get_height() * s,
283
+ scale=s, alpha=m.alpha)
284
+ self.blit(coach, m.coach_x, 457 - coach.get_height(), alpha=m.coach_alpha)
285
+
286
+ def feedback(self, game):
287
+ motion = feedback_motion(game)
288
+ if motion is None:
289
+ return
290
+ self.node(66, motion.x, 240)
291
+ if motion.shine_x is not None:
292
+ self.node(65, motion.shine_x, 240)
293
+ if motion.visible:
294
+ image = self.feedback_image(feedback_text(game, self.strings(), motion.index))
295
+ height = self.feedback_font().height
296
+ y = 259 - (height >> 2) if image.get_height() <= height else 259
297
+ self.blit(image, 160, y, scale=motion.scale, angle=motion.angle, center=True)
298
+
299
+ def draw(self, target, session):
300
+ game = session.engine.football
301
+ self.canvas.fill((0, 0, 0))
302
+ self.field(game)
303
+ self.targets(game)
304
+ self.effects(game)
305
+ self.hud(game)
306
+ self.feedback(game)
307
+ if game.phase in (20, 21, 22, 23, 25):
308
+ self.help(game)
309
+ elif game.phase == 0:
310
+ motion = countdown_motion(game.phase_ms)
311
+ self.blit(self.frame(motion.frame), 159.5, 240, scale=motion.scale,
312
+ alpha=motion.alpha, angle=motion.angle, center=True)
313
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
314
+ draw_pause_gear(self.canvas, self.art)
315
+ footer = footer_id(game)
316
+ if footer is not None:
317
+ self.label(self.strings()[footer], 180, 462, center=True)
318
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
319
+ return [(pygame.Rect(0, 422, 60, 58), ('menu',))]