diff options
| -rw-r--r-- | __init__.py | 6 | ||||
| -rw-r--r-- | assets/first-blood.png | bin | 0 -> 141954 bytes | |||
| -rw-r--r-- | assets/presenter.css | 221 | ||||
| -rw-r--r-- | assets/presenter.js | 70 | ||||
| -rw-r--r-- | presenter.py | 67 | ||||
| -rw-r--r-- | templates/presenter.html | 67 |
6 files changed, 431 insertions, 0 deletions
diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5e649cb --- /dev/null +++ b/__init__.py @@ -0,0 +1,6 @@ +from CTFd.plugins import register_plugin_assets_directory +from .presenter import presenter_blueprint + +def load(app): + register_plugin_assets_directory(app, base_path='/plugins/ctfd-presenter/assets/') + app.register_blueprint(presenter_blueprint) diff --git a/assets/first-blood.png b/assets/first-blood.png Binary files differnew file mode 100644 index 0000000..d36262d --- /dev/null +++ b/assets/first-blood.png diff --git a/assets/presenter.css b/assets/presenter.css new file mode 100644 index 0000000..ee5bdec --- /dev/null +++ b/assets/presenter.css @@ -0,0 +1,221 @@ +@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap'); + +html, body { + margin: 0; + height: 100%; + background: #000; + overflow: hidden; +} + +#scoreboard-frame { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + border: none; +} + +#solve-overlay { + position: fixed; + inset: 0; + background: #050505; + opacity: 0; + transition: opacity 0.3s ease; + z-index: 10; + pointer-events: none; + display: flex; + justify-content: center; + align-items: center; +} + +#solve-overlay.visible { + opacity: 1; +} + +#solve-text { + text-align: center; + font-family: "Source Code Pro"; + letter-spacing: 0.05em; + color: white; +} + +#solve-first { + display: none; +} + +#solve-first.visible { + display: flex; + height: 35vh; + margin: -5vh 0 -2vh 0; + align-items: center; + justify-content: center; + flex-direction: column; +} + +#solve-first img { + z-index: 99999; + position: relative; + height: 100% +} + +#solve-team { + white-space: nowrap; + font-size: 15vh; + font-weight: 900; + letter-spacing: 4px; +} + +#solve-value { + margin-top: 2vh; + font-weight: 700; + font-size: 10vh; +} + +#solve-challenge { + margin-top: 2vh; + font-weight: 400; + font-size: 10vh; + letter-spacing: -0.1em; +} + + + +.glitch { + position: relative; +} + +.glitch::before, +.glitch::after { + content: attr(data-text); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #050505; +} + +.glitch::before { + left: 3px; + text-shadow: -2px 0 #ff00c1; + clip-path: inset(44% 0 61% 0); + animation: glitch-anim 2s infinite linear alternate-reverse; +} + +.glitch::after { + left: -3px; + text-shadow: -2px 0 #00fff9; + clip-path: inset(54% 0 10% 0); + animation: glitch-anim 2.5s infinite linear alternate-reverse; +} + +@keyframes glitch-anim { + 0% { clip-path: inset(80% 0 10% 0); } + 20% { clip-path: inset(10% 0 70% 0); } + 40% { clip-path: inset(50% 0 20% 0); } + 60% { clip-path: inset(10% 0 60% 0); } + 80% { clip-path: inset(30% 0 40% 0); } + 100% { clip-path: inset(60% 0 5% 0); } +} + + + +.scanlines { + overflow: hidden; + mix-blend-mode: difference; +} + +.scanlines::before { + content: ""; + position: absolute; + width: 100%; + height: 150%; + top: -25%; + left: 0; + background: repeating-linear-gradient(to bottom, transparent 0%, rgba(255, 255, 255, 0.02) 0.25%, transparent 0.5%); + animation: fudge 5s linear infinite; +} + +@keyframes fudge { + from { + transform: translate(0px, 0); + } + to { + transform: translate(0px, 5%); + } +} + + + +.sparkler-light { + width: 9rem; + height: 100%; + position: relative; + top: -10vh; +} + +.spark { + position: absolute; + width: 0.1rem; + height: 0.1rem; + bottom: 4.3rem; + left: 4.35rem; + transform: rotate(var(--spark-rotate)); +} + +.spark::after { + content: ''; + display: block; + position: absolute; + bottom: 0; + width: 0.1rem; + height: 2rem; + border-radius: 0.1rem; + opacity: 0; + background-color: white; + transform-origin: bottom center; + animation: sparkler-sparkle var(--spark-duration) infinite linear; + animation-delay: var(--spark-delay); +} + +@keyframes sparkler-light-pulsating { + 0% { + transform: scale(1); + } + 25% { + transform: scale(1.1); + } + 75% { + transform: scale(0.9); + } + 100% { + transform: scale(1); + } +} + +@keyframes sparkler-sparkle { + 0% { + transform: translateY(-0.5vh) scaleY(0.25); + opacity: 0; + } + 10% { + transform: translateY(-3vh) scaleY(0.5); + opacity: 0.35; + } + 30% { + transform: translateY(-6vh) scaleY(0.5); + opacity: 0.7; + } + 50% { + transform: translateY(-10vh) scaleY(1.5); + opacity: 0.7; + } + 51% { + opacity: 0; + transform: translateY(-4vh) scaleY(1); + } + 100% { + opacity: 0; + transform: translateY(0) scaleY(0.25); + } +} diff --git a/assets/presenter.js b/assets/presenter.js new file mode 100644 index 0000000..bfe41b8 --- /dev/null +++ b/assets/presenter.js @@ -0,0 +1,70 @@ +const overlayEl = document.getElementById("solve-overlay"); +const firstBloodEl = document.getElementById("solve-first"); +const teamEl = document.getElementById("solve-team"); +const challengeEl = document.getElementById("solve-challenge"); +const valueEl = document.getElementById("solve-value"); +const scoreboardFrameEl = document.getElementById("scoreboard-frame"); + +const solveQueue = []; +let isShowingOverlay = false; + +function pollNextSolve() { + if (isShowingOverlay) { + return; + } + + if (solveQueue.length > 0) { + const nextSolve = solveQueue.shift(); + showSolve(nextSolve.team, nextSolve.challenge, nextSolve.value, nextSolve.first); + } +} + +function showSolve(team, challenge, value, first) { + teamEl.textContent = team; + teamEl.setAttribute("data-text", team); + challengeEl.textContent = challenge; + challengeEl.setAttribute("data-text", challenge); + valueEl.textContent = `+${value}`; + valueEl.setAttribute("data-text", `+${value}`); + + if (first) { + firstBloodEl.classList.add("visible"); + } + + overlayEl.classList.add("visible"); + isShowingOverlay = true; + + setTimeout(() => { + overlayEl.classList.remove("visible"); + firstBloodEl.classList.remove("visible"); + + setTimeout(() => { + isShowingOverlay = false; + pollNextSolve(); + }, 1000); + }, first ? 8500 : 4500); +} + +let reloadTimeout = null; + +function reloadScoreboard(delay = 1000) { + if (reloadTimeout) { + clearTimeout(reloadTimeout); + } + + reloadTimeout = setTimeout(() => { + scoreboardFrameEl.src = "/scoreboard?ts=" + Date.now(); + reloadTimeout = null; + }, delay); +} + +const es = new EventSource("/presenter/events"); + +es.onmessage = (e) => { + const data = JSON.parse(e.data); + if (data.type === "solve") { + solveQueue.push(data); + reloadScoreboard(); + pollNextSolve(); + } +}; diff --git a/presenter.py b/presenter.py new file mode 100644 index 0000000..613bd34 --- /dev/null +++ b/presenter.py @@ -0,0 +1,67 @@ +import json +import queue +from flask import Blueprint, Response, render_template +from sqlalchemy import event + +from CTFd.models import Solves, Challenges, Teams +from CTFd.utils.scores import get_standings + +presenter_blueprint = Blueprint( + "presenter", + __name__, + template_folder="templates" +) + +listeners = [] + + +def sse_format(data): + return f"data: {json.dumps(data)}\n\n" + + +@presenter_blueprint.route("/presenter") +def presenter_view(): + return render_template("presenter.html") + + +@presenter_blueprint.route("/presenter/events") +def presenter_events(): + def stream(): + q = queue.Queue() + listeners.append(q) + try: + while True: + data = q.get() + yield sse_format(data) + except GeneratorExit: + listeners.remove(q) + + return Response(stream(), mimetype="text/event-stream") + + +def broadcast(event): + for q in listeners: + q.put(event) + + +@event.listens_for(Solves, "after_insert") +def on_solve(mapper, connection, solve): + challenge = Challenges.query.get(solve.challenge_id) + team = Teams.query.get(solve.team_id) + + if not challenge or not team: + return + + first_blood = False + if Solves.query.filter_by(challenge_id=solve.challenge_id).count() == 1: + first_blood = True + + broadcast( + { + "type": "solve", + "team": team.name, + "challenge": challenge.name, + "value": challenge.value, + "first": first_blood + } + ) diff --git a/templates/presenter.html b/templates/presenter.html new file mode 100644 index 0000000..010393c --- /dev/null +++ b/templates/presenter.html @@ -0,0 +1,67 @@ +<!DOCTYPE html> +<html> +<head> + <title>Presenter mode</title> + <link href="/plugins/ctfd-presenter/assets/presenter.css" rel="stylesheet" /> +</head> + +<body> + <iframe + id="scoreboard-frame" + src="/scoreboard" + loading="eager"> + </iframe> + + <div id="solve-overlay"> + <div id="solve-text"> + <div id="solve-first"> + <img src="/plugins/ctfd-presenter/assets/first-blood.png" /> + <div class="sparkler-light"> + <div class="spark" style="--spark-rotate: 10deg; --spark-delay: 223ms; --spark-duration: 750ms"></div> + <div class="spark" style="--spark-rotate: 20deg; --spark-delay: 844ms; --spark-duration: 775ms"></div> + <div class="spark" style="--spark-rotate: 30deg; --spark-delay: 130ms; --spark-duration: 780ms"></div> + <div class="spark" style="--spark-rotate: 40deg; --spark-delay: 747ms; --spark-duration: 725ms"></div> + <div class="spark" style="--spark-rotate: 50deg; --spark-delay: 928ms; --spark-duration: 740ms"></div> + <div class="spark" style="--spark-rotate: 60deg; --spark-delay: 392ms; --spark-duration: 770ms"></div> + <div class="spark" style="--spark-rotate: 70deg; --spark-delay: 483ms; --spark-duration: 735ms"></div> + <div class="spark" style="--spark-rotate: 80deg; --spark-delay: 621ms; --spark-duration: 755ms"></div> + <div class="spark" style="--spark-rotate: 90deg; --spark-delay: 814ms; --spark-duration: 715ms"></div> + <div class="spark" style="--spark-rotate: 100deg; --spark-delay: 802ms; --spark-duration: 730ms"></div> + <div class="spark" style="--spark-rotate: 110deg; --spark-delay: 837ms; --spark-duration: 785ms"></div> + <div class="spark" style="--spark-rotate: 120deg; --spark-delay: 238ms; --spark-duration: 790ms"></div> + <div class="spark" style="--spark-rotate: 130deg; --spark-delay: 642ms; --spark-duration: 740ms"></div> + <div class="spark" style="--spark-rotate: 140deg; --spark-delay: 58ms; --spark-duration: 715ms"></div> + <div class="spark" style="--spark-rotate: 150deg; --spark-delay: 404ms; --spark-duration: 775ms"></div> + <div class="spark" style="--spark-rotate: 160deg; --spark-delay: 576ms; --spark-duration: 725ms"></div> + <div class="spark" style="--spark-rotate: 170deg; --spark-delay: 944ms; --spark-duration: 755ms"></div> + <div class="spark" style="--spark-rotate: 180deg; --spark-delay: 635ms; --spark-duration: 785ms"></div> + <div class="spark" style="--spark-rotate: 190deg; --spark-delay: 205ms; --spark-duration: 770ms"></div> + <div class="spark" style="--spark-rotate: 200deg; --spark-delay: 91ms; --spark-duration: 795ms"></div> + <div class="spark" style="--spark-rotate: 210deg; --spark-delay: 829ms; --spark-duration: 745ms"></div> + <div class="spark" style="--spark-rotate: 220deg; --spark-delay: 369ms; --spark-duration: 730ms"></div> + <div class="spark" style="--spark-rotate: 230deg; --spark-delay: 861ms; --spark-duration: 780ms"></div> + <div class="spark" style="--spark-rotate: 240deg; --spark-delay: 201ms; --spark-duration: 735ms"></div> + <div class="spark" style="--spark-rotate: 250deg; --spark-delay: 173ms; --spark-duration: 755ms"></div> + <div class="spark" style="--spark-rotate: 260deg; --spark-delay: 967ms; --spark-duration: 765ms"></div> + <div class="spark" style="--spark-rotate: 270deg; --spark-delay: 548ms; --spark-duration: 715ms"></div> + <div class="spark" style="--spark-rotate: 280deg; --spark-delay: 392ms; --spark-duration: 775ms"></div> + <div class="spark" style="--spark-rotate: 290deg; --spark-delay: 973ms; --spark-duration: 740ms"></div> + <div class="spark" style="--spark-rotate: 300deg; --spark-delay: 6ms; --spark-duration: 725ms"></div> + <div class="spark" style="--spark-rotate: 310deg; --spark-delay: 1ms; --spark-duration: 760ms"></div> + <div class="spark" style="--spark-rotate: 320deg; --spark-delay: 854ms; --spark-duration: 735ms"></div> + <div class="spark" style="--spark-rotate: 330deg; --spark-delay: 159ms; --spark-duration: 750ms"></div> + <div class="spark" style="--spark-rotate: 340deg; --spark-delay: 60ms; --spark-duration: 780ms"></div> + <div class="spark" style="--spark-rotate: 350deg; --spark-delay: 986ms; --spark-duration: 765ms"></div> + <div class="spark" style="--spark-rotate: 360deg; --spark-delay: 559ms; --spark-duration: 740ms"></div> + </div> + </div> + <div id="solve-team" class="glitch"></div> + <div id="solve-value" class="glitch"></div> + <div id="solve-challenge" class="glitch"></div> + </div> + <div class="scanlines"></div> + </div> + + <script src="/plugins/ctfd-presenter/assets/presenter.js"></script> +</body> +</html> |
