exp-runtime-shs 0.5.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.
- exp_runtime/shs/__init__.py +1 -0
- exp_runtime/shs/audio.py +33 -0
- exp_runtime/shs/builtin_episode.py +50 -0
- exp_runtime/shs/content.py +25 -0
- exp_runtime/shs/desktop_football.py +319 -0
- exp_runtime/shs/desktop_grid.py +341 -0
- exp_runtime/shs/football.py +517 -0
- exp_runtime/shs/football_layout.py +164 -0
- exp_runtime/shs/grid_layout.py +78 -0
- exp_runtime/shs/grid_motion.py +41 -0
- exp_runtime/shs/ios_assets.py +100 -0
- exp_runtime/shs/profile.py +37 -0
- exp_runtime/shs/relationships.py +14 -0
- exp_runtime/shs/services.py +114 -0
- exp_runtime/shs/survey.py +45 -0
- exp_runtime/shs/word_grid.py +592 -0
- exp_runtime_shs-0.5.0.dist-info/METADATA +47 -0
- exp_runtime_shs-0.5.0.dist-info/RECORD +21 -0
- exp_runtime_shs-0.5.0.dist-info/WHEEL +5 -0
- exp_runtime_shs-0.5.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_shs-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Surviving High School native game contracts."""
|
exp_runtime/shs/audio.py
ADDED
|
@@ -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',))]
|