Android build v1.1.2
This commit is contained in:
+401
-733
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
+686
-1095
File diff suppressed because it is too large
Load Diff
+528
-866
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user