Compare commits
4 Commits
b47f8fca83
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c69eb19af3 | ||
|
|
fd1c78b333 | ||
|
|
dd3493921b | ||
|
|
4ceff4015a |
@@ -51,7 +51,6 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
|
||||
- `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`.
|
||||
- `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`.
|
||||
- `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out).
|
||||
- `php artisan rig:playlist` — index audio dirs with ffprobe → `webapp/storage/data/playlist.json`.
|
||||
- `php artisan rig:cmd <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`).
|
||||
|
||||
**Audio + automation surfaces:**
|
||||
|
||||
10
README.md
@@ -50,8 +50,8 @@ playback from chat.
|
||||
through the per-app private dir.
|
||||
- **Laravel 13** webapp under `webapp/`, served by `php artisan serve` on
|
||||
`127.0.0.1:1118` and managed by **supervisord**. Routes return Blade-rendered
|
||||
overlay pages and a small data API (`/data/playlist.js`, `/cover.jpg`,
|
||||
`/track?p=…`, `POST /cmd/{skip|prev|pause|resume}`).
|
||||
overlay pages and a small data API (`/cover.jpg`, `/track?p=…`,
|
||||
`POST /cmd/{skip|prev|pause|resume}`).
|
||||
- **MPD** (system service) is the audio player. A small Python bridge
|
||||
(`bridges/mpd-state.py`, `obs-mpd-bridge.service`) listens to MPD and
|
||||
re-broadcasts state as `mpd:state` CustomEvents on the OBS WS bus.
|
||||
@@ -71,7 +71,7 @@ playback from chat.
|
||||
├── webapp/ Laravel app — every overlay served from here
|
||||
│ ├── app/
|
||||
│ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
|
||||
│ │ │ rig:playlist, rig:cmd
|
||||
│ │ │ rig:cmd
|
||||
│ │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,…}, Data,
|
||||
│ │ │ MusicCommand, Audio
|
||||
│ │ ├── Services/ ObsWsClient, TwitchHelix, HardwareSnapshot
|
||||
@@ -107,8 +107,8 @@ sudo supervisorctl restart obs-webapp
|
||||
# refresh a per-session manifest before going live (game / mode / countdown)
|
||||
cd webapp && php artisan rig:loading
|
||||
|
||||
# reindex the music library (after dropping new tracks into ~/HDD/Music)
|
||||
cd webapp && php artisan rig:playlist
|
||||
# rescan the music library (after dropping new tracks into ~/Music/Kolekcja)
|
||||
mpc update --wait
|
||||
|
||||
# re-snapshot hardware/OBS profile telemetry (kernel/GPU/encoder changes)
|
||||
cd webapp && php artisan rig:telemetry --collect
|
||||
|
||||
@@ -220,7 +220,7 @@ fi
|
||||
|
||||
ASSET_DIRS=(
|
||||
"$HOME/Videos"
|
||||
"$HOME/HDD/Music"
|
||||
"$HOME/Music"
|
||||
"$HOME/HDD/Images/Gifs"
|
||||
"$HOME/cloud.jakubzych.com/_img/logos"
|
||||
)
|
||||
@@ -372,7 +372,7 @@ write_mpd_conf() {
|
||||
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
|
||||
# Re-run the deploy script to regenerate, or hand-edit for local tweaks.
|
||||
|
||||
music_directory "~/HDD/Music"
|
||||
music_directory "~/Music"
|
||||
playlist_directory "~/.config/mpd/playlists"
|
||||
db_file "~/.config/mpd/database"
|
||||
state_file "~/.config/mpd/state"
|
||||
@@ -421,13 +421,13 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -d "$HOME/HDD/Music" ]] && [[ -n "$(find "$HOME/HDD/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then
|
||||
if [[ -d "$HOME/Music" ]] && [[ -n "$(find "$HOME/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then
|
||||
if would "mpc update"; then
|
||||
mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)"
|
||||
ok "MPD library scan kicked off"
|
||||
fi
|
||||
else
|
||||
warn "~/HDD/Music is empty — MPD will have no tracks until you populate it"
|
||||
warn "~/Music is empty — MPD will have no tracks until you populate it"
|
||||
fi
|
||||
|
||||
# Avahi for zeroconf advertisement (system service).
|
||||
|
||||
178
webapp/README.md
@@ -1,58 +1,166 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
# OPHI-118 webapp
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
Laravel app that renders every on-stream overlay for the OPHI-118 Twitch rig and
|
||||
hosts the streamer's control surfaces. Runs under supervisord on `http://127.0.0.1:1118/`.
|
||||
|
||||
## About Laravel
|
||||
> Looking for the rig-wide picture (OBS, MPD, twitch-bot, deployment)?
|
||||
> See [`../README.md`](../README.md). This file is just the webapp.
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
## What's in here
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
### Control panel — `/`
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
Tile-based dashboard. Entry point from a browser bookmark; not used by OBS itself.
|
||||
|
||||
## Learning Laravel
|
||||

|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
- **ONBOARD** — opens the project-loading wizard.
|
||||
- **SCENES** — opens the full route index.
|
||||
- **ophi118.com** — public channel landing page.
|
||||
- **gamez.ciemnosc.com** — self-hosted game-server roster.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
### Onboard wizard — `/onboard`
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
Web equivalent of `php artisan rig:loading`. Compiles the Project Loading manifest
|
||||
(game, subtitle, countdown, camera/mic state) and optionally pushes the channel
|
||||
title + category to Twitch via Helix.
|
||||
|
||||
## Agentic Development
|
||||

|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
- **Type-ahead game search** against the Twitch Helix `search/categories`
|
||||
endpoint, debounced. ↑/↓/Enter/Esc keys to pick a match; locks the `gameId`.
|
||||
- **Live preview** mirrors what the `/loading` overlay will render — every edit
|
||||
re-renders the terminal-style output immediately.
|
||||
- **Stepper** for the countdown, **ENABLED/DISABLED toggles** for camera/mic,
|
||||
**push-to-Twitch** checkbox to commit the channel update via Helix.
|
||||
- **Graceful fallback**: with `TWITCH_CLIENT_ID` / `TWITCH_ACCESS_TOKEN` unset,
|
||||
the search disables and the manifest still saves locally.
|
||||
|
||||

|
||||
|
||||
### Scenes index — `/scenes`
|
||||
|
||||
All overlay URLs the OBS browser-sources point at, plus the data endpoints.
|
||||
|
||||

|
||||
|
||||
### Overlay routes
|
||||
|
||||
Browser sources in `basic/scenes/Default_Stream_HUD.json` consume these. Edits
|
||||
to the Blade views are picked up on the next OBS **Refresh cache** — no server
|
||||
restart needed.
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `/landing` | Fallout-style "PLEASE STAND BY" + telemetry rotator |
|
||||
| `/loading` | Terminal-style Project Loading + countdown |
|
||||
| `/game` | Game HUD (camera-frame OBS-WS sync, status bar) |
|
||||
| `/desktop` | Desktop HUD (camera + screen-capture frames) |
|
||||
| `/goodbye` | Sign-off terminal + "OFFLINE FOR" counter |
|
||||
| `/music-box` | Full-screen music box terminal |
|
||||
| `/music-box/widget` | Compact 549×880 widget |
|
||||
| `/music-box/cover` | Album-art widget (`?bars=0` for compact mode) |
|
||||
| `/music-box/nc` | ncurses-styled 2-pane variant |
|
||||
| `/music-daemon` | Passive WebSocket listener (diagnostic) |
|
||||
|
||||
### Data + control endpoints
|
||||
|
||||
| Route | Notes |
|
||||
|---|---|
|
||||
| `GET /cover.jpg` | Streams `bridges/cover.jpg` (kept fresh by `mpd-state.py`) |
|
||||
| `GET /track?p=<base64>` | Audio stream — path-traversal-guarded |
|
||||
| `POST /cmd/{skip\|prev\|pause\|resume}` | Broadcasts `mpd:cmd` over OBS WebSocket |
|
||||
|
||||
## Artisan commands
|
||||
|
||||
Each lives at `app/Console/Commands/Rig*Command.php`. Run from the `webapp/`
|
||||
directory.
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
php artisan rig:setup # gen public/js/obs-config.js + OBS_WS_* in .env
|
||||
php artisan rig:telemetry # refresh storage/data/telemetry.json
|
||||
php artisan rig:telemetry --collect # re-prompt every field interactively
|
||||
php artisan rig:loading # interactive Project Loading manifest (CLI mirror of /onboard)
|
||||
php artisan rig:cmd skip # CLI mirror of POST /cmd/skip
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
## Running it
|
||||
|
||||
## Contributing
|
||||
The supervisord program file lives at [`../scripts/obs-webapp.supervisord.conf`](../scripts/obs-webapp.supervisord.conf)
|
||||
and is installed (root-owned) to `/etc/supervisor.d/obs-webapp.conf` by
|
||||
`scripts/deploy-rig.sh`.
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
```bash
|
||||
sudo supervisorctl status obs-webapp # → RUNNING
|
||||
sudo supervisorctl restart obs-webapp # after route / controller / .env changes
|
||||
tail -F storage/logs/supervisord.{out,err}.log
|
||||
```
|
||||
|
||||
## Code of Conduct
|
||||
For local hacking without supervisord:
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
```bash
|
||||
php artisan serve --host=127.0.0.1 --port=1118
|
||||
```
|
||||
|
||||
## Security Vulnerabilities
|
||||
## When to restart
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
| Change | Action |
|
||||
|---|---|
|
||||
| Blade view (`resources/views/`) | OBS → right-click source → **Refresh cache** |
|
||||
| Static CSS / JS (`public/`) | Same — Refresh cache |
|
||||
| Route / controller / service / config / `.env` | `sudo supervisorctl restart obs-webapp` |
|
||||
|
||||
## License
|
||||
## Layout
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
```
|
||||
webapp/
|
||||
├── app/
|
||||
│ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
|
||||
│ │ rig:cmd
|
||||
│ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,Desktop,
|
||||
│ │ Goodbye,MusicBox,MusicDaemon}, Data,
|
||||
│ │ MusicCommand, Audio, Onboard
|
||||
│ ├── Services/ ObsWsClient (Pawl), TwitchHelix,
|
||||
│ │ HardwareSnapshot
|
||||
│ └── Support/RigData.php
|
||||
├── resources/
|
||||
│ ├── views/
|
||||
│ │ ├── home.blade.php /
|
||||
│ │ ├── scenes.blade.php /scenes
|
||||
│ │ ├── onboard.blade.php /onboard
|
||||
│ │ ├── overlays/ per-scene overlays
|
||||
│ │ ├── partials/ topbar, hud-strip, crt-overlays, …
|
||||
│ │ └── layouts/ overlay base layout
|
||||
│ └── img/ favicon + screenshots (this README)
|
||||
├── public/
|
||||
│ ├── css/hud.css shared HUD chrome, scanlines, terminal styling
|
||||
│ ├── js/ hud, crt-static, obs-ws-bootstrap, obs-ws-mini
|
||||
│ ├── audio/static-hum.wav ffmpeg_source (not browser-served)
|
||||
│ └── favicon.ico → symlink to ../resources/img/favicon.ico
|
||||
├── routes/web.php
|
||||
├── config/rig.php storage paths, OBS WS creds, Twitch keys
|
||||
└── storage/data/ rig:* commands write JSON here
|
||||
├── loading.json
|
||||
└── telemetry.json
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
`.env` keys the webapp cares about:
|
||||
|
||||
```
|
||||
APP_URL=http://127.0.0.1:1118
|
||||
|
||||
OBS_WS_URL=ws://localhost:4455
|
||||
OBS_WS_PASSWORD=… # written by `php artisan rig:setup`
|
||||
|
||||
TWITCH_CLIENT_ID=…
|
||||
TWITCH_ACCESS_TOKEN=… # legacy TWITCH_TOKEN is also accepted
|
||||
TWITCH_BROADCASTER_ID=…
|
||||
|
||||
RIG_NAME=…
|
||||
OBS_PROFILE=ophi118
|
||||
MUSIC_DIR_1=…
|
||||
```
|
||||
|
||||
See [`.env.example`](.env.example) for the full set.
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
class RigPlaylistCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:playlist';
|
||||
protected $description = 'Index audio under the configured music roots → storage/data/playlist.json';
|
||||
|
||||
private const EXTS = ['m4a', 'mp3', 'opus', 'ogg', 'flac', 'webm', 'aac', 'wav'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$roots = array_values(array_filter((array) config('rig.music_dirs')));
|
||||
if (empty($roots)) {
|
||||
$this->warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)');
|
||||
}
|
||||
|
||||
$ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe');
|
||||
if (!is_executable($ffprobe)) {
|
||||
$this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line('── Indexing audio roots ──');
|
||||
foreach ($roots as $r) {
|
||||
$this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)");
|
||||
}
|
||||
|
||||
$tracks = [];
|
||||
foreach ($roots as $root) {
|
||||
if (!is_dir($root)) continue;
|
||||
$finder = (new Finder())
|
||||
->files()
|
||||
->in($root)
|
||||
->name('/\.(' . implode('|', self::EXTS) . ')$/i')
|
||||
->ignoreUnreadableDirs()
|
||||
->sortByName();
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$name = $file->getFilename();
|
||||
$base = $file->getFilenameWithoutExtension();
|
||||
// Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus).
|
||||
if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue;
|
||||
|
||||
$path = $file->getRealPath();
|
||||
$meta = $this->probe($ffprobe, $path);
|
||||
$tracks[] = [
|
||||
'title' => $this->cleanTitle($base),
|
||||
'file' => 'file://' . $this->urlEncodePath($path),
|
||||
'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$doc = [
|
||||
'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'sourceDirs' => $roots,
|
||||
'trackCount' => count($tracks),
|
||||
'tracks' => $tracks,
|
||||
];
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/playlist.json';
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
|
||||
$this->line('');
|
||||
$this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)");
|
||||
$this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function probe(string $ffprobe, string $path): array
|
||||
{
|
||||
try {
|
||||
$proc = new Process([
|
||||
$ffprobe, '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json',
|
||||
$path,
|
||||
]);
|
||||
$proc->setTimeout(10);
|
||||
$proc->run();
|
||||
if (!$proc->isSuccessful()) return [];
|
||||
$json = json_decode($proc->getOutput(), true) ?: [];
|
||||
$fmt = $json['format'] ?? [];
|
||||
return [
|
||||
'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors scripts/playlist.sh's clean_title():
|
||||
* - drop "| ..." (or fullwidth |) tails (genre/label noise)
|
||||
* - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]"
|
||||
* - strip "[NCS Release]" / "(NCS10 Release)" suffixes
|
||||
*/
|
||||
private function cleanTitle(string $t): string
|
||||
{
|
||||
$t = preg_replace('/\s*[||].*$/u', '', $t) ?? $t;
|
||||
$t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t;
|
||||
$t = preg_replace(
|
||||
'/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i',
|
||||
'',
|
||||
$t
|
||||
) ?? $t;
|
||||
return trim($t);
|
||||
}
|
||||
|
||||
private function urlEncodePath(string $path): string
|
||||
{
|
||||
// Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest.
|
||||
return implode('/', array_map('rawurlencode', explode('/', $path)));
|
||||
}
|
||||
}
|
||||
@@ -3,53 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class DataController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve the playlist as a script-tag-friendly JS global. Cached via ETag on file mtime
|
||||
* so CEF can revalidate cheaply (the playlist file changes rarely but the body is ~142 KB).
|
||||
*/
|
||||
public function playlist(Request $request, RigData $rig)
|
||||
{
|
||||
$path = $rig->playlistPath();
|
||||
$tracks = $rig->playlist();
|
||||
$mtime = is_file($path) ? filemtime($path) : 0;
|
||||
$etag = '"' . md5('playlist:' . $mtime) . '"';
|
||||
|
||||
if ($request->headers->get('If-None-Match') === $etag) {
|
||||
return response('', 304)->header('ETag', $etag);
|
||||
}
|
||||
|
||||
// Rewrite `file://` URIs into `/audio?p=...` HTTP URLs so the
|
||||
// music-daemon page (served over HTTP) can load tracks. Chromium
|
||||
// blocks HTTP-origin pages from loading file:// media.
|
||||
if (isset($tracks['tracks']) && is_array($tracks['tracks'])) {
|
||||
foreach ($tracks['tracks'] as $i => $track) {
|
||||
if (!isset($track['file'])) continue;
|
||||
if (str_starts_with($track['file'], 'file://')) {
|
||||
$absolute = rawurldecode(substr($track['file'], 7));
|
||||
$tracks['tracks'][$i]['file'] = \App\Http\Controllers\AudioController::urlFor($absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$body = 'window.__PLAYLIST = ' . json_encode(
|
||||
$tracks,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
) . ';';
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, must-revalidate',
|
||||
'ETag' => $etag,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image
|
||||
* even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1
|
||||
|
||||
159
webapp/app/Http/Controllers/OnboardController.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\TwitchHelix;
|
||||
use App\Support\OldenEra;
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Web-based equivalent of `php artisan rig:loading` — guides the streamer
|
||||
* through the Project Loading manifest fields and pushes the channel
|
||||
* update to Twitch via Helix.
|
||||
*
|
||||
* Lives at /onboard so it's reachable from the same server as the
|
||||
* overlays (bookmark http://127.0.0.1:1118/onboard).
|
||||
*/
|
||||
class OnboardController extends Controller
|
||||
{
|
||||
public function show(RigData $rig, TwitchHelix $twitch, OldenEra $olden)
|
||||
{
|
||||
return view('onboard', [
|
||||
'manifest' => $rig->loading(),
|
||||
'telemetry' => $rig->telemetry(),
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
'twitchEnabled' => $twitch->enabled(),
|
||||
'oldenEra' => [
|
||||
'gameId' => OldenEra::GAME_ID,
|
||||
'assetBase' => OldenEra::URL,
|
||||
'catalog' => $olden->catalog(),
|
||||
'manifest' => $olden->manifest(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request, TwitchHelix $twitch): JsonResponse
|
||||
{
|
||||
if (!$twitch->enabled()) {
|
||||
return response()->json(['error' => 'twitch not configured'], 503);
|
||||
}
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (mb_strlen($q) < 2) {
|
||||
return response()->json(['matches' => []]);
|
||||
}
|
||||
try {
|
||||
// Use the HTTP API directly to get the top-N candidates, not just
|
||||
// the single best-match the Artisan command uses.
|
||||
$hits = $this->rawSearch($twitch, $q);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 502);
|
||||
}
|
||||
return response()->json(['matches' => $hits]);
|
||||
}
|
||||
|
||||
public function submit(Request $request, RigData $rig, TwitchHelix $twitch, OldenEra $olden): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'game' => 'required|string|max:200',
|
||||
'gameId' => 'nullable|string|max:32',
|
||||
'subtitle' => 'nullable|string|max:300',
|
||||
'countdownMin' => 'required|integer|min:0|max:120',
|
||||
'camera' => 'required|boolean',
|
||||
'microphone' => 'required|boolean',
|
||||
'pushTwitch' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$manifest = [
|
||||
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'game' => $data['game'],
|
||||
'gameId' => $data['gameId'] ?? null,
|
||||
'subtitle' => $data['subtitle'] ?: null,
|
||||
'countdownMin' => (int) $data['countdownMin'],
|
||||
'camera' => (bool) $data['camera'],
|
||||
'microphone' => (bool) $data['microphone'],
|
||||
];
|
||||
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
|
||||
);
|
||||
|
||||
$oldenPath = null;
|
||||
if (($manifest['gameId'] ?? null) === OldenEra::GAME_ID) {
|
||||
$factions = array_keys($olden->catalog());
|
||||
$extra = $request->validate([
|
||||
'olden.color' => 'required|in:red,blue',
|
||||
'olden.my.faction' => 'required|string|in:' . implode(',', $factions),
|
||||
'olden.my.hero' => 'required|string|max:120',
|
||||
'olden.enemy.faction' => 'required|string|in:' . implode(',', $factions),
|
||||
'olden.enemy.hero' => 'required|string|max:120',
|
||||
'olden.map' => 'nullable|string|max:200',
|
||||
])['olden'];
|
||||
|
||||
$oldenPath = $olden->writeManifest([
|
||||
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'gameId' => OldenEra::GAME_ID,
|
||||
'color' => $extra['color'],
|
||||
'my' => $extra['my'],
|
||||
'enemy' => $extra['enemy'],
|
||||
'map' => isset($extra['map']) && $extra['map'] !== '' ? $extra['map'] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$twitchStatus = null;
|
||||
if (!empty($data['pushTwitch']) && $twitch->enabled() && !empty($manifest['gameId'])) {
|
||||
try {
|
||||
$title = $manifest['subtitle'] ?: $manifest['game'];
|
||||
$twitch->setChannel($title, $manifest['gameId']);
|
||||
$twitchStatus = "ok: \"{$title}\" / {$manifest['game']}";
|
||||
} catch (Throwable $e) {
|
||||
$twitchStatus = 'failed: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'manifest' => $manifest,
|
||||
'twitch' => $twitchStatus,
|
||||
'path' => str_replace(base_path() . '/', '', $path),
|
||||
'oldenPath' => $oldenPath ? str_replace(base_path() . '/', '', $oldenPath) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw search/categories Helix call — same shape as TwitchHelix internally
|
||||
* but returns multiple candidates instead of the first exact match.
|
||||
*/
|
||||
private function rawSearch(TwitchHelix $twitch, string $q): array
|
||||
{
|
||||
$reflection = new \ReflectionClass($twitch);
|
||||
$http = $reflection->getProperty('http')->getValue($twitch);
|
||||
$headers = $reflection->getMethod('headers');
|
||||
$headers->setAccessible(true);
|
||||
|
||||
$res = $http->get('search/categories', [
|
||||
'headers' => $headers->invoke($twitch),
|
||||
'query' => ['query' => $q, 'first' => 10],
|
||||
]);
|
||||
$body = json_decode((string) $res->getBody(), true);
|
||||
$hits = $body['data'] ?? [];
|
||||
|
||||
// Sort so case-insensitive exact matches surface first.
|
||||
usort($hits, function ($a, $b) use ($q) {
|
||||
$ax = strcasecmp((string) ($a['name'] ?? ''), $q) === 0 ? 0 : 1;
|
||||
$bx = strcasecmp((string) ($b['name'] ?? ''), $q) === 0 ? 0 : 1;
|
||||
return $ax <=> $bx;
|
||||
});
|
||||
|
||||
return array_map(fn($r) => [
|
||||
'id' => (string) ($r['id'] ?? ''),
|
||||
'name' => (string) ($r['name'] ?? ''),
|
||||
'boxArt' => (string) ($r['box_art_url'] ?? ''),
|
||||
], array_slice($hits, 0, 10));
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,22 @@
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\OldenEra;
|
||||
use App\Support\RigData;
|
||||
|
||||
class GameController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
public function show(RigData $rig, OldenEra $olden)
|
||||
{
|
||||
$manifest = $rig->loading();
|
||||
$isOlden = ($manifest['gameId'] ?? null) === OldenEra::GAME_ID;
|
||||
$oldenManifest = $isOlden ? $olden->manifest() : [];
|
||||
|
||||
return view('overlays.game', [
|
||||
'manifest' => $rig->loading(),
|
||||
'manifest' => $manifest,
|
||||
'olden' => !empty($oldenManifest) ? $oldenManifest : null,
|
||||
'oldenAssetBase' => OldenEra::URL,
|
||||
'rigName' => (string) (config('rig.streamer_nick') ?: 'OPHI118'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ use RuntimeException;
|
||||
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
|
||||
* for use from `php artisan rig:loading`.
|
||||
*
|
||||
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the
|
||||
* Requires TWITCH_CLIENT_ID + TWITCH_ACCESS_TOKEN (user OAuth token with the
|
||||
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
|
||||
* Legacy TWITCH_TOKEN is still accepted as a fallback (see config/rig.php).
|
||||
*/
|
||||
class TwitchHelix
|
||||
{
|
||||
|
||||
78
webapp/app/Support/OldenEra.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Per-game extra manifest for Heroes of Might & Magic: Olden Era.
|
||||
*
|
||||
* `loading.json` keeps the generic stream metadata (game, subtitle, mic…).
|
||||
* Olden Era specifics — matchup factions, heroes, the player's team color,
|
||||
* map name — live in their own `olden-era-manifest.json` so the loading
|
||||
* schema doesn't grow a new key every time a game gets bespoke chrome.
|
||||
*/
|
||||
class OldenEra
|
||||
{
|
||||
/** Twitch Helix category id for "Heroes of Might & Magic: Olden Era". */
|
||||
public const GAME_ID = '1426873999';
|
||||
|
||||
/** Public URL prefix; resolved via the webapp/public/games symlink. */
|
||||
public const URL = '/games/olden-era';
|
||||
|
||||
private ?array $catalogCache = null;
|
||||
|
||||
/**
|
||||
* Faction → {logo URL, heroes list}. Built by globbing
|
||||
* resources/games/olden-era/{factions,heroes/<Faction>}/*.png.
|
||||
*/
|
||||
public function catalog(): array
|
||||
{
|
||||
if ($this->catalogCache !== null) return $this->catalogCache;
|
||||
|
||||
$root = resource_path('games/olden-era');
|
||||
$out = [];
|
||||
|
||||
foreach (glob($root . '/factions/*.png') ?: [] as $factionPng) {
|
||||
$faction = pathinfo($factionPng, PATHINFO_FILENAME);
|
||||
$heroes = [];
|
||||
foreach (glob($root . '/heroes/' . $faction . '/*.png') ?: [] as $heroPng) {
|
||||
$name = pathinfo($heroPng, PATHINFO_FILENAME);
|
||||
$heroes[] = [
|
||||
'name' => $name,
|
||||
'portrait' => self::URL . '/heroes/' . rawurlencode($faction) . '/' . rawurlencode($name) . '.png',
|
||||
];
|
||||
}
|
||||
usort($heroes, fn($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||
$out[$faction] = [
|
||||
'logo' => self::URL . '/factions/' . rawurlencode($faction) . '.png',
|
||||
'heroes' => $heroes,
|
||||
];
|
||||
}
|
||||
|
||||
ksort($out);
|
||||
return $this->catalogCache = $out;
|
||||
}
|
||||
|
||||
public function manifest(): array
|
||||
{
|
||||
$path = $this->manifestPath();
|
||||
if (!is_file($path)) return [];
|
||||
$parsed = json_decode((string) @file_get_contents($path), true);
|
||||
return is_array($parsed) ? $parsed : [];
|
||||
}
|
||||
|
||||
public function writeManifest(array $data): string
|
||||
{
|
||||
$path = $this->manifestPath();
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
|
||||
);
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function manifestPath(): string
|
||||
{
|
||||
return rtrim(config('rig.storage_data'), '/') . '/olden-era-manifest.json';
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
class RigData
|
||||
{
|
||||
private array $cache = [];
|
||||
private ?int $mpdSongCount = null;
|
||||
|
||||
public function loading(): array
|
||||
{
|
||||
@@ -23,17 +27,26 @@ class RigData
|
||||
]);
|
||||
}
|
||||
|
||||
public function playlist(): array
|
||||
{
|
||||
return $this->read('playlist.json', [
|
||||
'syncedAt' => null, 'sourceDirs' => [], 'trackCount' => 0, 'tracks' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track count from MPD (the live library, queried via `mpc stats`).
|
||||
* Returns 0 if mpc is missing or MPD is unreachable — callers treat
|
||||
* that as "empty library" and degrade their flavor text accordingly.
|
||||
*/
|
||||
public function playlistCount(): int
|
||||
{
|
||||
$p = $this->playlist();
|
||||
return (int) ($p['trackCount'] ?? count($p['tracks'] ?? []));
|
||||
if ($this->mpdSongCount !== null) return $this->mpdSongCount;
|
||||
|
||||
try {
|
||||
$proc = new Process(['mpc', 'stats']);
|
||||
$proc->setTimeout(2);
|
||||
$proc->run();
|
||||
if ($proc->isSuccessful() && preg_match('/^Songs:\s*(\d+)/m', $proc->getOutput(), $m)) {
|
||||
return $this->mpdSongCount = (int) $m[1];
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// fall through
|
||||
}
|
||||
return $this->mpdSongCount = 0;
|
||||
}
|
||||
|
||||
public function cmd(): array
|
||||
@@ -49,11 +62,6 @@ class RigData
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public function playlistPath(): string
|
||||
{
|
||||
return rtrim(config('rig.storage_data'), '/') . '/playlist.json';
|
||||
}
|
||||
|
||||
public function coverPath(): string
|
||||
{
|
||||
return (string) config('rig.cover_jpg');
|
||||
|
||||
@@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'cmd/*',
|
||||
'onboard',
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
||||
@@ -31,6 +31,7 @@ return [
|
||||
|---------------------------------------------------------------------------
|
||||
*/
|
||||
'rig_name' => env('RIG_NAME'),
|
||||
'streamer_nick' => env('STREAMER_NICK', 'OPHI118'),
|
||||
'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')),
|
||||
'obs_profile' => env('OBS_PROFILE', 'ophi118'),
|
||||
|
||||
@@ -51,8 +52,10 @@ return [
|
||||
|---------------------------------------------------------------------------
|
||||
*/
|
||||
'twitch' => [
|
||||
'client_id' => env('TWITCH_CLIENT_ID'),
|
||||
'token' => env('TWITCH_TOKEN'),
|
||||
'client_id' => env('TWITCH_CLIENT_ID'),
|
||||
// Name aligned with twitch-bot/.env (TWITCH_ACCESS_TOKEN); old TWITCH_TOKEN
|
||||
// kept as a fallback so pre-existing webapp/.env files still work.
|
||||
'token' => env('TWITCH_ACCESS_TOKEN', env('TWITCH_TOKEN')),
|
||||
'broadcaster_id' => env('TWITCH_BROADCASTER_ID'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -574,3 +574,116 @@ body.camera-hud-body {
|
||||
}
|
||||
.np-block .title { color: var(--ink); }
|
||||
.np-block .time { color: var(--ink-dim); }
|
||||
|
||||
/* Olden Era matchup — right-aligned on the status-bar's LIVE/game-name line. */
|
||||
.matchup {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.matchup .m-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.matchup .m-faction {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 0 6px rgba(7, 8, 13, 0.9));
|
||||
}
|
||||
.matchup .m-hero {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(232, 232, 224, 0.35);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.matchup .m-name {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.matchup .m-vs {
|
||||
color: var(--hud-dim);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.32em;
|
||||
font-size: 14px;
|
||||
}
|
||||
.matchup .m-on {
|
||||
color: var(--hud-dim);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.32em;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.matchup .m-map {
|
||||
color: var(--hud);
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.matchup-red .m-my .m-name {
|
||||
color: #ff6a5e;
|
||||
text-shadow: 0 0 6px rgba(230, 58, 46, 0.55);
|
||||
}
|
||||
.matchup-blue .m-my .m-name {
|
||||
color: #6ec5ff;
|
||||
text-shadow: 0 0 6px rgba(58, 166, 255, 0.55);
|
||||
}
|
||||
.matchup-red .m-my .m-hero { border-color: rgba(230, 58, 46, 0.7); }
|
||||
.matchup-blue .m-my .m-hero { border-color: rgba(58, 166, 255, 0.7); }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Webapp navigation breadcrumb (used by /, /scenes, /onboard).
|
||||
* Distinct from the in-scene .hud strip so overlays stay untouched.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
.crumb-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.10em;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 28px;
|
||||
border-bottom: 1px solid rgba(79, 210, 255, 0.25);
|
||||
}
|
||||
.crumb-bar > :nth-child(2) { justify-self: center; }
|
||||
.crumb-bar > :nth-child(3) { justify-self: end; }
|
||||
|
||||
.crumbs { display: flex; align-items: center; gap: 12px; }
|
||||
.crumbs .led {
|
||||
display: inline-block;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--onair);
|
||||
box-shadow: 0 0 8px var(--onair);
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.crumb { color: var(--hud); text-decoration: none; transition: color 120ms ease, text-shadow 120ms ease; }
|
||||
.crumb-root { color: var(--hud-dim); }
|
||||
.crumb-root:hover{ color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); }
|
||||
.crumb-here { color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); cursor: default; }
|
||||
.crumbs .sep { color: var(--hud-dim); opacity: 0.6; }
|
||||
|
||||
.crumb-ctx, .crumb-clock { color: var(--hud); }
|
||||
.crumb-ctx .dim, .crumb-clock .dim { color: var(--hud-dim); margin-right: 8px; }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* Webapp page shell — used by every non-overlay browser page
|
||||
* (/, /scenes, /onboard) so the crumb-bar and content sit at the
|
||||
* same viewport anchor on every page.
|
||||
* Overlays keep .overlay-body / .camera-hud-body for OBS sizing.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
body.page-shell {
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
padding: 32px 56px;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
1
webapp/public/favicon.ico
Symbolic link
@@ -0,0 +1 @@
|
||||
../resources/img/favicon.ico
|
||||
1
webapp/public/games
Symbolic link
@@ -0,0 +1 @@
|
||||
../resources/games
|
||||
1
webapp/resources/games/olden-era/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Files here belongs to Unfrozen and are copies from https://wiki.hoodedhorse.com/Heroes_of_Might_and_Magic_Olden_Era - used only for game streaming purposes.
|
||||
BIN
webapp/resources/games/olden-era/factions/Dungeon.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
webapp/resources/games/olden-era/factions/Grove.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
webapp/resources/games/olden-era/factions/Hive.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
webapp/resources/games/olden-era/factions/Necropolis.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
webapp/resources/games/olden-era/factions/Schism.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
webapp/resources/games/olden-era/factions/Temple.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Enatee.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Glastor.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Kieran.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Lodos.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Motley.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Mouaren.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Rhea.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Sister Deira.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Stinger.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Typhona.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Ylwari.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Aeliniel.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Alluring Sh'a.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Aunt Daliar.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Echolily.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Eith.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Elder Tss'kish.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Faleor.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Gingertail.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Glacia.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Halon.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Mreowa.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Octavia.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Old Pilgrim.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Sullie.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/The Minstrel.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Vatawna.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
webapp/resources/games/olden-era/heroes/Grove/Vim.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Fleu.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Goldentongue.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Groo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Khariseth.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Leira.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Lo.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Maelstrom.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Mila.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Niev.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Nor.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Oriax.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Pauper.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Tavi.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
webapp/resources/games/olden-era/heroes/Hive/Xirr.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Adahn.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Bulwark.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Ethric.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Funerella.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Kel'Ghul.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Laura.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Mag.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Marl.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Natalida.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Onkos.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Tarius.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
webapp/resources/games/olden-era/heroes/Necropolis/Zam.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
webapp/resources/games/olden-era/heroes/Schism/Blackhorn.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Schism/Dhüvri.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.8 KiB |