Files
OnePieceDle/src/routes/(game)/daily/+page.svelte
whidix 835163f5bb
All checks were successful
Build Docker Image / build (push) Successful in 1m10s
feat: add tried characters tracking and display in daily game profile
2026-03-16 21:39:44 +01:00

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>