Give KO reactions an animated arcade overlay
This commit is contained in:
@@ -24,12 +24,13 @@ does not yet have ready-check controls.
|
|||||||
|
|
||||||
## Fight-night commands
|
## 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.
|
- `/bell` — DING!: Back to your corners.
|
||||||
- `/tap` — TAP!: Someone's had enough.
|
- `/tap` — TAP!: Someone's had enough.
|
||||||
- `/robbery` — a reaction plus a 30-second room vote on the decision.
|
- `/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
|
running. A shared 3-second cooldown limits room-wide effects. Reaction events
|
||||||
stay in chat history; joining or reconnecting does not replay animations.
|
stay in chat history; joining or reconnecting does not replay animations.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from pathlib import Path
|
|||||||
import struct
|
import struct
|
||||||
import wave
|
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
|
from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +51,58 @@ def write_reaction_sound(directory, kind):
|
|||||||
return path
|
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):
|
class RingsideStrip(QWidget):
|
||||||
"""A separate native window stays above VLC's native video surface."""
|
"""A separate native window stays above VLC's native video surface."""
|
||||||
def __init__(self, video_frame):
|
def __init__(self, video_frame):
|
||||||
@@ -65,6 +118,15 @@ class RingsideStrip(QWidget):
|
|||||||
outer.setContentsMargins(0, 0, 0, 0)
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
self.panel = QFrame()
|
self.panel = QFrame()
|
||||||
outer.addWidget(self.panel)
|
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 = QHBoxLayout(self.panel)
|
||||||
row.setContentsMargins(14, 10, 14, 10)
|
row.setContentsMargins(14, 10, 14, 10)
|
||||||
row.setSpacing(14)
|
row.setSpacing(14)
|
||||||
@@ -112,8 +174,18 @@ class RingsideStrip(QWidget):
|
|||||||
self.caption.setText(f"{author}: {caption}")
|
self.caption.setText(f"{author}: {caption}")
|
||||||
self.metadata.setText(f"Room reaction · {position}")
|
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.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.slide.stop()
|
||||||
self._place()
|
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()
|
end = self.pos()
|
||||||
self.move(end + QPoint(0, 12))
|
self.move(end + QPoint(0, 12))
|
||||||
self.show()
|
self.show()
|
||||||
@@ -124,6 +196,12 @@ class RingsideStrip(QWidget):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _place(self):
|
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))
|
width = max(1, min(540, self.video_frame.width() - 32))
|
||||||
self.setFixedWidth(width)
|
self.setFixedWidth(width)
|
||||||
height = self.layout().heightForWidth(width)
|
height = self.layout().heightForWidth(width)
|
||||||
@@ -141,6 +219,7 @@ class RingsideStrip(QWidget):
|
|||||||
return super().eventFilter(watched, event)
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
def dismiss(self):
|
def dismiss(self):
|
||||||
|
self.ko_animation.stop()
|
||||||
self.slide.stop()
|
self.slide.stop()
|
||||||
self.dismiss_timer.stop()
|
self.dismiss_timer.stop()
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|||||||
@@ -75,6 +75,24 @@ def test_reactions_work_with_chat_popout_focused(room, qtbot):
|
|||||||
popout.close()
|
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):
|
def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, qtbot, monkeypatch):
|
||||||
play = Mock()
|
play = Mock()
|
||||||
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
monkeypatch.setattr(room, '_play_reaction_sound', play)
|
||||||
|
|||||||
Reference in New Issue
Block a user