mirror of
https://github.com/isledecomp/isle.pizza.git
synced 2026-05-05 20:13:57 +00:00
* Add multiplayer, cloud sync, crash reporting, scene player, and memories features * Fix multiplayer overlay showing "Waiting for ..." with no names * Fix OGL link in README * Update README with architecture, backend setup, environment variables, and CI docs * Fix save editor showing wrong name for orphaned save slots Players.gsi could fall out of sync with save files during cloud sync because the saveSlotWritten event only tracked the slot file and History.gsi for incremental upload, not Players.gsi. This caused slots without a matching Players.gsi entry to display the first player's name due to a fallback to index 0. - Track Players.gsi in saveSlotWritten handler for incremental uploads - Remove broken fallback to player index 0 in name resolution - Hide save slots with no Players.gsi entry from the save editor UI
356 lines
11 KiB
Svelte
356 lines
11 KiB
Svelte
<script>
|
|
import { flip } from 'svelte/animate';
|
|
import { CharacterNameMap } from '../../core/savegame/actorConstants.js';
|
|
import { AnimationTitles } from './animationCatalog.js';
|
|
import { memoryUnlocks } from '../../stores.js';
|
|
|
|
export let animations = [];
|
|
export let currentInterest = null;
|
|
export let pendingInterest = -1;
|
|
export let onToggleInterest = () => {};
|
|
export let isMobile = false;
|
|
export let scrollContainer = null;
|
|
|
|
// Build sort order from CharacterNameMap keys (same order as g_characters[])
|
|
const charSortOrder = Object.fromEntries(Object.keys(CharacterNameMap).map((name, i) => [name, i]));
|
|
|
|
function missingCount(anim) {
|
|
return anim.slots.filter(s => !s.filled).length;
|
|
}
|
|
|
|
// Lowest g_characters index across all slot names (for grouping by character)
|
|
function charOrder(anim) {
|
|
let best = 9999;
|
|
for (const slot of anim.slots) {
|
|
for (const name of slot.names) {
|
|
const order = charSortOrder[name];
|
|
if (order !== undefined && order < best) best = order;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function hasActiveSession(anim) {
|
|
return anim.sessionState > 0; // gathering, countdown, or playing
|
|
}
|
|
|
|
$: sorted = [...animations].sort((a, b) => {
|
|
// Active sessions (gathering/countdown/playing) always on top
|
|
const aActive = hasActiveSession(a), bActive = hasActiveSession(b);
|
|
if (aActive !== bActive) return aActive ? -1 : 1;
|
|
if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
|
|
const ma = missingCount(a), mb = missingCount(b);
|
|
if (ma !== mb) return ma - mb;
|
|
if (a.slots.length !== b.slots.length) return a.slots.length - b.slots.length;
|
|
return charOrder(a) - charOrder(b);
|
|
});
|
|
|
|
function formatNeeds(slots) {
|
|
const named = [];
|
|
let anyCount = 0;
|
|
for (const s of slots) {
|
|
if (s.filled) continue;
|
|
if (s.names.length === 1 && s.names[0] === 'any') {
|
|
anyCount++;
|
|
} else {
|
|
named.push(s.names.map(n => CharacterNameMap[n] || n).join(' or '));
|
|
}
|
|
}
|
|
if (anyCount) named.push(anyCount === 1 ? '+1 player' : `+${anyCount} players`);
|
|
return named.join(', ');
|
|
}
|
|
|
|
function formatWaiting(anim) {
|
|
const unfilled = anim.slots.filter(s => !s.filled);
|
|
const parts = [];
|
|
let anyCount = 0;
|
|
for (const slot of unfilled) {
|
|
if (slot.names.length === 1 && slot.names[0] === 'any') {
|
|
anyCount++;
|
|
} else {
|
|
parts.push(slot.names.map(n => CharacterNameMap[n] || n).join(' or '));
|
|
}
|
|
}
|
|
if (anyCount) parts.push(anyCount === 1 ? '1 more player' : `${anyCount} more players`);
|
|
if (!parts.length) return 'Starting...';
|
|
return `Waiting for ${parts.join(', ')}...`;
|
|
}
|
|
|
|
function isInterested(anim) {
|
|
return currentInterest === anim.animIndex || pendingInterest === anim.animIndex;
|
|
}
|
|
|
|
function isClickDisabled(anim) {
|
|
if (anim.sessionState === 3) return true; // playing
|
|
if (anim.localInSession) return false; // can always cancel own interest
|
|
if (anim.sessionState >= 1 && !anim.canJoin) return true; // no available slot
|
|
return false;
|
|
}
|
|
|
|
let listEl;
|
|
|
|
function handleClick(anim) {
|
|
const joining = !isInterested(anim);
|
|
onToggleInterest(anim.animIndex);
|
|
// Scroll to top when joining so the user follows the item as it moves up
|
|
const el = scrollContainer || listEl;
|
|
if (joining && el) {
|
|
el.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
// Refocus the game canvas so arrow keys don't scroll the list
|
|
document.getElementById('canvas')?.focus();
|
|
}
|
|
</script>
|
|
|
|
<div class="anim-panel" onfocusin={() => document.getElementById('canvas')?.focus()}>
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
<div class="anim-list" tabindex="-1" bind:this={listEl}>
|
|
{#each sorted as anim (anim.animIndex)}
|
|
<button class="anim-row" tabindex="-1" animate:flip={{ duration: 250 }}
|
|
class:eligible={anim.eligible && anim.sessionState === 0}
|
|
class:interested={isInterested(anim)}
|
|
class:dimmed={!anim.eligible && !anim.atLocation && anim.sessionState === 0}
|
|
class:gathering={anim.sessionState === 1}
|
|
class:countdown={anim.sessionState === 2}
|
|
class:playing={anim.sessionState === 3}
|
|
class:joinable={anim.sessionState >= 1 && anim.canJoin && !anim.localInSession}
|
|
disabled={isClickDisabled(anim)}
|
|
onclick={() => handleClick(anim)}>
|
|
<div class="row-left">
|
|
<span class="anim-name-row">
|
|
<span class="anim-name">{AnimationTitles[anim.animIndex] || anim.name}</span>
|
|
{#if $memoryUnlocks.has(anim.animIndex)}<span class="unlocked-mark" title="Memory unlocked">✓</span>{/if}
|
|
</span>
|
|
{#if anim.sessionState === 3 && anim.localInSession}
|
|
<span class="anim-sub playing-text">Playing...</span>
|
|
{:else if anim.sessionState === 2 && anim.localInSession}
|
|
<span class="anim-sub countdown-text">Starting...</span>
|
|
{:else if anim.sessionState === 1 && anim.localInSession}
|
|
<span class="anim-sub gathering-text">{formatWaiting(anim)}</span>
|
|
{:else if anim.sessionState >= 1 && !anim.canJoin}
|
|
<span class="anim-sub full-text">Roles filled</span>
|
|
{:else if anim.sessionState >= 1 && anim.canJoin}
|
|
<span class="anim-sub join-text">Join!</span>
|
|
{:else if anim.eligible}
|
|
<span class="anim-sub ready-text">{isMobile ? 'Tap to start' : 'Click to start'}</span>
|
|
{:else if anim.atLocation}
|
|
<span class="anim-sub needs-text">{formatNeeds(anim.slots)}</span>
|
|
{/if}
|
|
</div>
|
|
<span class="slot-dots">
|
|
{#each [...anim.slots].sort((a, b) => (b.filled ? 1 : 0) - (a.filled ? 1 : 0)) as slot}
|
|
<span class="dot" class:filled={slot.filled}></span>
|
|
{/each}
|
|
</span>
|
|
</button>
|
|
{:else}
|
|
<div class="empty">Explore the island to discover scenes</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.anim-panel {
|
|
width: 300px;
|
|
font-family: Arial, sans-serif;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.anim-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
touch-action: pan-y;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
|
|
}
|
|
|
|
.anim-list::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
.anim-list::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.anim-list::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.anim-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
padding: 7px 8px;
|
|
background: none;
|
|
border: none;
|
|
border-left: 3px solid transparent;
|
|
cursor: pointer;
|
|
transition: background 0.12s ease, border-left-color 0.2s ease, opacity 0.2s ease;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.anim-row + .anim-row {
|
|
border-top: 1px solid var(--color-surface-subtle);
|
|
}
|
|
|
|
@media (hover: hover) {
|
|
.anim-row:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
}
|
|
|
|
.anim-row.eligible {
|
|
border-left-color: rgba(76, 175, 80, 0.5);
|
|
}
|
|
|
|
.anim-row.interested {
|
|
border-left-color: var(--color-primary);
|
|
background: rgba(255, 215, 0, 0.06);
|
|
}
|
|
|
|
.anim-row.dimmed {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.anim-row.gathering {
|
|
border-left-color: rgba(255, 193, 7, 0.6);
|
|
}
|
|
|
|
.anim-row.countdown {
|
|
border-left-color: rgba(255, 152, 0, 0.7);
|
|
}
|
|
|
|
.anim-row.playing {
|
|
border-left-color: rgba(0, 188, 212, 0.7);
|
|
opacity: 0.7;
|
|
cursor: default;
|
|
}
|
|
|
|
.anim-row.joinable {
|
|
border-left-color: rgba(100, 181, 246, 0.7);
|
|
background: rgba(100, 181, 246, 0.06);
|
|
animation: joinable-pulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
.anim-row:disabled {
|
|
cursor: default;
|
|
}
|
|
|
|
.row-left {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.anim-name-row {
|
|
display: flex;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
.anim-name {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
}
|
|
|
|
.unlocked-mark {
|
|
color: rgba(76, 175, 80, 0.7);
|
|
font-size: 10px;
|
|
margin-left: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.anim-sub {
|
|
font-size: 10px;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.ready-text {
|
|
color: rgba(76, 175, 80, 0.85);
|
|
}
|
|
|
|
.needs-text {
|
|
color: var(--color-text-muted);
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.gathering-text {
|
|
color: rgba(255, 193, 7, 0.85);
|
|
}
|
|
|
|
.countdown-text {
|
|
color: rgba(255, 152, 0, 0.9);
|
|
animation: pulse 1s ease-in-out infinite;
|
|
}
|
|
|
|
.join-text {
|
|
color: rgba(100, 181, 246, 0.95);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.full-text {
|
|
color: rgba(255, 107, 107, 0.6);
|
|
}
|
|
|
|
.playing-text {
|
|
color: rgba(0, 188, 212, 0.85);
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
@keyframes joinable-pulse {
|
|
0%, 100% { background: rgba(100, 181, 246, 0.04); }
|
|
50% { background: rgba(100, 181, 246, 0.1); }
|
|
}
|
|
|
|
.slot-dots {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3px;
|
|
flex-shrink: 0;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
}
|
|
|
|
.dot.filled {
|
|
background: rgba(76, 175, 80, 0.7);
|
|
border-color: rgba(76, 175, 80, 0.85);
|
|
}
|
|
|
|
.interested .dot.filled {
|
|
background: rgba(255, 215, 0, 0.7);
|
|
border-color: rgba(255, 215, 0, 0.85);
|
|
}
|
|
|
|
.empty {
|
|
padding: 12px; text-align: center;
|
|
font-size: 11px; color: var(--color-text-muted); opacity: 0.4;
|
|
}
|
|
</style>
|