fix: character slot selection glow and empty protagonist

- character grid starts at position 1 (skip protagonist)
- add isEmptySelected state for glow on empty slots
- empty protagonist shows relief.png background only
This commit is contained in:
Justin Edmund 2025-11-30 02:31:48 -08:00
parent 18328e2d38
commit d51fe03905
2 changed files with 56 additions and 9 deletions

View file

@ -11,6 +11,7 @@
import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte' import { openDetailsSidebar } from '$lib/features/details/openDetailsSidebar.svelte'
import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils' import { getJobPortraitUrl, Gender } from '$lib/utils/jobUtils'
import { sidebar } from '$lib/stores/sidebar.svelte' import { sidebar } from '$lib/stores/sidebar.svelte'
import { GridType } from '$lib/types/enums'
import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg' import perpetuityFilled from '$src/assets/icons/perpetuity/filled.svg'
import perpetuityEmpty from '$src/assets/icons/perpetuity/empty.svg' import perpetuityEmpty from '$src/assets/icons/perpetuity/empty.svg'
import * as m from '$lib/paraglide/messages' import * as m from '$lib/paraglide/messages'
@ -30,6 +31,8 @@
updateParty: (p: Party) => void updateParty: (p: Party) => void
canEdit: () => boolean canEdit: () => boolean
getEditKey: () => string | null getEditKey: () => string | null
getSelectedSlot?: () => number
getActiveTab?: () => GridType
services: { gridService: any; partyService: any } services: { gridService: any; partyService: any }
openPicker?: (opts: { openPicker?: (opts: {
type: 'character' | 'weapon' | 'summon' type: 'character' | 'weapon' | 'summon'
@ -53,6 +56,11 @@
return getJobPortraitUrl(job, Gender.Gran) // TODO: Get gender from user preferences return getJobPortraitUrl(job, Gender.Gran) // TODO: Get gender from user preferences
} }
// For protagonist slot without a job, show nothing (relief.png background shows through)
if (position === 0 && !job) {
return ''
}
// If no item or no character with granblueId, return placeholder // If no item or no character with granblueId, return placeholder
if (!item || !item.character?.granblueId) { if (!item || !item.character?.granblueId) {
return getCharacterImageWithPose(null, 'main', 0, 0) return getCharacterImageWithPose(null, 'main', 0, 0)
@ -74,6 +82,14 @@
// Check if this item is currently active in the sidebar // Check if this item is currently active in the sidebar
let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id)) let isActive = $derived(item?.id && sidebar.activeItemId === String(item.id))
// Check if this empty slot is currently selected for adding an item
let isEmptySelected = $derived(
!item &&
!isProtagonist &&
ctx?.getSelectedSlot?.() === position &&
ctx?.getActiveTab?.() === GridType.Character
)
// Determine element class for focus ring // Determine element class for focus ring
let elementClass = $derived.by(() => { let elementClass = $derived.by(() => {
const element = item?.character?.element || partyElement const element = item?.character?.element || partyElement
@ -180,6 +196,7 @@
class="frame character cell {elementClass}" class="frame character cell {elementClass}"
class:protagonist={position === 0} class:protagonist={position === 0}
class:editable={ctx?.canEdit()} class:editable={ctx?.canEdit()}
class:is-active={isActive}
onclick={() => viewDetails()} onclick={() => viewDetails()}
> >
{#if position !== 0} {#if position !== 0}
@ -210,6 +227,7 @@
/> />
{/if} {/if}
{/if} {/if}
{#if imageUrl}
<img <img
class="image {elementClass}" class="image {elementClass}"
class:placeholder={!item?.character?.granblueId && !isProtagonist} class:placeholder={!item?.character?.granblueId && !isProtagonist}
@ -217,6 +235,7 @@
alt={isProtagonist && job ? job.name.en : displayName(item?.character)} alt={isProtagonist && job ? job.name.en : displayName(item?.character)}
src={imageUrl} src={imageUrl}
/> />
{/if}
</div> </div>
{/key} {/key}
</div> </div>
@ -254,20 +273,28 @@
class="frame character cell" class="frame character cell"
class:editable={ctx?.canEdit() && !isProtagonist} class:editable={ctx?.canEdit() && !isProtagonist}
class:protagonist={isProtagonist} class:protagonist={isProtagonist}
class:empty-protagonist={isProtagonist && !job}
class:is-selected={isEmptySelected}
onclick={() => onclick={() =>
!isProtagonist && !isProtagonist &&
ctx?.canEdit() && ctx?.canEdit() &&
ctx?.openPicker && ctx?.openPicker &&
ctx.openPicker({ type: 'character', position, item })} ctx.openPicker({ type: 'character', position, item })}
> >
<img {#if !isProtagonist}
class="image" <img
class:placeholder={!isProtagonist || !job} class="image placeholder"
class:protagonist={isProtagonist} alt=""
alt={isProtagonist && job ? job.name.en : ''} src="/images/placeholders/placeholder-weapon-grid.png"
src={isProtagonist ? imageUrl : '/images/placeholders/placeholder-weapon-grid.png'} />
/> {:else if job && imageUrl}
{#if ctx?.canEdit()} <img
class="image protagonist"
alt={job.name.en}
src={imageUrl}
/>
{/if}
{#if ctx?.canEdit() && !isProtagonist}
<span class="icon"> <span class="icon">
<Icon name="plus" size={24} /> <Icon name="plus" size={24} />
</span> </span>
@ -381,6 +408,22 @@
opacity: 0.95; opacity: 0.95;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
// Slot selection - subtle dark pulsing glow (works for both empty and filled)
&.is-selected,
&.is-active {
animation: pulse-slot-shadow 2s ease-in-out infinite;
}
}
@keyframes pulse-slot-shadow {
0%,
100% {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 0 4px 2px rgba(0, 0, 0, 0.06);
}
50% {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.24), 0 0 8px 4px rgba(0, 0, 0, 0.12);
}
} }
.frame.character.cell { .frame.character.cell {
@ -391,6 +434,10 @@
background-size: cover; background-size: cover;
background-position: center -20px; background-position: center -20px;
background-repeat: no-repeat; background-repeat: no-repeat;
&.empty-protagonist {
background-position: center 0;
}
} }
} }

View file

@ -19,7 +19,7 @@ export interface SlotRange {
const GRID_CONFIGS: Record<GridType, SlotRange> = { const GRID_CONFIGS: Record<GridType, SlotRange> = {
[GridType.Weapon]: { start: 0, end: 8, specialSlots: [-1] }, // mainhand + 9 grid slots [GridType.Weapon]: { start: 0, end: 8, specialSlots: [-1] }, // mainhand + 9 grid slots
[GridType.Summon]: { start: 0, end: 5, specialSlots: [-1, 6] }, // main + 6 grid + friend [GridType.Summon]: { start: 0, end: 5, specialSlots: [-1, 6] }, // main + 6 grid + friend
[GridType.Character]: { start: 0, end: 4, specialSlots: [] } // 5 slots (0-4) [GridType.Character]: { start: 1, end: 4, specialSlots: [] } // 4 slots (1-4), position 0 is protagonist (not user-selectable)
} }
/** /**