add everything
This commit is contained in:
376
js/pages/Roulette.js
Normal file
376
js/pages/Roulette.js
Normal file
@@ -0,0 +1,376 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user