add everything

This commit is contained in:
Koolant
2026-04-17 12:29:41 -04:00
commit 4c1cfe6847
437 changed files with 11939 additions and 0 deletions

376
js/pages/Roulette.js Normal file
View 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">#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);
},
},
};