Give KO reactions an animated arcade overlay

This commit is contained in:
Peter Stockings
2026-10-05 15:52:30 +11:00
parent f3eeda71e6
commit b5f09a9e9e
3 changed files with 101 additions and 3 deletions
+3 -2
View File
@@ -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.
+80 -1
View File
@@ -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()
+18
View File
@@ -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)