diff --git a/desktop-client/README.md b/desktop-client/README.md index afb74d9..d83f10e 100644 --- a/desktop-client/README.md +++ b/desktop-client/README.md @@ -24,12 +24,13 @@ does not yet have ready-check controls. ## Fight-night commands -- `/ko` — K.O.: That's a wrap. +- `/ko` — a large arcade-style K.O. with gold lettering, red outline, and an impact animation. - `/bell` — DING!: Back to your corners. - `/tap` — TAP!: Someone's had enough. - `/robbery` — a reaction plus a 30-second room vote on the decision. -Reactions slide into the bottom of the video for 2.2 seconds and keep playback +The K.O. appears across the center of the video; other reactions slide into the +bottom. Effects clear after 2.2 seconds and keep playback running. A shared 3-second cooldown limits room-wide effects. Reaction events stay in chat history; joining or reconnecting does not replay animations. diff --git a/desktop-client/fight_reactions.py b/desktop-client/fight_reactions.py index f7120ba..897c882 100644 --- a/desktop-client/fight_reactions.py +++ b/desktop-client/fight_reactions.py @@ -4,7 +4,8 @@ from pathlib import Path import struct import wave -from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QPoint, QEasingCurve, QEvent +from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QVariantAnimation, QPoint, QEasingCurve, QEvent +from PyQt6.QtGui import QPainter, QPainterPath, QFont, QColor, QPen, QLinearGradient, QTransform from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout @@ -50,6 +51,58 @@ def write_reaction_sound(directory, kind): return path +class ArcadeKnockout(QWidget): + """Vector-painted arcade lettering scales cleanly with the video surface.""" + def __init__(self, parent): + super().__init__(parent) + self.progress = 0.0 + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def set_progress(self, value): + self.progress = value + self.update() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + t = self.progress + opacity = min(1.0, t / .035, max(0.0, (1.0 - t) / .18)) + painter.setOpacity(opacity) + painter.translate(self.width() / 2, self.height() / 2) + entry = min(1.0, t / .18) + scale = 1.0 + .65 * (1 - entry) ** 3 - .06 * math.sin(entry * math.pi) + painter.scale(scale, scale) + font = QFont("Impact") + font.setPixelSize(180) + font.setBold(True) + letters = QPainterPath() + letters.addText(0, 0, font, "K.O.") + letters = QTransform().shear(-.12, 0).map(letters) + bounds = letters.boundingRect() + fit = min(self.width() * .63 / bounds.width(), self.height() * .40 / bounds.height()) + painter.scale(fit, fit) + painter.translate(-bounds.center()) + shadow = QTransform().translate(7, 10).map(letters) + painter.setPen(QPen(QColor("#131017"), 18, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.setBrush(QColor("#131017")) + painter.drawPath(shadow) + painter.setPen(QPen(QColor("#8b1028"), 14, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.setBrush(QColor("#f33c35")) + painter.drawPath(letters) + gradient = QLinearGradient(0, bounds.top(), 0, bounds.bottom()) + gradient.setColorAt(0, QColor("#fff7b0")) + gradient.setColorAt(.40, QColor("#ffe252")) + gradient.setColorAt(1, QColor("#ff9c22")) + painter.setPen(QPen(QColor("#ff583e"), 5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)) + painter.setBrush(gradient) + painter.drawPath(letters) + if t < .32: + painter.setOpacity(opacity * (1 - t / .32) * .55) + painter.setClipPath(letters) + sweep = bounds.left() + bounds.width() * min(1.0, t / .32) + painter.fillRect(int(sweep), int(bounds.top() - 10), 16, int(bounds.height() + 20), QColor("#fffef0")) + + class RingsideStrip(QWidget): """A separate native window stays above VLC's native video surface.""" def __init__(self, video_frame): @@ -65,6 +118,15 @@ class RingsideStrip(QWidget): outer.setContentsMargins(0, 0, 0, 0) self.panel = QFrame() outer.addWidget(self.panel) + self.ko_canvas = ArcadeKnockout(self) + outer.addWidget(self.ko_canvas) + self.ko_canvas.hide() + self.kind = None + self.ko_animation = QVariantAnimation(self) + self.ko_animation.setDuration(2200) + self.ko_animation.setStartValue(0.0) + self.ko_animation.setEndValue(1.0) + self.ko_animation.valueChanged.connect(self.ko_canvas.set_progress) row = QHBoxLayout(self.panel) row.setContentsMargins(14, 10, 14, 10) row.setSpacing(14) @@ -112,8 +174,18 @@ class RingsideStrip(QWidget): self.caption.setText(f"{author}: {caption}") self.metadata.setText(f"Room reaction · {position}") self.panel.setStyleSheet(f"QFrame {{ background: #1a1b20; border-left: 3px solid {color}; border-radius: 6px; }} QLabel {{ border: none; }}") + self.kind = kind + self.panel.setVisible(kind != "ko") + self.ko_canvas.setVisible(kind == "ko") + self.ko_animation.stop() self.slide.stop() self._place() + if kind == "ko": + self.ko_canvas.set_progress(0.0) + self.show() + self.ko_animation.start() + self.dismiss_timer.start(2200) + return True end = self.pos() self.move(end + QPoint(0, 12)) self.show() @@ -124,6 +196,12 @@ class RingsideStrip(QWidget): return True def _place(self): + if self.kind == "ko": + self.setFixedSize(self.video_frame.size()) + self.move(self.video_frame.mapToGlobal(QPoint(0, 0))) + return + self.setMinimumSize(0, 0) + self.setMaximumSize(16777215, 16777215) width = max(1, min(540, self.video_frame.width() - 32)) self.setFixedWidth(width) height = self.layout().heightForWidth(width) @@ -141,6 +219,7 @@ class RingsideStrip(QWidget): return super().eventFilter(watched, event) def dismiss(self): + self.ko_animation.stop() self.slide.stop() self.dismiss_timer.stop() self.hide() diff --git a/desktop-client/test_fight_reactions.py b/desktop-client/test_fight_reactions.py index 87ad53d..bab914e 100644 --- a/desktop-client/test_fight_reactions.py +++ b/desktop-client/test_fight_reactions.py @@ -75,6 +75,24 @@ def test_reactions_work_with_chat_popout_focused(room, qtbot): popout.close() +def test_arcade_ko_covers_video_then_restores_strip(room, qtbot): + room.activateWindow() + qtbot.wait(30) + room.handle_fight_reaction({'kind': 'ko', 'position': 0}) + overlay = room.ringside_strip + assert overlay.size() == room.video_frame.size() + assert overlay.panel.isHidden() + assert not overlay.ko_canvas.isHidden() + assert overlay.ko_animation.state() == overlay.ko_animation.State.Running + room.handle_fight_reaction({'kind': 'bell', 'position': 0}) + assert overlay.ko_canvas.isHidden() + assert not overlay.panel.isHidden() + assert overlay.width() <= 540 + assert overlay.height() < room.video_frame.height() + room.cleanup() + assert overlay.ko_animation.state() == overlay.ko_animation.State.Stopped + + def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, qtbot, monkeypatch): play = Mock() monkeypatch.setattr(room, '_play_reaction_sound', play)