Files
TheEvilList/js/pages/Roulette.js
2026-04-17 12:29:41 -04:00

376 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">#175</span></label>
</div>
<div class="check">
<input type="checkbox" id="extended" v-model="useExtendedList">
<label for="extended">Extended List <span class="check-sub">#76150</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">&#9654;</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">&#9654;</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">&#9654;</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
);
},
// 0100 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);
},
},
};