376 lines
17 KiB
JavaScript
376 lines
17 KiB
JavaScript
import { fetchList } from "../content.js";
|
||
import { getThumbnailFromId, getYoutubeIdFromUrl, shuffle } from "../util.js";
|
||
|
||
import Spinner from "../components/Spinner.js";
|
||
import Btn from "../components/Btn.js";
|
||
|
||
export default {
|
||
components: { Spinner, Btn },
|
||
template: `
|
||
<main v-if="loading">
|
||
<Spinner></Spinner>
|
||
</main>
|
||
<main v-else class="page-roulette">
|
||
|
||
<!-- ══════════════════════════════
|
||
SIDEBAR
|
||
══════════════════════════════ -->
|
||
<div class="sidebar surface">
|
||
<div class="sidebar-header">
|
||
<h2 class="sidebar-title">Demon Roulette</h2>
|
||
<p class="sidebar-credit">
|
||
Based on the <a href="https://matcool.github.io/extreme-demon-roulette/" target="_blank">Extreme Demon Roulette</a> by matcool.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Progress summary (only when active) -->
|
||
<div class="sidebar-progress" v-if="levels.length > 0">
|
||
<div class="sp-row">
|
||
<span class="sp-label">Progress</span>
|
||
<span class="sp-val">{{ progression.length }} / {{ levels.length }}</span>
|
||
</div>
|
||
<div class="sp-track">
|
||
<div class="sp-fill" :style="{ width: progressPct + '%' }"></div>
|
||
</div>
|
||
<div class="sp-row">
|
||
<span class="sp-label">Highest %</span>
|
||
<span class="sp-val sp-pct">{{ currentPercentage }}%</span>
|
||
</div>
|
||
<div class="sp-row" v-if="streak > 1">
|
||
<span class="sp-label">Streak 🔥</span>
|
||
<span class="sp-val sp-streak">{{ streak }} levels</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Options -->
|
||
<form class="options">
|
||
<p class="options-heading">Include</p>
|
||
<div class="check">
|
||
<input type="checkbox" id="main" v-model="useMainList">
|
||
<label for="main">Main List <span class="check-sub">#1–75</span></label>
|
||
</div>
|
||
<div class="check">
|
||
<input type="checkbox" id="extended" v-model="useExtendedList">
|
||
<label for="extended">Extended List <span class="check-sub">#76–150</span></label>
|
||
</div>
|
||
<div class="check">
|
||
<input type="checkbox" id="legacy" v-model="useLegacyList">
|
||
<label for="legacy">Legacy List <span class="check-sub">#151+</span></label>
|
||
</div>
|
||
<Btn @click.native.prevent="onStart" class="btn-start">
|
||
{{ levels.length === 0 ? 'Start Roulette' : 'Restart' }}
|
||
</Btn>
|
||
</form>
|
||
|
||
<!-- Save/load -->
|
||
<div class="save">
|
||
<p class="options-heading">Save / Load</p>
|
||
<p class="sidebar-credit">Progress saves automatically.</p>
|
||
<div class="btns">
|
||
<Btn @click.native.prevent="onImport">Import</Btn>
|
||
<Btn :disabled="!isActive" @click.native.prevent="onExport">Export</Btn>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════
|
||
LEVEL FEED
|
||
══════════════════════════════ -->
|
||
<section class="levels-container surface">
|
||
<div class="levels">
|
||
<template v-if="levels.length > 0">
|
||
|
||
<!-- Empty state before starting -->
|
||
<div class="roulette-empty" v-if="progression.length === 0 && !hasCompleted">
|
||
<p class="roulette-empty-icon">🎲</p>
|
||
<p class="roulette-empty-text">Your first level is waiting…</p>
|
||
</div>
|
||
|
||
<!-- Completed levels -->
|
||
<div
|
||
class="level level-done"
|
||
v-for="(level, i) in levels.slice(0, progression.length)"
|
||
:key="'done-' + i"
|
||
>
|
||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"></div>
|
||
<div class="level-tint level-tint-done"></div>
|
||
<a :href="level.video" target="_blank" class="video">
|
||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(level.video))" alt="">
|
||
<div class="video-play">▶</div>
|
||
</a>
|
||
<div class="meta">
|
||
<span class="meta-rank">#{{ level.rank }}</span>
|
||
<h2 class="meta-name">{{ level.name }}</h2>
|
||
<span class="meta-pct meta-pct-done">{{ progression[i] }}% ✓</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Current level -->
|
||
<div class="level level-current" v-if="!hasCompleted && currentLevel">
|
||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${currentLevel.name}.png')\` }"></div>
|
||
<div class="level-tint level-tint-current"></div>
|
||
<a :href="currentLevel.video" target="_blank" class="video">
|
||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(currentLevel.video))" alt="">
|
||
<div class="video-play">▶</div>
|
||
</a>
|
||
<div class="meta">
|
||
<span class="meta-rank">#{{ currentLevel.rank }}</span>
|
||
<h2 class="meta-name">{{ currentLevel.name }}</h2>
|
||
<span class="meta-id">{{ currentLevel.id }}</span>
|
||
<span class="meta-badge">Current</span>
|
||
</div>
|
||
<form class="actions" v-if="!givenUp">
|
||
<input
|
||
type="number"
|
||
v-model="percentage"
|
||
:placeholder="placeholder"
|
||
:min="currentPercentage + 1"
|
||
max="100"
|
||
class="pct-input"
|
||
/>
|
||
<Btn @click.native.prevent="onDone" class="btn-done">Done</Btn>
|
||
<Btn @click.native.prevent="onGiveUp" class="btn-giveup">Give Up</Btn>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<div v-if="givenUp || hasCompleted" class="results">
|
||
<div class="results-icon">{{ hasCompleted ? '🏆' : '💀' }}</div>
|
||
<h1 class="results-title">{{ hasCompleted ? 'Completed!' : 'Game Over' }}</h1>
|
||
<div class="results-stats">
|
||
<div class="rstat">
|
||
<span class="rstat-val">{{ progression.length }}</span>
|
||
<span class="rstat-key">Levels</span>
|
||
</div>
|
||
<div class="rstat">
|
||
<span class="rstat-val">{{ currentPercentage }}%</span>
|
||
<span class="rstat-key">Best %</span>
|
||
</div>
|
||
<div class="rstat">
|
||
<span class="rstat-val">{{ streak }}</span>
|
||
<span class="rstat-key">Peak Streak</span>
|
||
</div>
|
||
</div>
|
||
<Btn
|
||
v-if="currentPercentage < 99 && !hasCompleted"
|
||
@click.native.prevent="showRemaining = true"
|
||
class="btn-show-remaining"
|
||
>Show remaining levels</Btn>
|
||
</div>
|
||
|
||
<!-- Remaining levels -->
|
||
<template v-if="givenUp && showRemaining">
|
||
<div
|
||
class="level level-remaining"
|
||
v-for="(level, i) in levels.slice(progression.length + 1, levels.length - currentPercentage + progression.length)"
|
||
:key="'rem-' + i"
|
||
>
|
||
<div class="level-bg" :style="{ backgroundImage: \`url('/assets/levels/\${level.name}.png')\` }"></div>
|
||
<div class="level-tint level-tint-remaining"></div>
|
||
<a :href="level.video" target="_blank" class="video">
|
||
<img :src="getThumbnailFromId(getYoutubeIdFromUrl(level.video))" alt="">
|
||
<div class="video-play">▶</div>
|
||
</a>
|
||
<div class="meta">
|
||
<span class="meta-rank">#{{ level.rank }}</span>
|
||
<h2 class="meta-name">{{ level.name }}</h2>
|
||
<span class="meta-pct meta-pct-missed">Would need {{ currentPercentage + 2 + i }}%</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
</template>
|
||
|
||
<!-- Nothing started yet -->
|
||
<div class="roulette-start-prompt" v-if="levels.length === 0">
|
||
<p class="roulette-start-icon">🎮</p>
|
||
<p class="roulette-start-text">Pick your lists and hit Start to begin.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ══════════════════════════════
|
||
TOASTS
|
||
══════════════════════════════ -->
|
||
<div class="toasts-container">
|
||
<transition-group name="toast" tag="div" class="toasts">
|
||
<div v-for="(toast, i) in toasts" :key="i" class="toast">
|
||
<span class="toast-icon">⚠️</span>
|
||
<p>{{ toast }}</p>
|
||
</div>
|
||
</transition-group>
|
||
</div>
|
||
</main>
|
||
`,
|
||
|
||
data: () => ({
|
||
loading: false,
|
||
levels: [],
|
||
progression: [],
|
||
percentage: undefined,
|
||
givenUp: false,
|
||
showRemaining: false,
|
||
useMainList: true,
|
||
useExtendedList: true,
|
||
useLegacyList: false,
|
||
toasts: [],
|
||
fileInput: undefined,
|
||
peakStreak: 0,
|
||
}),
|
||
|
||
mounted() {
|
||
this.fileInput = document.createElement("input");
|
||
this.fileInput.type = "file";
|
||
this.fileInput.multiple = false;
|
||
this.fileInput.accept = ".json";
|
||
this.fileInput.addEventListener("change", this.onImportUpload);
|
||
|
||
const roulette = JSON.parse(localStorage.getItem("roulette"));
|
||
if (!roulette) return;
|
||
this.levels = roulette.levels;
|
||
this.progression = roulette.progression;
|
||
this.peakStreak = roulette.peakStreak || 0;
|
||
},
|
||
|
||
computed: {
|
||
currentLevel() {
|
||
return this.levels[this.progression.length];
|
||
},
|
||
currentPercentage() {
|
||
return this.progression[this.progression.length - 1] || 0;
|
||
},
|
||
placeholder() {
|
||
return `At least ${this.currentPercentage + 1}%`;
|
||
},
|
||
hasCompleted() {
|
||
return (
|
||
this.progression[this.progression.length - 1] >= 100 ||
|
||
this.progression.length === this.levels.length
|
||
);
|
||
},
|
||
isActive() {
|
||
return (
|
||
this.progression.length > 0 &&
|
||
!this.givenUp &&
|
||
!this.hasCompleted
|
||
);
|
||
},
|
||
// 0–100 based on highest % reached, not levels completed
|
||
progressPct() {
|
||
return this.currentPercentage;
|
||
},
|
||
// current consecutive streak of completed levels
|
||
streak() {
|
||
if (this.givenUp || this.hasCompleted) return this.peakStreak;
|
||
return this.progression.length;
|
||
},
|
||
},
|
||
|
||
methods: {
|
||
shuffle,
|
||
getThumbnailFromId,
|
||
getYoutubeIdFromUrl,
|
||
|
||
async onStart() {
|
||
if (this.isActive) {
|
||
this.showToast("Give up before starting a new roulette.");
|
||
return;
|
||
}
|
||
if (!this.useMainList && !this.useExtendedList && !this.useLegacyList) return;
|
||
|
||
this.loading = true;
|
||
|
||
const fullListWithUnverifiedLevels = await fetchList();
|
||
const fullList = fullListWithUnverifiedLevels.filter(([_, rank]) => rank !== null);
|
||
|
||
if (fullList.filter(([err]) => err).length > 0) {
|
||
this.loading = false;
|
||
this.showToast("List is currently broken. Wait until it's fixed to start a roulette.");
|
||
return;
|
||
}
|
||
|
||
const fullListMapped = fullList.map(([_, rank, lvl], i) => ({
|
||
rank: i + 1,
|
||
id: lvl.id,
|
||
name: lvl.name,
|
||
video: lvl.verification,
|
||
}));
|
||
|
||
const list = [];
|
||
if (this.useMainList) list.push(...fullListMapped.slice(0, 75));
|
||
if (this.useExtendedList) list.push(...fullListMapped.slice(75, 150));
|
||
if (this.useLegacyList) list.push(...fullListMapped.slice(150));
|
||
|
||
this.levels = shuffle(list).slice(0, 100);
|
||
this.showRemaining = false;
|
||
this.givenUp = false;
|
||
this.progression = [];
|
||
this.percentage = undefined;
|
||
this.peakStreak = 0;
|
||
this.loading = false;
|
||
},
|
||
|
||
save() {
|
||
localStorage.setItem("roulette", JSON.stringify({
|
||
levels: this.levels,
|
||
progression: this.progression,
|
||
peakStreak: this.peakStreak,
|
||
}));
|
||
},
|
||
|
||
onDone() {
|
||
if (!this.percentage) return;
|
||
if (this.percentage <= this.currentPercentage || this.percentage > 100) {
|
||
this.showToast("Invalid percentage.");
|
||
return;
|
||
}
|
||
this.progression.push(this.percentage);
|
||
this.peakStreak = Math.max(this.peakStreak, this.progression.length);
|
||
this.percentage = undefined;
|
||
this.save();
|
||
},
|
||
|
||
onGiveUp() {
|
||
this.givenUp = true;
|
||
localStorage.removeItem("roulette");
|
||
},
|
||
|
||
onImport() {
|
||
if (this.isActive && !window.confirm("This will overwrite the currently running roulette. Continue?")) return;
|
||
this.fileInput.showPicker();
|
||
},
|
||
|
||
async onImportUpload() {
|
||
if (this.fileInput.files.length === 0) return;
|
||
const file = this.fileInput.files[0];
|
||
if (file.type !== "application/json") { this.showToast("Invalid file."); return; }
|
||
try {
|
||
const roulette = JSON.parse(await file.text());
|
||
if (!roulette.levels || !roulette.progression) { this.showToast("Invalid file."); return; }
|
||
this.levels = roulette.levels;
|
||
this.progression = roulette.progression;
|
||
this.peakStreak = roulette.peakStreak || 0;
|
||
this.save();
|
||
this.givenUp = false;
|
||
this.showRemaining = false;
|
||
this.percentage = undefined;
|
||
} catch {
|
||
this.showToast("Invalid file.");
|
||
}
|
||
},
|
||
|
||
onExport() {
|
||
const file = new Blob([JSON.stringify({ levels: this.levels, progression: this.progression, peakStreak: this.peakStreak })], { type: "application/json" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(file);
|
||
a.download = "tsl_roulette";
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
},
|
||
|
||
showToast(msg) {
|
||
this.toasts.push(msg);
|
||
setTimeout(() => this.toasts.shift(), 3000);
|
||
},
|
||
},
|
||
}; |