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
|
||||
|
||||
- `/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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user