Android build v1.1.2

This commit is contained in:
Warren H
2026-06-29 22:35:49 -04:00
parent cbe42b6164
commit c6251167a5
52 changed files with 5554 additions and 3117 deletions
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'
import { memo, type CSSProperties } from 'react'
import {
bindingLabel,
compactBindingLabel,
@@ -31,7 +31,7 @@ function faceButtonFor(binding: string, iconStyle: ControllerIconStyle) {
return FACE_BUTTONS[iconStyle][Number(binding.slice(6))] ?? null
}
function FaceIcon({
const FaceIcon = memo(function FaceIcon({
color,
iconStyle,
label,
@@ -52,9 +52,9 @@ function FaceIcon({
{label}
</span>
)
}
})
export function ControllerBindingLabel({
export const ControllerBindingLabel = memo(function ControllerBindingLabel({
binding,
compact = false,
iconStyle,
@@ -78,9 +78,9 @@ export function ControllerBindingLabel({
}
return <>{compact ? compactBindingLabel(binding, iconStyle) : title}</>
}
})
export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
export const ControllerStylePreview = memo(function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIconStyle }) {
return (
<span className="controller-style-preview" aria-hidden="true">
{[0, 1, 2, 3].map((button) => {
@@ -99,4 +99,4 @@ export function ControllerStylePreview({ iconStyle }: { iconStyle: ControllerIco
})}
</span>
)
}
})
+36 -52
View File
@@ -84,6 +84,7 @@ export function EquipmentScreen({
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const activeEquipmentTab = mode ?? equipmentTab
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
@@ -97,10 +98,6 @@ export function EquipmentScreen({
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null,
)
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
: undefined
@@ -132,9 +129,10 @@ export function EquipmentScreen({
1,
Math.ceil(visibleInventory.length / EQUIPMENT_LIST_PAGE_SIZE),
)
const activeInventoryPage = Math.min(inventoryPage, inventoryPageCount - 1)
const inventoryPageItems = visibleInventory.slice(
inventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
(inventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
activeInventoryPage * EQUIPMENT_LIST_PAGE_SIZE,
(activeInventoryPage + 1) * EQUIPMENT_LIST_PAGE_SIZE,
)
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
@@ -172,42 +170,28 @@ export function EquipmentScreen({
1,
Math.ceil(filteredRecipes.length / CRAFTING_LIST_PAGE_SIZE),
)
const activeRecipePage = Math.min(recipePage, recipePageCount - 1)
const recipePageItems = filteredRecipes.slice(
recipePage * CRAFTING_LIST_PAGE_SIZE,
(recipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
activeRecipePage * CRAFTING_LIST_PAGE_SIZE,
(activeRecipePage + 1) * CRAFTING_LIST_PAGE_SIZE,
)
const activeSelectedRecipeId = filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)
? selectedRecipeId
: filteredRecipes[0]?.id ?? null
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === activeSelectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe
? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
: false
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
setInventoryPage((current) => Math.min(current, inventoryPageCount - 1))
}, [inventoryPageCount])
useEffect(() => {
setRecipePage((current) => Math.min(current, recipePageCount - 1))
}, [recipePageCount])
useEffect(() => {
if (filteredRecipes.length === 0) {
setSelectedRecipeId(null)
return
}
if (!filteredRecipes.some((recipe) => recipe.id === selectedRecipeId)) {
setSelectedRecipeId(filteredRecipes[0].id)
}
}, [filteredRecipes, selectedRecipeId])
useEffect(() => {
if (equipmentTab === 'crafting') {
if (activeEquipmentTab === 'crafting') {
loadProfile().then((fresh) => onUpdated(fresh)).catch(() => {})
}
}, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
}, [activeEquipmentTab, onUpdated])
function saveScroll() {
scrollRef.current = window.scrollY
@@ -330,7 +314,7 @@ export function EquipmentScreen({
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (activeEquipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
@@ -416,7 +400,7 @@ export function EquipmentScreen({
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
}, [activeEquipmentTab, comparisonItem, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
@@ -449,14 +433,14 @@ export function EquipmentScreen({
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
className={`equipment-tab ${activeEquipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
className={`equipment-tab ${activeEquipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
@@ -465,7 +449,7 @@ export function EquipmentScreen({
</nav>
)}
{equipmentTab === 'equipment' ? (
{activeEquipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
@@ -577,11 +561,11 @@ export function EquipmentScreen({
</div>
{visibleInventory.length > EQUIPMENT_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${inventoryPage + 1} / ${inventoryPageCount}`}
onNext={() => setInventoryPage((current) => Math.min(inventoryPageCount - 1, current + 1))}
onPrevious={() => setInventoryPage((current) => Math.max(0, current - 1))}
nextDisabled={inventoryPage >= inventoryPageCount - 1}
previousDisabled={inventoryPage <= 0}
label={`Page ${activeInventoryPage + 1} / ${inventoryPageCount}`}
onNext={() => setInventoryPage(Math.min(inventoryPageCount - 1, activeInventoryPage + 1))}
onPrevious={() => setInventoryPage(Math.max(0, activeInventoryPage - 1))}
nextDisabled={activeInventoryPage >= inventoryPageCount - 1}
previousDisabled={activeInventoryPage <= 0}
/>
)}
</section>
@@ -666,7 +650,7 @@ export function EquipmentScreen({
<EquipmentHeading
eyebrow="Available Gear"
title={slotFilter === 'all' ? 'Craftable Gear' : SLOT_LABELS[slotFilter]}
detail={`Page ${recipePage + 1}/${recipePageCount}`}
detail={`Page ${activeRecipePage + 1}/${recipePageCount}`}
/>
{filteredRecipes.length === 0 ? (
<p className="inventory-empty">No recipes match filters.</p>
@@ -674,7 +658,7 @@ export function EquipmentScreen({
<div className="crafting-list">
{recipePageItems.map((recipe) => (
<button
className={`${selectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
className={`${activeSelectedRecipeId === recipe.id ? 'selected' : ''} rarity-${recipe.item.rarity}`}
key={recipe.id}
onClick={() => setSelectedRecipeId(recipe.id)}
type="button"
@@ -696,11 +680,11 @@ export function EquipmentScreen({
)}
{filteredRecipes.length > CRAFTING_LIST_PAGE_SIZE && (
<ListPager
label={`Page ${recipePage + 1} / ${recipePageCount}`}
onNext={() => setRecipePage((current) => Math.min(recipePageCount - 1, current + 1))}
onPrevious={() => setRecipePage((current) => Math.max(0, current - 1))}
nextDisabled={recipePage >= recipePageCount - 1}
previousDisabled={recipePage <= 0}
label={`Page ${activeRecipePage + 1} / ${recipePageCount}`}
onNext={() => setRecipePage(Math.min(recipePageCount - 1, activeRecipePage + 1))}
onPrevious={() => setRecipePage(Math.max(0, activeRecipePage - 1))}
nextDisabled={activeRecipePage >= recipePageCount - 1}
previousDisabled={activeRecipePage <= 0}
/>
)}
<div className="crafting-action-row">
@@ -748,7 +732,7 @@ export function EquipmentScreen({
</section>
)}
{equipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
{activeEquipmentTab === 'equipment' && profile.setBonuses.length > 0 && (
<section className="set-bonus-panel">
<div className="equipment-heading toggle-heading">
<div>
@@ -784,11 +768,11 @@ export function EquipmentScreen({
)
if (embedded) {
return <div className={`equipment-screen embedded-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
return <div className={`equipment-screen embedded-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>{content}</div>
}
return (
<section className={`content-screen equipment-screen ${equipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
<section className={`content-screen equipment-screen ${activeEquipmentTab === 'crafting' ? 'crafting-active' : ''}`}>
{content}
</section>
)
+153
View File
@@ -0,0 +1,153 @@
import { memo, type ElementType, type ReactNode } from 'react'
import type { ControllerIconStyle } from '../input'
import type { PartyMember } from '../game'
import {
effectiveMaxHealth,
formatEffectTime,
memberHotEffects,
} from '../combat/rules'
import { ControllerBindingLabel } from './ControllerIcons'
export type FloatingCombatText = {
id: number
memberId: string
value: number
}
type MemberEffectMode = 'full' | 'compact' | 'timed-basic'
const MemberEffects = memo(function MemberEffects({
member,
mode,
}: {
member: PartyMember
mode: MemberEffectMode
}) {
if (mode === 'compact') {
return (
<div className="member-effects">
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && <span className="debuff">{member.debuff}</span>}
</div>
)
}
if (mode === 'timed-basic') {
return (
<div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
}
return (
<div className="member-effects">
{memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
{(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
</div>
)
})
export const PartyMemberFrame = memo(function PartyMemberFrame({
as,
member,
selected = false,
baseClassName = 'party-member',
selectedClassName = 'selected',
deadClassName = 'dead',
effectMode = 'full',
floatingTexts = [],
showTargetMarker = false,
showHeaderHealth = true,
showHealthText = false,
targetBinding,
controllerIconStyle,
onSelect,
children,
}: {
as?: ElementType
member: PartyMember
selected?: boolean
baseClassName?: string
selectedClassName?: string
deadClassName?: string
effectMode?: MemberEffectMode
floatingTexts?: FloatingCombatText[]
showTargetMarker?: boolean
showHeaderHealth?: boolean
showHealthText?: boolean
targetBinding?: string | null
controllerIconStyle?: ControllerIconStyle
onSelect?: (id: string) => void
children?: ReactNode
}) {
const Component = as ?? (onSelect ? 'button' : 'div')
const maxHealth = effectiveMaxHealth(member)
const className = [
baseClassName,
selected ? selectedClassName : '',
member.health <= 0 ? deadClassName : '',
].filter(Boolean).join(' ')
const action = targetBinding ? {
binding: targetBinding,
iconStyle: controllerIconStyle ?? 'xbox',
} : null
return (
<Component
className={className}
data-party-member-id={member.id}
onClick={onSelect ? () => onSelect(member.id) : undefined}
aria-pressed={onSelect ? selected : undefined}
type={Component === 'button' ? 'button' : undefined}
>
{showTargetMarker && (
<span className="target-marker" aria-hidden="true">
<i />
Target
</span>
)}
<div className="member-header">
<span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
<strong>{member.name}</strong>
{showHeaderHealth && <small>{Math.ceil(member.health)} / {maxHealth}</small>}
</div>
<div className="bar member-health">
<span style={{ width: `${(member.health / maxHealth) * 100}%` }} />
{member.shield > 0 && <i style={{ width: `${(member.shield / maxHealth) * 100}%` }} />}
{showHealthText && <em className="health-text">{Math.floor(member.health)} / {maxHealth}</em>}
</div>
<div className="floating-combat-texts" aria-hidden="true">
{floatingTexts.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div>
{action && (
<div className="member-target-key">
<ControllerBindingLabel
binding={action.binding}
iconStyle={action.iconStyle}
/>
</div>
)}
<MemberEffects member={member} mode={effectMode} />
{children}
</Component>
)
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
import type { ReactNode } from 'react'
import type { CombatLogEntry } from '../game'
import { RematchControls, ResultLogToggle } from './RewardPanels'
type ResultAction = {
label: string
onClick: () => void
className?: string
disabled?: boolean
}
type ResultRematch = {
visible: boolean
requested: boolean
message: string
onRematch: () => void
}
export function ResultScreen({
eyebrow,
title,
children,
log,
showLog,
onToggleLog,
rematch,
actions,
}: {
eyebrow: string
title: string
children?: ReactNode
log?: CombatLogEntry[]
showLog?: boolean
onToggleLog?: () => void
rematch?: ResultRematch
actions?: ResultAction[]
}) {
return (
<div className="result-screen">
<div>
<p className="eyebrow">{eyebrow}</p>
<h2>{title}</h2>
{children}
{log && onToggleLog && (
<ResultLogToggle log={log} showEndLog={Boolean(showLog)} onToggle={onToggleLog} />
)}
{rematch?.visible && (
<RematchControls
requested={rematch.requested}
message={rematch.message}
onRematch={rematch.onRematch}
/>
)}
{actions?.map((action) => (
<button
className={action.className}
disabled={action.disabled}
key={action.label}
onClick={action.onClick}
type="button"
>
{action.label}
</button>
))}
</div>
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import type { CombatLogEntry } from '../game'
import type { DungeonReward, LootRoll } from '../profile'
type BonusItem = NonNullable<DungeonReward['bonusItem']>
export function LevelGain({
previousLevel,
newLevel,
talentPointsGained,
}: {
previousLevel: number | null
newLevel: number | null
talentPointsGained: number
}) {
if (!previousLevel || !newLevel || talentPointsGained <= 0) return null
return (
<p className="level-gain">
Level {previousLevel} to {newLevel}
<small>+{talentPointsGained} talent point</small>
</p>
)
}
export function AbilityUnlocks({
abilities,
}: {
abilities: DungeonReward['unlockedAbilities']
}) {
return (
<>
{abilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
</>
)
}
export function RewardXpSummary({
reward,
}: {
reward: {
experienceGained: number
previousLevel: number | null
newLevel: number | null
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
}
}) {
return (
<>
<p>+{reward.experienceGained} XP</p>
<LevelGain previousLevel={reward.previousLevel} newLevel={reward.newLevel} talentPointsGained={reward.talentPointsGained} />
<AbilityUnlocks abilities={reward.unlockedAbilities} />
</>
)
}
export function BonusItemReward({
item,
eyebrow,
compact = false,
}: {
item?: BonusItem | null
eyebrow?: string
compact?: boolean
}) {
if (!item) return null
if (compact) {
return (
<p className="ability-unlock">
<span>{item.glyph}</span>
{item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
</p>
)
}
return (
<div className="bonus-item">
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
<div className="bonus-item-detail">
<span>{item.glyph}</span>
<strong className={`rarity-${item.rarity}`}>{item.name}</strong>
<small>Item Level {item.itemLevel} x{item.quantity}</small>
{item.duplicate && <small> (owned x{item.quantityAfter})</small>}
</div>
</div>
)
}
export function LootRollList({
rolls,
expectedRolls,
}: {
rolls: LootRoll[]
expectedRolls: number
}) {
return (
<div className="run-loot-rolls">
{rolls.map((roll) => (
<div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
<strong>{roll.encounterName}</strong>
<span>
{roll.items.length > 0
? roll.items
.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
.join(', ')
: 'No components dropped'}
</span>
</div>
))}
{rolls.length < expectedRolls && <small>Finishing loot rolls...</small>}
</div>
)
}
export function PvpRunLootList({
loot,
}: {
loot: BonusItem[]
}) {
return (
<div className="run-loot-rolls">
{loot.length > 0 ? loot.map((item, index) => (
<div className="dropped" key={`${item.id}-${index}`}>
<strong>Boss {index + 1}</strong>
<span>
{item.glyph} {item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
</span>
</div>
)) : (
<div>
<strong>Loot</strong>
<span>No boss loot awarded</span>
</div>
)}
</div>
)
}
export function ResultLogToggle({
log,
showEndLog,
onToggle,
}: {
log: CombatLogEntry[]
showEndLog: boolean
onToggle: () => void
}) {
if (log.length === 0) return null
return (
<>
<button className="secondary-result-button" onClick={onToggle} type="button">
{showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
</button>
{showEndLog && (
<div className="result-log">
{log.slice().reverse().map((entry) => (
<div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
))}
</div>
)}
</>
)
}
export function RematchControls({
requested,
message,
onRematch,
}: {
requested: boolean
message: string
onRematch: () => void
}) {
return (
<>
<button disabled={requested} onClick={onRematch} type="button">
{requested ? 'Waiting for Rematch' : 'Rematch'}
</button>
{message && <p>{message}</p>}
</>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { memo } from 'react'
import type { ControllerIconStyle } from '../input'
import type { Spell } from '../game'
import { ControllerBindingLabel } from './ControllerIcons'
export type SpellSlot = (Spell & {
cost: number
remaining: number
slotIndex: number
}) | null
export const ResourceBar = memo(function ResourceBar({
resource,
maxResource,
resourceName,
speedMultiplier,
unavailableText,
}: {
resource: number
maxResource: number
resourceName: string
speedMultiplier?: 1 | 2
unavailableText?: string
}) {
return (
<div className="mana-wrap party-mana-wrap">
<span>
{unavailableText ?? `${resourceName} ${Math.floor(resource)} / ${maxResource}`}
</span>
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
</div>
)
})
export const SpellButton = memo(function SpellButton({
spell,
binding,
iconStyle,
resourceName,
disabled,
onCast,
emptyKeyPrefix = 'empty',
}: {
spell: SpellSlot
binding?: string
iconStyle: ControllerIconStyle
resourceName: string
disabled?: boolean
onCast: (spell: Spell) => void
emptyKeyPrefix?: string
}) {
if (!spell) {
return (
<div className="spell empty-spell" key={emptyKeyPrefix}>
<kbd>{emptyKeyPrefix}</kbd><strong>Empty</strong>
</div>
)
}
return (
<button
className="spell"
disabled={disabled}
key={spell.id}
onClick={() => onCast(spell)}
title={spell.description}
type="button"
>
<kbd>
{binding
? (
<ControllerBindingLabel
binding={binding}
compact
iconStyle={iconStyle}
/>
)
: spell.slotIndex + 1}
</kbd>
<span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
<strong>{spell.name}</strong>
<small>{spell.cost} {resourceName}</small>
{spell.remaining > 0 && <i>{spell.remaining.toFixed(1)}</i>}
</button>
)
})
export const SpellBar = memo(function SpellBar({
spells,
bindings,
iconStyle,
resource,
resourceName,
canCast,
onCast,
className = 'spell-bar six-slots vertical-spell-bar',
}: {
spells: SpellSlot[]
bindings: Record<string, string>
iconStyle: ControllerIconStyle
resource: number
resourceName: string
canCast: boolean
onCast: (spell: Spell) => void
className?: string
}) {
return (
<div className={className}>
{spells.map((spell, slotIndex) => {
if (!spell) {
return (
<div className="spell empty-spell" key={`empty-${slotIndex}`}>
<kbd>{slotIndex + 1}</kbd><strong>Empty</strong>
</div>
)
}
return (
<SpellButton
binding={bindings[`ability${slotIndex + 1}`]}
disabled={!canCast || resource < spell.cost || spell.remaining > 0}
iconStyle={iconStyle}
key={spell.id}
onCast={onCast}
resourceName={resourceName}
spell={spell}
/>
)
})}
</div>
)
})
+12 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
allocateTalent,
resetTalents,
@@ -57,24 +57,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
?? gameClass.talents[0]
?? null
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
const activeEffectPage = Math.min(effectPage, effectPageCount - 1)
const visibleTalents = gameClass.talents.slice(
effectPage * EFFECTS_PER_PAGE,
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
activeEffectPage * EFFECTS_PER_PAGE,
activeEffectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
)
useEffect(() => {
window.scrollTo(0, scrollRef.current)
}, [profile])
useEffect(() => {
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
setSelectedTalentId(selectedTalent?.id ?? null)
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
function saveScroll() {
scrollRef.current = window.scrollY
}
@@ -123,9 +115,8 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
}
}
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (!isEffectClass) return null
return {
const workshopState: DualScreenWorkshopState | null = isEffectClass
? {
mode: 'talents',
title: 'Spell Effects',
subtitle: `${selectedEffects.length}/${capacity} active`,
@@ -140,7 +131,7 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
status: talent.rank > 0 ? 'Selected' : '',
})),
}
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
: null
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
@@ -265,16 +256,16 @@ export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: P
{effectPageCount > 1 && (
<div className="effect-pager">
<button
disabled={effectPage === 0}
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
disabled={activeEffectPage === 0}
onClick={() => setEffectPage(Math.max(0, activeEffectPage - 1))}
type="button"
>
Prev
</button>
<span>{effectPage + 1}/{effectPageCount}</span>
<span>{activeEffectPage + 1}/{effectPageCount}</span>
<button
disabled={effectPage >= effectPageCount - 1}
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
disabled={activeEffectPage >= effectPageCount - 1}
onClick={() => setEffectPage(Math.min(effectPageCount - 1, activeEffectPage + 1))}
type="button"
>
Next