mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-29 01:23:58 +08:00
Arabesque: Rebecca, Adrift
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -4,6 +4,7 @@ import pyray as rl
|
||||
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools.files import Files
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools.rebecca import Rebecca
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools.terminal import Terminal
|
||||
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos, TextAlignment
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
@@ -34,7 +35,7 @@ class ArabesqueLayout(Widget):
|
||||
self._menu_button = rl.Rectangle(rect.x + 24 * s, rect.y + 8 * s, 88 * s, 88 * s)
|
||||
self._exit_button = rl.Rectangle(rect.x + 24 * s, rect.y + 116 * s, 456 * s, 116 * s)
|
||||
self._back_button = rl.Rectangle(rect.x + rect.width - 296 * s, rect.y + 8 * s, 272 * s, 88 * s)
|
||||
self._dock = dock = rl.Rectangle(rect.x + (rect.width - 600 * s) / 2, rect.y + rect.height - 272 * s, 600 * s, 240 * s)
|
||||
self._dock = dock = rl.Rectangle(rect.x + (rect.width - 840 * s) / 2, rect.y + rect.height - 272 * s, 840 * s, 240 * s)
|
||||
self._dock_shelf = rl.Rectangle(dock.x, dock.y + 178 * s, dock.width, 10 * s)
|
||||
self._dock_top = (rl.Vector2(dock.x + 36 * s, dock.y + 118 * s), rl.Vector2(dock.x, self._dock_shelf.y),
|
||||
rl.Vector2(dock.x + dock.width, self._dock_shelf.y), rl.Vector2(dock.x + dock.width - 36 * s, dock.y + 118 * s))
|
||||
@@ -43,6 +44,7 @@ class ArabesqueLayout(Widget):
|
||||
self._launchers = (
|
||||
(rl.Rectangle(dock.x + 60 * s, dock.y, 240 * s, 200 * s), "Terminal", lambda: Terminal()),
|
||||
(rl.Rectangle(dock.x + 300 * s, dock.y, 240 * s, 200 * s), "Files", lambda: Files(self._permitted)),
|
||||
(rl.Rectangle(dock.x + 540 * s, dock.y, 240 * s, 200 * s), "Rebecca", lambda: Rebecca()),
|
||||
)
|
||||
self._tool_rect = rl.Rectangle(rect.x, self._bar.y + self._bar.height, rect.width, rect.height - self._bar.height)
|
||||
|
||||
@@ -150,10 +152,16 @@ class ArabesqueLayout(Widget):
|
||||
rl.draw_line_ex(rl.Vector2(icon.x - 32 * s, icon.y - 24 * s), rl.Vector2(icon.x - 8 * s, icon.y), 6 * s, tint)
|
||||
rl.draw_line_ex(rl.Vector2(icon.x - 8 * s, icon.y), rl.Vector2(icon.x - 32 * s, icon.y + 24 * s), 6 * s, tint)
|
||||
rl.draw_line_ex(rl.Vector2(icon.x + 8 * s, icon.y + 24 * s), rl.Vector2(icon.x + 36 * s, icon.y + 24 * s), 6 * s, tint)
|
||||
else:
|
||||
elif name == "Files":
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 32 * s, icon.y - 26 * s, 30 * s, 18 * s), 0.3, 8, tint)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 36 * s, icon.y - 16 * s, 72 * s, 44 * s), 0.2, 8, tint)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(icon.x - 29 * s, icon.y - 9 * s, 58 * s, 30 * s), 0.2, 8, theme.background)
|
||||
else:
|
||||
rl.draw_circle_v(rl.Vector2(icon.x - 19 * s, icon.y - 6 * s), 20 * s, tint)
|
||||
rl.draw_circle_v(rl.Vector2(icon.x + 19 * s, icon.y - 6 * s), 20 * s, tint)
|
||||
for offset in (-19, 19):
|
||||
rl.draw_circle_v(rl.Vector2(icon.x + offset * s, icon.y - 8 * s), 11 * s, theme.background)
|
||||
rl.draw_line_ex(rl.Vector2(icon.x - 8 * s, icon.y + 25 * s), rl.Vector2(icon.x + 8 * s, icon.y + 25 * s), 3 * s, tint)
|
||||
gui_label(rl.Rectangle(icon.x - 96 * s, icon.y + 68 * s, 192 * s, 36 * s),
|
||||
tr(name), round(28 * s), theme.text, theme.font_weight, alignment=TextAlignment.CENTER)
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""One frog, one button, bounded scenery; Arabesque owns the window and input."""
|
||||
|
||||
from importlib.resources import as_file
|
||||
from pathlib import Path
|
||||
import random
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME as THEME
|
||||
from openpilot.system.ui.lib.application import ASSETS_DIR, gui_app, TextAlignment
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.widgets import Widget, device
|
||||
from openpilot.system.ui.widgets.label import gui_label
|
||||
|
||||
_STEP = 1 / 60
|
||||
_SCORE_FILE = Path(__file__).with_name('rebecca-score.txt')
|
||||
_INK = rl.Color(118, 98, 139, 255)
|
||||
_STONE = rl.Color(169, 139, 153, 255)
|
||||
_CREAM = rl.Color(239, 221, 183, 255)
|
||||
_CORAL = rl.Color(222, 145, 136, 255)
|
||||
_FIELD = rl.Color(118, 98, 139, 70)
|
||||
|
||||
|
||||
class Rebecca(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._sprite = None
|
||||
self._sounds, self._owns_audio = {}, False
|
||||
self._best, self._notice = 0, ''
|
||||
self._pressed_button = False
|
||||
self._rng = random.Random()
|
||||
self._reset()
|
||||
self._state = 'ready'
|
||||
|
||||
def _reset(self):
|
||||
self._state = 'flying'
|
||||
self._y, self._vy, self._vx = 208.0, -160.0, 150.0
|
||||
self._distance = self._elapsed = self._accumulator = self._cooldown = 0.0
|
||||
self._objects = []
|
||||
self._next, self._encounter, self._corridor = 0.0, 0, 208.0
|
||||
self._record = False
|
||||
|
||||
def show_event(self):
|
||||
super().show_event()
|
||||
self._sprite = gui_app.texture('arabesque/rebecca.png', 256, 256)
|
||||
try:
|
||||
self._best = max(0, int(_SCORE_FILE.read_text(encoding='ascii')))
|
||||
except (FileNotFoundError, ValueError):
|
||||
self._best = 0
|
||||
except OSError:
|
||||
self._notice = tr('Best score unavailable')
|
||||
try:
|
||||
self._owns_audio = not rl.is_audio_device_ready()
|
||||
if self._owns_audio:
|
||||
rl.init_audio_device()
|
||||
if rl.is_audio_device_ready():
|
||||
for name in ('flap', 'loss', 'best'):
|
||||
with as_file(ASSETS_DIR.joinpath('arabesque', name + '.wav')) as path:
|
||||
sound = rl.load_sound(str(path))
|
||||
if sound.frameCount:
|
||||
self._sounds[name] = sound
|
||||
rl.set_sound_volume(sound, 0.35)
|
||||
except (OSError, RuntimeError) as error:
|
||||
cloudlog.warning(f'Rebecca audio unavailable: {error}')
|
||||
|
||||
def hide_event(self):
|
||||
self._save_best()
|
||||
for sound in self._sounds.values():
|
||||
rl.stop_sound(sound)
|
||||
rl.unload_sound(sound)
|
||||
self._sounds.clear()
|
||||
if self._owns_audio and rl.is_audio_device_ready():
|
||||
rl.close_audio_device()
|
||||
self._owns_audio = False
|
||||
self._state, self._accumulator = 'ready', 0.0
|
||||
super().hide_event()
|
||||
|
||||
def _play(self, name):
|
||||
if name in self._sounds:
|
||||
rl.play_sound(self._sounds[name])
|
||||
|
||||
def _save_best(self):
|
||||
score = int(self._distance / 10)
|
||||
if score > self._best:
|
||||
try:
|
||||
# shortcut: no power-loss durability; add fsync if records must survive abrupt power cuts.
|
||||
temporary = _SCORE_FILE.with_suffix('.tmp')
|
||||
temporary.write_text(f'{score}\n', encoding='ascii')
|
||||
temporary.replace(_SCORE_FILE)
|
||||
self._best, self._record = score, True
|
||||
except OSError:
|
||||
self._notice = tr('Best score could not be saved')
|
||||
|
||||
def _update_layout_rects(self):
|
||||
rect = self._rect
|
||||
self._scale = s = min(rect.width / 1080, rect.height / 488)
|
||||
self._ox = rect.x + (rect.width - 1080 * s) / 2
|
||||
self._oy = rect.y + (rect.height - 488 * s) / 2
|
||||
self._button = rl.Rectangle(rect.x, rect.y + rect.height - 72 * s, rect.width, 72 * s)
|
||||
|
||||
def _handle_mouse_press(self, pos):
|
||||
self._pressed_button = rl.check_collision_point_rec(pos, self._button)
|
||||
if not self._pressed_button or self._cooldown:
|
||||
return
|
||||
if self._state != 'flying':
|
||||
self._reset()
|
||||
self._vy = -160.0
|
||||
self._play('flap')
|
||||
|
||||
def _spawn(self):
|
||||
self._corridor = max(156, min(260, self._corridor + self._rng.uniform(-60, 60)))
|
||||
x, y = self._distance + 1140, self._corridor
|
||||
kind = self._encounter % 3
|
||||
if kind == 0 and len(self._objects) <= 4:
|
||||
self._objects.extend((0, x, y + offset, self._rng.uniform(44, 54)) for offset in (-120, 120))
|
||||
elif len(self._objects) < 6:
|
||||
kind = 2 if kind == 2 and not any(o[0] == 2 for o in self._objects) else 1
|
||||
self._objects.append((kind, x, self._rng.choice((76, 340)), 26))
|
||||
self._encounter += 1
|
||||
self._next = self._distance + 440
|
||||
|
||||
def _update_state(self):
|
||||
if not self.enabled or not device.awake:
|
||||
self._accumulator = 0.0
|
||||
return
|
||||
dt = max(0.0, min(rl.get_frame_time(), 4 * _STEP))
|
||||
self._cooldown = max(0.0, self._cooldown - dt)
|
||||
if self._state != 'flying':
|
||||
return
|
||||
self._accumulator += dt
|
||||
while self._accumulator >= _STEP and self._state == 'flying':
|
||||
self._accumulator -= _STEP
|
||||
self._step()
|
||||
|
||||
def _step(self):
|
||||
ax, ay = (min(200, 150 + self._distance / 1600) - self._vx) * 3, 330.0
|
||||
for kind, x, y, _radius in self._objects:
|
||||
dx, dy = x - self._distance - 230, y - self._y
|
||||
d2 = dx * dx + dy * dy
|
||||
if kind == 2 and d2 < 185 ** 2:
|
||||
pull = min(4.5, 90000 / (d2 + 6400))
|
||||
ax, ay = ax + dx * pull, ay + dy * pull
|
||||
self._vx = max(90, min(240, self._vx + ax * _STEP))
|
||||
self._vy = max(-240, min(260, self._vy + ay * _STEP))
|
||||
self._distance += self._vx * _STEP
|
||||
self._y += self._vy * _STEP
|
||||
self._elapsed += _STEP
|
||||
self._objects = [o for o in self._objects if o[1] - self._distance > -200]
|
||||
if self._distance >= self._next:
|
||||
self._spawn()
|
||||
if not 30 < self._y < 386 or any(
|
||||
(x - self._distance - 230) ** 2 + (y - self._y) ** 2 < (radius + 30) ** 2 for _, x, y, radius in self._objects
|
||||
):
|
||||
self._state, self._cooldown = 'lost', 0.4
|
||||
self._save_best()
|
||||
self._play('loss')
|
||||
if self._record:
|
||||
self._play('best')
|
||||
|
||||
def _label(self, x, y, width, height, text, size=18, color=_CREAM):
|
||||
s = self._scale
|
||||
gui_label(rl.Rectangle(self._ox + x * s, self._oy + y * s, width * s, height * s),
|
||||
text, round(size * s), color)
|
||||
|
||||
def _render(self, rect):
|
||||
s, ox, oy = self._scale, self._ox, self._oy
|
||||
rl.draw_rectangle_rec(rect, THEME.background)
|
||||
for i in range(32):
|
||||
x = (i * 137.51 - self._distance * (0.12 + i % 3 * 0.08)) % 1080
|
||||
y = 24 + i * 83 % 360
|
||||
rl.draw_circle_v(rl.Vector2(ox + x * s, oy + y * s), (0.7 + i % 3 * 0.35) * s, _INK if i % 4 else _CREAM)
|
||||
streak = self._elapsed % 11
|
||||
if 0 < streak < 0.7:
|
||||
x, y = ox + (1000 - streak * 400) * s, oy + (45 + streak * 110) * s
|
||||
rl.draw_line_ex(rl.Vector2(x, y), rl.Vector2(x + 36 * s, y - 10 * s), s, _CREAM)
|
||||
for kind, x, y, radius in self._objects:
|
||||
center = rl.Vector2(ox + (x - self._distance) * s, oy + y * s)
|
||||
r = radius * s
|
||||
if kind == 0:
|
||||
rl.draw_poly(center, 7, r, x % 360, _STONE)
|
||||
rl.draw_circle_v(rl.Vector2(center.x - r * 0.25, center.y - r * 0.22), r * 0.23, _INK)
|
||||
rl.draw_circle_v(rl.Vector2(center.x + r * 0.32, center.y + r * 0.3), r * 0.12, _INK)
|
||||
elif kind == 1:
|
||||
rl.draw_line_ex(rl.Vector2(center.x - r, center.y), rl.Vector2(center.x + r, center.y), 2 * s, _CREAM)
|
||||
for offset in (-0.75, 0.75):
|
||||
rl.draw_rectangle_rec(rl.Rectangle(center.x + offset * r - 8 * s, center.y - 13 * s, 16 * s, 26 * s), _INK)
|
||||
rl.draw_poly(center, 4, 12 * s, 0, _CREAM)
|
||||
else:
|
||||
rl.draw_ring(center, 184 * s, 185 * s, 0, 360, 48, _FIELD)
|
||||
rl.draw_ring(center, 32 * s, 34 * s, 0, 360, 32, _CORAL)
|
||||
rl.draw_ring(center, 26 * s, 27 * s, 0, 360, 32, _CREAM)
|
||||
rl.draw_circle_v(center, r, THEME.background)
|
||||
if self._sprite is not None:
|
||||
height = 96 * self._sprite.height / self._sprite.width
|
||||
rl.draw_texture_pro(self._sprite, rl.Rectangle(0, 0, self._sprite.width, self._sprite.height),
|
||||
rl.Rectangle(ox + 182 * s, oy + (self._y - height / 2) * s, 96 * s, height * s),
|
||||
rl.Vector2(0, 0), 0, rl.WHITE)
|
||||
self._label(24, 12, 400, 36, f'{int(self._distance / 10)} m', 24)
|
||||
self._label(810, 12, 246, 36, tr('Best') + f' {max(self._best, int(self._distance / 10))} m', color=_INK)
|
||||
if self._state != 'flying':
|
||||
self._label(440, 142, 600, 64, tr('Rebecca, Adrift') if self._state == 'ready' else tr('Still unbothered.'), 36)
|
||||
caption = tr('Tap to Float. Dodge everything.') if self._state == 'ready' else tr('New best!') if self._record else tr('Space is rude. Try again.')
|
||||
self._label(444, 208, 580, 48, caption, color=THEME.secondary_text)
|
||||
if self._notice:
|
||||
self._label(24, 368, 1032, 40, self._notice, 16, _CORAL)
|
||||
rl.draw_rectangle_rec(self._button, THEME.pressed if self._pressed_button and self.is_pressed and self.enabled else THEME.control)
|
||||
label = {'ready': tr('Start floating'), 'flying': tr('Float'), 'lost': tr('Again')}[self._state]
|
||||
gui_label(self._button, label, round(24 * s), THEME.background, alignment=TextAlignment.CENTER)
|
||||
@@ -110,8 +110,8 @@ def test_layout_anchors_and_touch_targets(shell, bounds):
|
||||
assert dock.y + dock.height < rect.y + rect.height
|
||||
assert dock.width >= 44 and dock.height >= 44
|
||||
frame(shell)
|
||||
captions = [call.args[0] for call in desktop.gui_label.call_args_list if call.args[1] in ('Terminal', 'Files')]
|
||||
assert len(captions) == 2
|
||||
captions = [call.args[0] for call in desktop.gui_label.call_args_list if call.args[1] in ('Terminal', 'Files', 'Rebecca')]
|
||||
assert len(captions) == 3
|
||||
for (target, _, _), caption in zip(shell._launchers, captions, strict=True):
|
||||
icon_x, icon_y = target.x + target.width / 2, dock.y + 72 * shell._scale
|
||||
assert caption.x + caption.width / 2 == pytest.approx(icon_x)
|
||||
@@ -279,7 +279,7 @@ def test_files_has_its_own_launcher_and_blank_shelf_does_not_launch(shell, probe
|
||||
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
|
||||
create = Mock(return_value=probe)
|
||||
monkeypatch.setattr(desktop, 'Files', create)
|
||||
terminal_target, files_target = (item[0] for item in shell._launchers)
|
||||
terminal_target, files_target = (item[0] for item in shell._launchers[:2])
|
||||
# A gesture crossing launcher boundaries must not start either tool.
|
||||
frame(shell, event(terminal_target.x + 20, terminal_target.y + 20, pressed=True))
|
||||
frame(shell, event(files_target.x + 20, files_target.y + 20, released=True))
|
||||
@@ -296,6 +296,39 @@ def test_files_has_its_own_launcher_and_blank_shelf_does_not_launch(shell, probe
|
||||
assert shell._active_tool is None
|
||||
|
||||
|
||||
def test_rebecca_launcher_uses_shared_lifecycle(shell, probe, monkeypatch):
|
||||
create = Mock(return_value=probe)
|
||||
monkeypatch.setattr(desktop, 'Rebecca', create)
|
||||
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
|
||||
tap(shell, shell._launchers[2][0])
|
||||
assert shell._active_tool is probe and not gui_app.mouse_events
|
||||
create.assert_called_once_with()
|
||||
probe.show_event.assert_called_once()
|
||||
frame(shell)
|
||||
assert desktop.gui_label.call_args_list[-2].args[1] == 'Rebecca'
|
||||
tap(shell, shell._back_button)
|
||||
probe.hide_event.assert_called_once()
|
||||
|
||||
|
||||
def test_rebecca_menu_pauses_and_dismissal_does_not_flap(shell, monkeypatch):
|
||||
game = desktop.Rebecca()
|
||||
monkeypatch.setattr(game, 'show_event', Mock())
|
||||
monkeypatch.setattr(game, '_render', Mock())
|
||||
monkeypatch.setattr(rl, 'get_frame_time', lambda: 1 / 60)
|
||||
shell.set_rect(rl.Rectangle(0, 0, 2160, 1080))
|
||||
shell._open_tool('Rebecca', game)
|
||||
game._reset()
|
||||
frame(shell)
|
||||
tap(shell, shell._menu_button)
|
||||
frozen = game._distance, game._y, game._vy
|
||||
frame(shell)
|
||||
tap(shell, game._button)
|
||||
assert not shell._menu_open
|
||||
assert (game._distance, game._y, game._vy) == frozen
|
||||
frame(shell)
|
||||
assert game._distance > frozen[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main(shell, monkeypatch):
|
||||
ui = NS(started=False, is_body=False, add_on_body_changed_callbacks=Mock())
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Gameplay, local score files and lifecycle checks without a window or audio device."""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import Mock
|
||||
import wave
|
||||
|
||||
import pyray as rl
|
||||
import pytest
|
||||
|
||||
from openpilot.selfdrive.ui.layouts.arabesque.tools import rebecca
|
||||
from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(monkeypatch, tmp_path):
|
||||
import openpilot.system.ui.widgets as widgets
|
||||
monkeypatch.setattr(widgets, 'device', NS(awake=True))
|
||||
monkeypatch.setattr(rebecca, 'device', NS(awake=True))
|
||||
monkeypatch.setattr(gui_app, '_mouse_events', [])
|
||||
monkeypatch.setattr(gui_app, '_show_touches', False)
|
||||
monkeypatch.setattr(gui_app, 'texture', Mock(return_value=NS(width=256, height=235)))
|
||||
monkeypatch.setattr(rebecca, 'gui_label', Mock())
|
||||
score_file = tmp_path / 'rebecca-score.txt'
|
||||
score_file.write_text('12\n', encoding='ascii')
|
||||
monkeypatch.setattr(rebecca, '_SCORE_FILE', score_file)
|
||||
for name in ('init_audio_device', 'close_audio_device', 'load_sound', 'set_sound_volume', 'play_sound', 'stop_sound',
|
||||
'unload_sound', 'draw_rectangle_rec', 'draw_circle_v', 'draw_poly', 'draw_ring', 'draw_line_ex', 'draw_texture_pro'):
|
||||
monkeypatch.setattr(rl, name, Mock())
|
||||
monkeypatch.setattr(rl, 'is_audio_device_ready', lambda: True)
|
||||
monkeypatch.setattr(rl, 'get_frame_time', lambda: 1 / 60)
|
||||
rl.load_sound.side_effect = lambda path: NS(frameCount=100, name=Path(path).stem)
|
||||
view = rebecca.Rebecca()
|
||||
view.set_rect(rl.Rectangle(0, 104, 2160, 976))
|
||||
view.show_event()
|
||||
yield view
|
||||
view.hide_event()
|
||||
|
||||
|
||||
def press(game, pos, slot=0):
|
||||
gui_app._mouse_events = [MouseEvent(pos, slot, True, False, True, 1.0)]
|
||||
game.render()
|
||||
|
||||
|
||||
def test_only_primary_button_press_starts_and_flaps(game):
|
||||
assert game._best == 12
|
||||
press(game, MousePos(500, 400))
|
||||
assert game._state == 'ready'
|
||||
pos = MousePos(1000, 1000)
|
||||
press(game, pos, slot=1)
|
||||
assert game._state == 'ready'
|
||||
press(game, pos)
|
||||
assert game._state == 'flying' and game._vy == -160
|
||||
game._vy = 100
|
||||
press(game, pos)
|
||||
assert game._vy == -160
|
||||
assert rebecca._SCORE_FILE.read_text() == '12\n'
|
||||
|
||||
|
||||
def test_fixed_steps_match_across_frame_rates_and_cap_stalls(game, monkeypatch):
|
||||
states = []
|
||||
for fps in (20, 60):
|
||||
game._reset()
|
||||
monkeypatch.setattr(rl, 'get_frame_time', lambda fps=fps: 1 / fps)
|
||||
for _ in range(fps // 2):
|
||||
game._update_state()
|
||||
states.append((game._y, game._vy, game._distance))
|
||||
assert states[0] == pytest.approx(states[1])
|
||||
monkeypatch.setattr(rl, 'get_frame_time', lambda: 30)
|
||||
game._reset()
|
||||
game._update_state()
|
||||
assert game._elapsed == pytest.approx(4 / 60)
|
||||
|
||||
|
||||
def test_disabled_or_sleeping_simulation_does_not_catch_up(game, monkeypatch):
|
||||
game._reset()
|
||||
game._accumulator = 0.01
|
||||
game.set_enabled(False)
|
||||
game._update_state()
|
||||
assert game._distance == 0 and game._accumulator == 0
|
||||
game.set_enabled(True)
|
||||
monkeypatch.setattr(rebecca.device, 'awake', False)
|
||||
game._update_state()
|
||||
assert game._distance == 0
|
||||
monkeypatch.setattr(rebecca.device, 'awake', True)
|
||||
game._update_state()
|
||||
assert game._distance == pytest.approx(2.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('dx,dy', [(0, -100), (0, 100), (-100, 60), (100, 60)])
|
||||
def test_black_holes_pull_in_both_axes(game, dx, dy):
|
||||
game._reset()
|
||||
game._vy = 0
|
||||
game._next = float('inf')
|
||||
game._objects = [(2, 230 + dx, 208 + dy, 26)]
|
||||
game._step()
|
||||
assert (game._vy - 330 / 60) * dy > 0
|
||||
if dx:
|
||||
assert (game._vx - 150) * dx > 0
|
||||
|
||||
|
||||
def test_collision_freezes_scene_and_debounces_restart(game):
|
||||
game._reset()
|
||||
game._distance = 200
|
||||
game._objects = [(0, 430, 208, 50)]
|
||||
game._step()
|
||||
assert game._state == 'lost'
|
||||
frozen = game._distance, game._y
|
||||
game._update_state()
|
||||
press(game, MousePos(1000, 1000))
|
||||
assert game._state == 'lost' and (game._distance, game._y) == frozen
|
||||
for _ in range(25):
|
||||
game._update_state()
|
||||
press(game, MousePos(1000, 1000))
|
||||
assert game._state == 'flying' and game._distance == 0
|
||||
|
||||
|
||||
def test_scenery_is_bounded_and_old_objects_expire(game):
|
||||
for _ in range(100):
|
||||
game._spawn()
|
||||
assert len(game._objects) <= 6
|
||||
assert sum(o[0] == 2 for o in game._objects) <= 1
|
||||
assert 156 <= game._corridor <= 260
|
||||
game._objects = [(0, -300, 60, 50)]
|
||||
game._next = float('inf')
|
||||
game._step()
|
||||
assert not game._objects
|
||||
|
||||
|
||||
def test_only_new_records_are_saved_including_exit_mid_run(game):
|
||||
game._distance = 150
|
||||
game.hide_event()
|
||||
assert rebecca._SCORE_FILE.read_text() == '15\n'
|
||||
modified = rebecca._SCORE_FILE.stat().st_mtime_ns
|
||||
game._distance = 100
|
||||
game._save_best()
|
||||
assert rebecca._SCORE_FILE.stat().st_mtime_ns == modified
|
||||
assert game._best == 15
|
||||
assert rl.unload_sound.call_count == 3
|
||||
rl.close_audio_device.assert_not_called() # A preexisting device belongs to its original owner.
|
||||
|
||||
|
||||
def test_storage_failure_preserves_previous_record_and_keeps_game_playable(game, monkeypatch):
|
||||
monkeypatch.setattr(Path, 'replace', Mock(side_effect=OSError('unavailable')))
|
||||
game._distance = 200
|
||||
game._save_best()
|
||||
assert game._notice and game._best == 12
|
||||
assert rebecca._SCORE_FILE.read_text() == '12\n'
|
||||
press(game, MousePos(1000, 1000))
|
||||
assert game._state == 'flying'
|
||||
|
||||
|
||||
def test_record_survives_reopening(game):
|
||||
game._distance = 240
|
||||
game.hide_event()
|
||||
reopened = rebecca.Rebecca()
|
||||
reopened.show_event()
|
||||
assert reopened._best == 24
|
||||
reopened.hide_event()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('contents', [None, '', 'not a score', '-50', '\N{SNOWMAN}', '9' * 5000])
|
||||
def test_missing_or_invalid_score_defaults_to_zero(game, contents):
|
||||
if contents is None:
|
||||
rebecca._SCORE_FILE.unlink()
|
||||
else:
|
||||
rebecca._SCORE_FILE.write_text(contents, encoding='utf-8')
|
||||
game.show_event()
|
||||
assert game._best == 0
|
||||
press(game, MousePos(1000, 1000))
|
||||
assert game._state == 'flying'
|
||||
|
||||
|
||||
def test_unreadable_score_keeps_game_playable(game, monkeypatch):
|
||||
monkeypatch.setattr(Path, 'read_text', Mock(side_effect=OSError('unavailable')))
|
||||
game.show_event()
|
||||
assert game._notice
|
||||
press(game, MousePos(1000, 1000))
|
||||
assert game._state == 'flying'
|
||||
|
||||
|
||||
def test_audio_unavailable_is_silent_and_owned_audio_is_released(game, monkeypatch):
|
||||
game.hide_event()
|
||||
monkeypatch.setattr(rl, 'is_audio_device_ready', lambda: False)
|
||||
game.show_event()
|
||||
assert not game._sounds
|
||||
game._play('flap')
|
||||
game.hide_event()
|
||||
monkeypatch.setattr(rl, 'is_audio_device_ready', Mock(side_effect=[False, True, True]))
|
||||
game.show_event()
|
||||
game.hide_event()
|
||||
rl.close_audio_device.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bounds', [(0, 104, 2160, 976), (45, 100, 1280, 620), (0, 104, 2560, 976)])
|
||||
def test_full_width_button_and_world_fit(game, bounds):
|
||||
game.set_rect(rl.Rectangle(*bounds))
|
||||
rect, button, s = game.rect, game._button, game._scale
|
||||
assert button.x == rect.x and button.width == rect.width
|
||||
assert button.y + button.height == pytest.approx(rect.y + rect.height)
|
||||
assert game._ox >= rect.x and game._ox + 1080 * s <= rect.x + rect.width + .01
|
||||
assert game._oy >= rect.y and game._oy + 416 * s <= button.y + .01
|
||||
assert button.height >= 44
|
||||
|
||||
|
||||
def test_audio_assets_are_tiny_mono_pcm():
|
||||
folder = Path(__file__).resolve().parents[2] / 'assets' / 'arabesque'
|
||||
assert sum(path.stat().st_size for path in folder.glob('*.wav')) < 11 * 1024
|
||||
for name in ('flap', 'loss', 'best'):
|
||||
with wave.open(str(folder / (name + '.wav'))) as sound:
|
||||
assert (sound.getnchannels(), sound.getsampwidth(), sound.getframerate()) == (1, 2, 16000)
|
||||
assert sound.getnframes() <= 3200
|
||||
Reference in New Issue
Block a user