From 167f986f468014e00d82fa2df8193f6be8dca19d Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 1 Mar 2024 18:35:49 +0100 Subject: [PATCH] add simple guessing game browser source --- app/Http/Controllers/ChannelController.php | 65 +++++- app/Models/Channel.php | 13 +- app/Models/GuessingGuess.php | 4 + app/Models/GuessingWinner.php | 4 + .../2024_03_01_090259_channel_access_key.php | 32 +++ resources/js/app/Routes.js | 7 + resources/js/components/common/Icon.js | 2 + resources/js/components/common/Slider.js | 47 +++++ .../js/components/twitch-bot/Controls.js | 41 ++-- resources/js/i18n/de.js | 1 + resources/js/i18n/en.js | 1 + resources/js/pages/GuessingGameMonitor.js | 194 ++++++++++++++++++ resources/sass/channels.scss | 55 +++++ resources/sass/common.scss | 25 +++ routes/api.php | 2 + 15 files changed, 470 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2024_03_01_090259_channel_access_key.php create mode 100644 resources/js/components/common/Slider.js create mode 100644 resources/js/pages/GuessingGameMonitor.js diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php index 4cc49c1..78648af 100644 --- a/app/Http/Controllers/ChannelController.php +++ b/app/Http/Controllers/ChannelController.php @@ -6,6 +6,8 @@ use App\Models\Channel; use App\Models\TwitchBotCommand; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; class ChannelController extends Controller { @@ -34,12 +36,12 @@ class ChannelController extends Controller { ->orWhere('short_name', 'LIKE', '%'.$validatedData['phrase'].'%'); } $channels = $channels->limit(5); - return $channels->get()->toJson(); + return $this->sendChannels($channels->get()); } public function single(Request $request, Channel $channel) { $this->authorize('view', $channel); - return $channel->toJson(); + return $this->sendChannel($channel); } public function chat(Request $request, Channel $channel) { @@ -53,7 +55,7 @@ class ChannelController extends Controller { $this->authorize('editRestream', $channel); $nick = empty($validatedData['bot_nick']) ? 'localhorsttv' : $validatedData['bot_nick']; TwitchBotCommand::chat($channel->twitch_chat, $validatedData['text'], $request->user(), $nick); - return $channel->toJson(); + return $this->sendChannel($channel); } public function chatSettings(Request $request, Channel $channel) { @@ -69,7 +71,7 @@ class ChannelController extends Controller { $this->authorize('editRestream', $channel); $channel->chat_settings = $validatedData; $channel->save(); - return $channel->toJson(); + return $this->sendChannel($channel); } public function join(Request $request, Channel $channel) { @@ -88,7 +90,7 @@ class ChannelController extends Controller { } $channel->save(); TwitchBotCommand::join($channel->twitch_chat, $request->user(), $nick); - return $channel->toJson(); + return $this->sendChannel($channel); } public function part(Request $request, Channel $channel) { @@ -107,7 +109,7 @@ class ChannelController extends Controller { } $channel->save(); TwitchBotCommand::part($channel->twitch_chat, $request->user(), $nick); - return $channel->toJson(); + return $this->sendChannel($channel); } public function deleteCommand(Channel $channel, $command) { @@ -118,7 +120,7 @@ class ChannelController extends Controller { $channel->chat_commands = $cmds; $channel->save(); } - return $channel->toJson(); + return $this->sendChannel($channel); } public function saveCommand(Request $request, Channel $channel, $command) { @@ -135,7 +137,7 @@ class ChannelController extends Controller { $channel->chat_commands = $cmds; $channel->save(); - return $channel->toJson(); + return $this->sendChannel($channel); } public function controlGuessingGame(Request $request, Channel $channel, $name) { @@ -195,7 +197,7 @@ class ChannelController extends Controller { break; } - return $channel->toJson(); + return $this->sendChannel($channel); } public function getGuessingGame(Channel $channel, $name) { @@ -214,6 +216,23 @@ class ChannelController extends Controller { ]; } + public function getGuessingGameMonitor($key) { + $channel = Channel::where('access_key', '=', $key)->firstOrFail(); + + $cutoff = $channel->guessing_start; + if (is_null($cutoff)) { + $last = $channel->winners()->latest()->first(); + $cutoff = $last->pod; + } + $guesses = $channel->guesses()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get(); + $winners = $channel->winners()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get(); + return [ + 'channel' => $channel->toArray(), + 'guesses' => $guesses->toArray(), + 'winners' => $winners->toArray(), + ]; + } + public function saveGuessingGame(Request $request, Channel $channel, $name) { $this->authorize('editRestream', $channel); @@ -238,7 +257,35 @@ class ChannelController extends Controller { $channel->guessing_settings = $settings; $channel->save(); + return $this->sendChannel($channel); + } + + protected function sendChannel(Channel $channel) { + if (Gate::allows('editRestream', $channel)) { + $this->unhideChannel($channel); + } return $channel->toJson(); } + protected function sendChannels(Collection $channels) { + foreach ($channels as $channel) { + if (Gate::allows('editRestream', $channel)) { + $this->unhideChannel($channel); + } + } + return $channels->toJson(); + } + + private function unhideChannel(Channel $channel) { + $channel->makeVisible([ + 'access_key', + 'chat', + 'chat_commands', + 'chat_settings', + 'guessing_settings', + 'join', + 'twitch_chat', + ]); + } + } diff --git a/app/Models/Channel.php b/app/Models/Channel.php index da9f6a7..8debe4f 100644 --- a/app/Models/Channel.php +++ b/app/Models/Channel.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Broadcasting\Channel as PublicChannel; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Database\Eloquent\BroadcastsEvents; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -17,6 +18,9 @@ class Channel extends Model { $channels = [ new PrivateChannel('Channel.'.$this->id), ]; + if (!empty($this->access_key)) { + $channels[] = new PublicChannel('ChannelKey.'.$this->access_key); + } return $channels; } @@ -183,16 +187,23 @@ class Channel extends Model { 'chat' => 'boolean', 'chat_commands' => 'array', 'chat_settings' => 'array', + 'guessing_end' => 'datetime', 'guessing_settings' => 'array', 'guessing_start' => 'datetime', - 'guessing_end' => 'datetime', 'languages' => 'array', 'join' => 'boolean', ]; protected $hidden = [ + 'access_key', + 'chat', + 'chat_commands', + 'chat_settings', 'created_at', 'ext_id', + 'guessing_settings', + 'join', + 'twitch_chat', 'updated_at', ]; diff --git a/app/Models/GuessingGuess.php b/app/Models/GuessingGuess.php index b8e413b..4062452 100644 --- a/app/Models/GuessingGuess.php +++ b/app/Models/GuessingGuess.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Broadcasting\Channel as PublicChannel; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Database\Eloquent\BroadcastsEvents; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,6 +21,9 @@ class GuessingGuess extends Model { $channels = [ new PrivateChannel('Channel.'.$this->channel_id), ]; + if (!empty($this->channel->access_key)) { + $channels[] = new PublicChannel('ChannelKey.'.$this->channel->access_key); + } return $channels; } diff --git a/app/Models/GuessingWinner.php b/app/Models/GuessingWinner.php index e6945bf..0caada3 100644 --- a/app/Models/GuessingWinner.php +++ b/app/Models/GuessingWinner.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Broadcasting\Channel as PublicChannel; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Database\Eloquent\BroadcastsEvents; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,6 +21,9 @@ class GuessingWinner extends Model { $channels = [ new PrivateChannel('Channel.'.$this->channel_id), ]; + if (!empty($this->channel->access_key)) { + $channels[] = new PublicChannel('ChannelKey.'.$this->channel->access_key); + } return $channels; } diff --git a/database/migrations/2024_03_01_090259_channel_access_key.php b/database/migrations/2024_03_01_090259_channel_access_key.php new file mode 100644 index 0000000..1b37d09 --- /dev/null +++ b/database/migrations/2024_03_01_090259_channel_access_key.php @@ -0,0 +1,32 @@ +string('access_key')->default(''); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('channels', function (Blueprint $table) { + $table->dropColumn('access_key'); + }); + } +}; diff --git a/resources/js/app/Routes.js b/resources/js/app/Routes.js index 3f73834..dbdbcae 100644 --- a/resources/js/app/Routes.js +++ b/resources/js/app/Routes.js @@ -135,6 +135,13 @@ const router = createBrowserRouter( '../pages/GuessingGameControls' )} /> + import( + /* webpackChunkName: "guessing" */ + '../pages/GuessingGameMonitor' + )} + /> ) diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 8f03bae..4d9559c 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -62,6 +62,7 @@ Icon.ADD = makePreset('AddIcon', 'circle-plus'); Icon.ALLOWED = makePreset('AllowedIcon', 'square-check'); Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket'); Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running'); +Icon.BROWSER_SOURCE = makePreset('BrowserSourceIcon', 'tv'); Icon.CHART = makePreset('ChartIcon', 'chart-line'); Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs'); Icon.DELETE = makePreset('DeleteIcon', 'user-xmark'); @@ -103,6 +104,7 @@ Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']); Icon.UNKNOWN = makePreset('UnknownIcon', 'square-question'); Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open'); Icon.VIDEO = makePreset('VideoIcon', 'video'); +Icon.WARNING = makePreset('WarningIcon', 'triangle-exclamation'); Icon.VOLUME = makePreset('VolumeIcon', 'volume-high'); Icon.YOUTUBE = makePreset('YoutubeIcon', ['fab', 'youtube']); diff --git a/resources/js/components/common/Slider.js b/resources/js/components/common/Slider.js new file mode 100644 index 0000000..634c15c --- /dev/null +++ b/resources/js/components/common/Slider.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +const Slider = ({ children, duration, vertical }) => { + const [index, setIndex] = React.useState(0); + + React.useEffect(() => { + const interval = setInterval(() => { + setIndex(i => (i + 1) % React.Children.count(children)); + }, duration); + return () => { + clearInterval(interval); + }; + }, [React.Children.count(children), duration]); + + return
+
+ {children} +
+
; +}; + +Slider.propTypes = { + children: PropTypes.node, + duration: PropTypes.number, + vertical: PropTypes.bool, +}; + +Slider.defaultProps = { + duration: 2500, +}; + +const Slide = ({ children }) => { + return
+ {children} +
; +}; + +Slide.propTypes = { + children: PropTypes.node, +}; + +Slider.Slide = Slide; + +export default Slider; diff --git a/resources/js/components/twitch-bot/Controls.js b/resources/js/components/twitch-bot/Controls.js index 84742d8..54f4dec 100644 --- a/resources/js/components/twitch-bot/Controls.js +++ b/resources/js/components/twitch-bot/Controls.js @@ -232,19 +232,34 @@ const Controls = () => {

{t('twitchBot.guessingGame.settings')}

- +
+ {channel.access_key ? + + : null} + +
{ + const [channel, setChannel] = React.useState({}); + const [guesses, setGuesses] = React.useState([]); + const [winnerExpiry, setWinnerExpiry] = React.useState(moment().subtract(15, 'second')); + const [winners, setWinners] = React.useState([]); + + const params = useParams(); + const { key } = params; + + React.useEffect(() => { + if (!key) return; + axios.get(`/api/guessing-game-monitor/${key}`) + .then(res => { + setChannel(res.data.channel); + res.data.guesses.forEach(g => { + setGuesses(gs => patchGuess(gs, g)); + }); + res.data.winners.forEach(w => { + setWinners(ws => patchGuess(ws, w)); + }); + }); + window.Echo.channel(`ChannelKey.${key}`) + .listen('.GuessingGuessCreated', (e) => { + setGuesses(gs => patchGuess(gs, e.model)); + }) + .listen('.GuessingWinnerCreated', (e) => { + setWinners(ws => patchWinner(ws, e.model)); + }) + .listen('.ChannelUpdated', (e) => { + setChannel(c => ({ ...c, ...e.model })); + }); + return () => { + window.Echo.leave(`ChannelKey.${key}`); + }; + }, [key]); + + React.useEffect(() => { + if (isAcceptingGuesses(channel)) { + setGuesses(gs => gs.filter(g => g.created_at >= channel.guessing_start)); + setWinners([]); + } + }, [channel]); + + React.useEffect(() => { + const interval = setInterval(() => { + setWinnerExpiry(moment().subtract(15, 'second')); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); + + const guessingStats = React.useMemo(() => { + const stats = { + counts: [], + lastWin: null, + max: 0, + wins: [], + winners: [], + }; + for (let i = 0; i < 22; ++i) { + stats.counts.push(0); + stats.wins.push(false); + } + const seen = []; + guesses.forEach(guess => { + if (seen[guess.uid]) { + --stats.counts[parseInt(seen[guess.uid].guess, 10) - 1]; + } + ++stats.counts[parseInt(guess.guess, 10) - 1]; + seen[guess.uid] = guess; + }); + winners.forEach(winner => { + if (winner.score) { + stats.wins[parseInt(winner.guess, 10) - 1] = true; + stats.winners.push(winner.uname); + } + if (!stats.lastWin || stats.lastWin < winner.created_at) { + stats.lastWin = winner.created_at; + } + }); + for (let i = 0; i < 22; ++i) { + if (stats.counts[i] > stats.max) { + stats.max = stats.counts[i]; + } + } + return stats; + }, [guesses, winners]); + + const getNumberHeight = React.useCallback((number) => { + if (!guessingStats || !guessingStats.max) return 3; + if (!number) return 3; + return Math.max(0.05, number / guessingStats.max) * 100; + }, [guessingStats]); + + const getStatClass = React.useCallback((index) => { + const names = ['guessing-stat']; + if (guessingStats.wins[index]) { + names.push('has-won'); + } + return names.join(' '); + }, [guessingStats]); + + const showOpen = React.useMemo(() => { + return isAcceptingGuesses(channel); + }, [channel]); + + const showClosed = React.useMemo(() => { + return hasActiveGuessing(channel) && !isAcceptingGuesses(channel); + }, [channel]); + + const showWinners = React.useMemo(() => { + return !hasActiveGuessing(channel) && ( + guessingStats?.lastWin && + moment(guessingStats.lastWin).isAfter(winnerExpiry)); + }, [channel, guessingStats, winnerExpiry]); + + return + + Guessing Game + + + {showOpen || showClosed || showWinners ? +
+ {showOpen ? +
+ +
+ + GT Big Key Guessing Game + Zahlen von 1 bis 22 in den Chat! + +
+ +
+ : null} + {showClosed ? +
+
+ Anmeldung geschlossen +
+
+ : null} + {showWinners ? +
+
+ {guessingStats.winners.length ? + + Herzlichen Glückwunsch! + {guessingStats.winners.map(winner => + {winner} + )} + + : + 'Leider keiner richtig' + } +
+
+ : null} +
+ {guessingStats.counts.map((number, index) => +
+
+
+
+
{index + 1}
+
+ )} +
+
+ : null} + + ; +}; diff --git a/resources/sass/channels.scss b/resources/sass/channels.scss index cffa578..3e0f185 100644 --- a/resources/sass/channels.scss +++ b/resources/sass/channels.scss @@ -5,3 +5,58 @@ opacity: .6; background: repeating-linear-gradient(150deg, #444, #444 1ex, transparent 1ex, transparent 1em); } + +.guessing-game-monitor { + font-size: 5vw; + text-align: center; + + .message-box { + display: inline-block; + padding: 0.5ex 1ex; + border-radius: 0.5ex; + background: rgba(0, 0, 0, 0.5); + } + .message-title { + display: flex; + align-items: center; + justify-content: space-between; + } + .accepting-guesses .message-icon { + color: $warning; + } + .guessing-closed .message-icon { + color: $danger; + } + .message-text { + flex-grow: 1; + margin-left: 1ex; + margin-right: 1ex; + width: 70vw; + height: 1.2em; + line-height: 1; + } + + .guessing-stats { + display: flex; + font-size: 3vw; + justify-content: center; + margin-top: 1ex; + } + .guessing-stat { + width: 1.3em; + } + .guessing-box { + width: 100%; + height: 2em; + position: relative; + } + .guessing-box-bar { + background-color: $primary; + position: absolute; + bottom: 0; + width: 100%; + } + .has-won .guessing-box-bar { + background-color: $danger; + } +} diff --git a/resources/sass/common.scss b/resources/sass/common.scss index 4d603f0..0cb6655 100644 --- a/resources/sass/common.scss +++ b/resources/sass/common.scss @@ -78,6 +78,31 @@ h1 { } } +.slider-container { + overflow: hidden; + height: 100%; + width: 100%; + + > .slider-slides { + transition: transform 600ms; + } + &.horizontal { + > .slider-slides { + height: 100%; + white-space: nowrap; + > .slider-slide { + display: inline-block; + width: 100%; + } + } + } + &.vertical { + > .slider-slides { + width: 100%; + } + } +} + .snes-button-a, .snes-button-b, .snes-button-x, diff --git a/routes/api.php b/routes/api.php index 2135a2f..0c897af 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,8 @@ Route::get('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\Chan Route::post('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@controlGuessingGame'); Route::put('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@saveGuessingGame'); +Route::get('guessing-game-monitor/{key}', 'App\Http\Controllers\ChannelController@getGuessingGameMonitor'); + Route::get('content', 'App\Http\Controllers\TechniqueController@search'); Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single'); Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update'); -- 2.39.2