381 lines
12 KiB
Svelte
381 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import YesterdayCharacter from '$lib/components/YesterdayCharacter.svelte';
|
|
import HintsPanel from '$lib/components/HintsPanel.svelte';
|
|
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
|
|
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
|
|
import WinPanel from '$lib/components/WinPanel.svelte';
|
|
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
|
|
import { t } from '$lib/i18n';
|
|
|
|
export let data;
|
|
|
|
let selectedCharacters: CharacterWithRelations[] = [];
|
|
let isLoaded = false;
|
|
let isGeckoMoriaWin = false;
|
|
|
|
let showOriginUnlock = false;
|
|
let showFruitUnlock = false;
|
|
let showAffiliationUnlock = false;
|
|
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
|
|
if (type === 'origin') {
|
|
clearUnlockTimeout(originUnlockTimeout);
|
|
showOriginUnlock = true;
|
|
originUnlockTimeout = setTimeout(() => {
|
|
showOriginUnlock = false;
|
|
originUnlockTimeout = null;
|
|
}, 600);
|
|
return;
|
|
}
|
|
|
|
if (type === 'fruit') {
|
|
clearUnlockTimeout(fruitUnlockTimeout);
|
|
showFruitUnlock = true;
|
|
fruitUnlockTimeout = setTimeout(() => {
|
|
showFruitUnlock = false;
|
|
fruitUnlockTimeout = null;
|
|
}, 600);
|
|
return;
|
|
}
|
|
|
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
|
showAffiliationUnlock = true;
|
|
affiliationUnlockTimeout = setTimeout(() => {
|
|
showAffiliationUnlock = false;
|
|
affiliationUnlockTimeout = null;
|
|
}, 600);
|
|
}
|
|
|
|
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
|
|
const nextOriginAvailable = nextGuessCount >= 5;
|
|
const nextFruitAvailable = nextGuessCount >= 10;
|
|
const nextAffiliationAvailable = nextGuessCount >= 15;
|
|
|
|
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
|
|
pulseUnlock('origin');
|
|
}
|
|
|
|
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
|
|
pulseUnlock('fruit');
|
|
}
|
|
|
|
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
|
|
pulseUnlock('affiliation');
|
|
}
|
|
|
|
if (!nextOriginAvailable) {
|
|
showOriginUnlock = false;
|
|
clearUnlockTimeout(originUnlockTimeout);
|
|
originUnlockTimeout = null;
|
|
}
|
|
|
|
if (!nextFruitAvailable) {
|
|
showFruitUnlock = false;
|
|
clearUnlockTimeout(fruitUnlockTimeout);
|
|
fruitUnlockTimeout = null;
|
|
}
|
|
|
|
if (!nextAffiliationAvailable) {
|
|
showAffiliationUnlock = false;
|
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
|
affiliationUnlockTimeout = null;
|
|
}
|
|
|
|
}
|
|
|
|
// Load from localStorage on mount
|
|
onMount(() => {
|
|
const storedDailyCharacterId = localStorage.getItem('dailyCurrentCharacterId');
|
|
const dailyCurrentCharacterId = dailyCharacter?.id;
|
|
|
|
// If the daily character has changed, clear the history
|
|
if (storedDailyCharacterId && storedDailyCharacterId !== dailyCurrentCharacterId) {
|
|
localStorage.removeItem('dailyCharacterHistory');
|
|
selectedCharacters = [];
|
|
} else {
|
|
// Load existing history if the character hasn't changed
|
|
const stored = localStorage.getItem('dailyCharacterHistory');
|
|
if (stored) {
|
|
try {
|
|
const storedIds = JSON.parse(stored);
|
|
// Reconstruct character objects from IDs
|
|
if (Array.isArray(storedIds)) {
|
|
selectedCharacters = storedIds
|
|
.map((id: string) => data.characters.find((c: CharacterWithRelations) => c.id === id))
|
|
.filter((c: CharacterWithRelations | undefined): c is CharacterWithRelations => !!c);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse stored history', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store the current daily character ID
|
|
if (dailyCurrentCharacterId) {
|
|
localStorage.setItem('dailyCurrentCharacterId', dailyCurrentCharacterId);
|
|
}
|
|
|
|
syncHintAvailability(0, selectedCharacters.length);
|
|
|
|
isLoaded = true;
|
|
});
|
|
|
|
onDestroy(() => {
|
|
clearUnlockTimeout(originUnlockTimeout);
|
|
clearUnlockTimeout(fruitUnlockTimeout);
|
|
clearUnlockTimeout(affiliationUnlockTimeout);
|
|
});
|
|
|
|
// Save to localStorage whenever selectedCharacters changes (only store IDs)
|
|
$: if (isLoaded && selectedCharacters) {
|
|
const ids = selectedCharacters.map(char => char.id);
|
|
localStorage.setItem('dailyCharacterHistory', JSON.stringify(ids));
|
|
}
|
|
|
|
$: characters = data.characters || [];
|
|
$: dailyCharacter = data.dailyCharacter;
|
|
$: yesterdayCharacter = data.yesterdayCharacter;
|
|
$: columnVisibility = data.columnVisibility || {};
|
|
$: hasWon = selectedCharacters.some(char => char.id === dailyCharacter.id);
|
|
|
|
function handleCharacterSelect(character: CharacterWithRelations) {
|
|
selectCharacter(character);
|
|
}
|
|
|
|
function selectCharacter(character: CharacterWithRelations) {
|
|
const previousGuessCount = selectedCharacters.length;
|
|
selectedCharacters = [character, ...selectedCharacters];
|
|
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
|
|
|
|
// Check if player won
|
|
if (character.id === dailyCharacter.id) {
|
|
const triedCharacterIds = selectedCharacters.map(selected => selected.id);
|
|
|
|
// Send request to record win in database
|
|
fetch('/daily', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
characterId: dailyCharacter.id,
|
|
tryCount: selectedCharacters.length,
|
|
triedCharacterIds
|
|
})
|
|
}).catch(err => console.error('Failed to record win:', err));
|
|
|
|
// Check if it's gecko_moria for special animation
|
|
if (dailyCharacter.id === 'gecko_moria_gecko_moria') {
|
|
isGeckoMoriaWin = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resetHistory() {
|
|
const previousGuessCount = selectedCharacters.length;
|
|
selectedCharacters = [];
|
|
syncHintAvailability(previousGuessCount, 0);
|
|
localStorage.removeItem('dailyCharacterHistory');
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{$t.game.daily.metaTitle}</title>
|
|
<style>
|
|
@keyframes shadow-pulse {
|
|
0% {
|
|
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1), inset 0 0 50px rgba(0, 0, 0, 0.7);
|
|
opacity: 0.9;
|
|
}
|
|
100% {
|
|
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
@keyframes moria-chaos {
|
|
0% {
|
|
transform: rotate(0deg) scale(1);
|
|
filter: invert(0%) hue-rotate(0deg) blur(0px);
|
|
}
|
|
10% {
|
|
transform: rotate(15deg) scale(1.02);
|
|
filter: invert(30%) hue-rotate(45deg) blur(2px);
|
|
}
|
|
20% {
|
|
transform: rotate(-10deg) scale(0.98);
|
|
filter: invert(60%) hue-rotate(90deg) blur(1px);
|
|
}
|
|
30% {
|
|
transform: rotate(25deg) scale(1.05);
|
|
filter: invert(100%) hue-rotate(180deg) blur(3px);
|
|
}
|
|
40% {
|
|
transform: rotate(-20deg) scale(0.95);
|
|
filter: invert(80%) hue-rotate(270deg) blur(2px);
|
|
}
|
|
50% {
|
|
transform: rotate(30deg) scale(1.08);
|
|
filter: invert(100%) hue-rotate(0deg) blur(4px);
|
|
}
|
|
60% {
|
|
transform: rotate(-25deg) scale(0.92);
|
|
filter: invert(70%) hue-rotate(90deg) blur(2px);
|
|
}
|
|
70% {
|
|
transform: rotate(20deg) scale(1.03);
|
|
filter: invert(50%) hue-rotate(180deg) blur(3px);
|
|
}
|
|
80% {
|
|
transform: rotate(-15deg) scale(1.01);
|
|
filter: invert(80%) hue-rotate(270deg) blur(1px);
|
|
}
|
|
100% {
|
|
transform: rotate(360deg) scale(1);
|
|
filter: invert(0%) hue-rotate(360deg) blur(0px);
|
|
}
|
|
}
|
|
.gecko-moria-effect {
|
|
animation: shadow-pulse 1.5s ease-in-out infinite;
|
|
}
|
|
.moria-screen-chaos {
|
|
animation: moria-chaos 4s ease-in-out;
|
|
}
|
|
</style>
|
|
</svelte:head>
|
|
|
|
<main
|
|
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
|
|
>
|
|
<div class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
|
|
<div class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"></div>
|
|
|
|
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
|
|
<header class="flex flex-col items-start gap-6 w-full">
|
|
<div class="flex w-full items-center justify-between gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
|
|
{$t.game.daily.title}
|
|
</h1>
|
|
<p class="mt-2 text-sm text-amber-300">
|
|
{data.winCount} {data.winCount > 1 ? $t.game.daily.winsPeoplePlural : $t.game.daily.winsPeopleSingular} {data.winCount > 1 ? $t.game.daily.winsVerbPlural : $t.game.daily.winsVerbSingular} {$t.game.daily.winsSuffix}
|
|
</p>
|
|
</div>
|
|
{#if hasWon}
|
|
<button
|
|
class="rounded-full border border-amber-200/40 bg-transparent px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-200 hover:text-amber-50"
|
|
onclick={resetHistory}
|
|
>
|
|
{$t.game.daily.reset}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
<p class="max-w-2xl text-base text-slate-200 sm:text-lg">
|
|
{$t.game.daily.description}
|
|
</p>
|
|
</header>
|
|
|
|
<section class="mt-10 grid gap-6">
|
|
{#if selectedCharacters.length > 0 && !hasWon}
|
|
<HintsPanel
|
|
{dailyCharacter}
|
|
{selectedCharacters}
|
|
{showOriginUnlock}
|
|
{showFruitUnlock}
|
|
{showAffiliationUnlock}
|
|
/>
|
|
{/if}
|
|
|
|
{#if hasWon}
|
|
<WinPanel
|
|
selectedCharacter={dailyCharacter}
|
|
{selectedCharacters}
|
|
{isGeckoMoriaWin}
|
|
/>
|
|
{:else}
|
|
<CharacterSearchInput
|
|
{characters}
|
|
{selectedCharacters}
|
|
onSelect={handleCharacterSelect}
|
|
/>
|
|
{/if}
|
|
</section>
|
|
|
|
|
|
{#if hasWon && data.friendsTodayResults && data.friendsTodayResults.length > 0}
|
|
<section class="mt-6 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
|
|
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-amber-100 text-center">{$t.game.daily.friendsToday}</p>
|
|
<div class="mt-4 space-y-2">
|
|
{#each data.friendsTodayResults as friendResult (friendResult.userId)}
|
|
<div class="rounded-lg border border-white/10 bg-slate-950/50 px-4 py-3">
|
|
<div class="flex items-center justify-between gap-4">
|
|
<div class="flex items-center gap-3">
|
|
{#if friendResult.image}
|
|
<img
|
|
src={friendResult.image}
|
|
alt={friendResult.name}
|
|
class="h-8 w-8 rounded-full border border-white/20 object-cover"
|
|
/>
|
|
{:else}
|
|
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-amber-300/20 text-xs font-semibold text-amber-100">
|
|
{friendResult.name?.charAt(0).toUpperCase() || 'U'}
|
|
</div>
|
|
{/if}
|
|
<p class="text-sm font-semibold text-slate-100">{friendResult.name}</p>
|
|
</div>
|
|
<p class="text-sm text-amber-300">
|
|
{friendResult.tryCount} {friendResult.tryCount > 1 ? $t.game.daily.friendTryPlural : $t.game.daily.friendTrySingular}
|
|
</p>
|
|
</div>
|
|
<div class="mt-3 border-t border-white/10 pt-2">
|
|
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">
|
|
{$t.game.daily.friendsTriedCharacters}
|
|
</p>
|
|
{#if friendResult.triedCharacters && friendResult.triedCharacters.length > 0}
|
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
{#each friendResult.triedCharacters as triedCharacter (triedCharacter.id)}
|
|
<span class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-xs text-slate-200">
|
|
{#if triedCharacter.pictureUrl}
|
|
<img
|
|
src={triedCharacter.pictureUrl}
|
|
alt={triedCharacter.name}
|
|
class="h-4 w-4 rounded-full object-cover"
|
|
/>
|
|
{/if}
|
|
{triedCharacter.name}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<p class="mt-1 text-xs text-slate-500">{$t.game.daily.friendsNoTriedCharacters}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<GuessHistoryTable
|
|
{selectedCharacters}
|
|
{dailyCharacter}
|
|
{columnVisibility}
|
|
/>
|
|
|
|
<YesterdayCharacter {yesterdayCharacter} />
|
|
</div>
|
|
</main>
|