Compare commits

...

3 Commits

Author SHA1 Message Date
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
15 changed files with 1176 additions and 229 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46
versionName "1.0.28"
versionCode 49
versionName "1.0.31"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+5 -5
View File
@@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells
VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
+516 -8
View File
@@ -4395,6 +4395,10 @@ h2 {
outline-color: var(--gold);
}
.customize-tab-back {
display: none;
}
.embedded-screen .gear-summary,
.embedded-screen .talent-toolbar {
margin-top: 16px;
@@ -6851,6 +6855,109 @@ h2 {
outline-color: var(--gold);
}
.workshop-bottom-display {
gap: 8px;
}
.workshop-bottom-summary {
background: #1c1e25;
border: 2px solid #0a0b0e;
color: var(--muted);
font-size: 15px;
line-height: 1.15;
outline: 2px solid #41404a;
padding: 10px;
}
.workshop-bottom-grid {
display: grid;
flex: 1;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 0;
overflow: hidden;
}
.workshop-bottom-grid article {
align-items: center;
background: #1c1e25;
border: 2px solid #0a0b0e;
color: var(--ink);
display: grid;
gap: 8px;
grid-template-columns: 36px minmax(0, 1fr) auto;
min-height: 58px;
outline: 2px solid #41404a;
padding: 8px;
}
.workshop-bottom-grid article > span {
align-items: center;
background: #15161c;
color: var(--gold);
display: flex;
font-family: 'Press Start 2P', monospace;
height: 34px;
justify-content: center;
}
.workshop-bottom-grid strong,
.workshop-bottom-grid small {
display: block;
}
.workshop-bottom-grid strong {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
line-height: 1.25;
}
.workshop-bottom-grid small,
.workshop-bottom-grid p {
color: var(--muted);
font-size: 12px;
line-height: 1.05;
margin-top: 3px;
}
.workshop-bottom-grid i {
color: var(--gold);
font-size: 12px;
font-style: normal;
text-align: right;
}
.equipment-action-strip,
.crafting-action-row {
align-items: center;
background: #1c1e25;
border: 2px solid #0a0b0e;
display: flex;
gap: 8px;
margin-top: 8px;
min-height: 42px;
outline: 2px solid #3e3d47;
padding: 7px;
}
.equipment-action-strip .comparison-delta {
display: grid;
gap: 4px;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 190px;
}
.equipment-action-strip > p {
color: var(--muted);
margin: 0;
}
.crafting-action-row {
justify-content: flex-end;
margin-top: 6px;
padding: 0;
}
@media (max-height: 620px) {
.game-shell.workshop-shell {
height: 100dvh;
@@ -6871,6 +6978,10 @@ h2 {
padding: 6px;
}
.workshop-shell .customize-heading {
display: none;
}
.workshop-shell .screen-heading {
padding-bottom: 3px;
}
@@ -6892,6 +7003,10 @@ h2 {
padding: 5px 8px;
}
.workshop-shell .customize-tab-back {
display: block;
}
.workshop-shell .customize-tabs,
.workshop-shell .equipment-tabs,
.workshop-shell .talent-page-tabs {
@@ -6907,6 +7022,11 @@ h2 {
padding: 5px 7px;
}
.workshop-shell .customize-tabs {
grid-template-columns: 70px repeat(4, minmax(0, 1fr));
margin-top: 0;
}
.workshop-shell .equipment-screen,
.workshop-shell .talent-screen {
gap: 0;
@@ -6965,6 +7085,23 @@ h2 {
padding: 5px;
}
.workshop-shell .item-comparison,
.workshop-shell .crafting-detail-panel {
display: none;
}
.workshop-shell .equipment-action-strip {
gap: 5px;
margin-top: 5px;
min-height: 36px;
padding: 5px;
}
.workshop-shell .equipment-action-strip .comparison-delta {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 140px;
}
.workshop-shell .comparison-arrow {
font-size: 11px;
padding: 0;
@@ -7020,7 +7157,7 @@ h2 {
.workshop-shell .equipment-layout {
gap: 6px;
grid-template-columns: minmax(0, 1.05fr) minmax(178px, 0.95fr);
grid-template-columns: minmax(0, 1.15fr) minmax(210px, 0.85fr);
margin-top: 5px;
}
@@ -7115,12 +7252,15 @@ h2 {
.workshop-shell .crafting-layout {
gap: 6px;
grid-template-columns: minmax(132px, 0.42fr) minmax(230px, 1fr) minmax(190px, 0.72fr);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
margin-top: 6px;
}
.workshop-shell .crafting-filters {
display: grid;
gap: 6px;
grid-template-columns: minmax(0, 1fr) auto;
}
.workshop-shell .crafting-filter-grid,
@@ -7128,9 +7268,13 @@ h2 {
gap: 4px;
}
.workshop-shell .crafting-filter-grid {
grid-template-columns: repeat(10, minmax(0, 1fr));
}
.workshop-shell .crafting-filter-grid button {
min-height: 28px;
padding: 4px;
min-height: 32px;
padding: 4px 2px;
}
.workshop-shell .crafting-filter-grid strong {
@@ -7313,15 +7457,243 @@ h2 {
}
.workshop-shell .crafting-list > button {
grid-template-columns: 24px minmax(0, 1fr);
min-height: 34px;
min-height: 68px;
}
.workshop-shell .crafting-list > button i {
.workshop-shell .crafting-action-row {
background: transparent;
border: 0;
margin-top: 5px;
min-height: 28px;
outline: 0;
padding: 0;
}
.workshop-shell .crafting-action-row .primary-button {
font-size: 8px;
min-height: 28px;
padding: 4px 8px;
}
.workshop-shell .customize-layout {
gap: 8px;
grid-template-columns: 148px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .class-picker {
padding-right: 6px;
}
.workshop-shell .class-picker > .eyebrow {
display: none;
}
.workshop-shell .crafting-list > button:nth-child(n+4) {
.workshop-shell .class-picker > button {
gap: 6px;
margin-bottom: 5px;
min-height: 44px;
padding: 5px;
}
.workshop-shell .class-picker > button > span {
flex-basis: 28px;
height: 28px;
}
.workshop-shell .class-picker strong {
font-size: 7px;
}
.workshop-shell .class-picker small {
font-size: 10px;
margin-top: 2px;
}
.workshop-shell .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.workshop-shell .class-detail {
gap: 8px;
padding: 6px;
}
.workshop-shell .class-portrait {
flex-basis: 32px;
height: 32px;
}
.workshop-shell .class-detail h2 {
font-size: 9px;
}
.workshop-shell .class-detail p:last-child,
.workshop-shell .loadout-heading > span {
display: none;
}
.workshop-shell .loadout-heading,
.workshop-shell .ability-library-heading,
.workshop-shell .save-row {
margin-top: 6px;
}
.workshop-shell .ability-slots {
gap: 5px;
margin-top: 5px;
}
.workshop-shell .ability-slots button {
min-height: 40px;
padding: 5px 2px;
}
.workshop-shell .ability-slots span {
font-size: 12px;
margin-bottom: 4px;
}
.workshop-shell .ability-slots strong {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .ability-library {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
overflow: hidden;
}
.workshop-shell .ability-library > button {
align-content: center;
gap: 5px;
grid-template-columns: 1fr;
min-height: 58px;
padding: 5px;
text-align: center;
}
.workshop-shell .ability-library > button > span {
height: 26px;
}
.workshop-shell .ability-library strong {
font-size: 8px;
line-height: 1.1;
}
.workshop-shell .ability-library small,
.workshop-shell .ability-library i {
font-size: 9px;
line-height: 1;
}
.workshop-shell .ability-library small {
display: none;
}
.workshop-shell .ability-library i {
font-size: 8px;
}
.workshop-shell .save-row .primary-button {
font-size: 8px;
min-height: 28px;
padding: 4px 8px;
}
.workshop-shell .save-row > span {
font-size: 11px;
}
.workshop-bottom-display {
padding: 6px;
}
.workshop-bottom-grid {
gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workshop-bottom-grid article {
min-height: 50px;
padding: 6px;
}
}
@media (max-width: 700px) and (max-height: 620px) {
.workshop-shell .equipment-layout {
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.7fr);
}
.workshop-shell .equipment-action-strip .comparison-delta {
display: none;
}
.workshop-shell .crafting-layout {
grid-template-columns: minmax(0, 1fr);
}
.workshop-shell .crafting-filters {
grid-template-columns: minmax(0, 1fr);
}
.workshop-shell .crafting-filter-grid {
grid-template-columns: repeat(10, minmax(0, 1fr));
}
.workshop-shell .crafting-filter-grid button {
min-height: 34px;
padding: 4px 2px;
}
.workshop-shell .crafting-filter-grid strong {
font-size: 5px;
}
.workshop-shell .crafting-filter-grid span {
font-size: 10px;
}
.workshop-shell .crafting-level-row {
flex-wrap: nowrap;
}
.workshop-shell .crafting-list > button {
display: grid;
min-height: 68px;
}
.workshop-shell .customize-layout {
grid-template-columns: 118px minmax(0, 1fr);
}
.workshop-shell .ability-library {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
}
.workshop-bottom-grid {
grid-template-columns: 1fr;
}
}
@media (max-height: 620px) {
.workshop-shell .crafting-list > button {
grid-template-columns: 24px minmax(0, 1fr);
}
.workshop-shell .crafting-list > button i {
display: none;
}
@@ -7341,3 +7713,139 @@ h2 {
grid-column: auto !important;
}
}
@media (max-height: 620px) {
.workshop-shell .customize-screen > .customize-layout {
gap: 8px;
grid-template-columns: 148px minmax(0, 1fr);
margin-top: 5px;
overflow: hidden;
}
.workshop-shell .class-picker {
padding-right: 6px;
}
.workshop-shell .class-picker > .eyebrow {
display: none;
}
.workshop-shell .class-picker > button {
gap: 6px;
margin-bottom: 5px;
min-height: 44px;
padding: 5px;
}
.workshop-shell .class-picker > button > span {
flex-basis: 28px;
height: 28px;
}
.workshop-shell .class-picker strong {
font-size: 7px;
}
.workshop-shell .class-picker small {
font-size: 10px;
margin-top: 2px;
}
.workshop-shell .loadout-editor {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.workshop-shell .class-detail {
gap: 8px;
padding: 6px;
}
.workshop-shell .class-detail p:last-child,
.workshop-shell .loadout-heading > span,
.workshop-shell .ability-library small {
display: none;
}
.workshop-shell .class-portrait {
flex-basis: 32px;
height: 32px;
}
.workshop-shell .class-detail h2 {
font-size: 9px;
}
.workshop-shell .loadout-heading,
.workshop-shell .ability-library-heading,
.workshop-shell .save-row {
margin-top: 6px;
}
.workshop-shell .ability-slots {
gap: 5px;
margin-top: 5px;
}
.workshop-shell .ability-slots button {
min-height: 40px;
padding: 5px 2px;
}
.workshop-shell .ability-slots span {
font-size: 12px;
margin-bottom: 4px;
}
.workshop-shell .ability-slots strong {
font-size: 9px;
line-height: 1.1;
}
.workshop-shell .ability-library {
flex: 1;
gap: 5px;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(52px, 1fr));
margin-top: 5px;
max-height: none;
min-height: 0;
overflow: hidden;
}
.workshop-shell .ability-library > button {
align-content: center;
gap: 5px;
grid-template-columns: 1fr;
min-height: 58px;
padding: 5px;
text-align: center;
}
.workshop-shell .ability-library > button > span {
height: 26px;
}
.workshop-shell .ability-library strong {
font-size: 8px;
line-height: 1.1;
}
.workshop-shell .ability-library i {
font-size: 8px;
line-height: 1;
}
}
@media (max-width: 700px) and (max-height: 620px) {
.workshop-shell .customize-screen > .customize-layout {
grid-template-columns: 118px minmax(0, 1fr);
}
.workshop-shell .ability-library {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
+97 -78
View File
@@ -283,6 +283,19 @@ function App() {
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0]
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedPart(1)
setScreen('combat')
}
const tierOptions = activityOptions
.flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => (
@@ -425,84 +438,90 @@ function App() {
</div>
{roguelikeVariant === 'pve' && (
<>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="roguelike-mode-grid">
<button
className="menu-card"
onClick={() => {
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>D</span>
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Timing</p>
<h2>Buff Drafts</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeUpgradeTiming === 'encounter' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('encounter')}
type="button"
>
Every Encounter
</button>
<button
className={`text-button ${roguelikeUpgradeTiming === 'boss' ? 'active' : ''}`}
onClick={() => setRoguelikeUpgradeTiming('boss')}
type="button"
>
Boss Only
</button>
</div>
</div>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Upgrade Labels</p>
<h2>Display Mode</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeAbilityLabelMode === 'ability' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('ability')}
type="button"
>
Ability Names
</button>
<button
className={`text-button ${roguelikeAbilityLabelMode === 'slot' ? 'active' : ''}`}
onClick={() => setRoguelikeAbilityLabelMode('slot')}
type="button"
>
Slot Names
</button>
</div>
</div>
<div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
<small>
{roguelikeKind === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
</small>
</div>
<button
className="text-button"
onClick={startPveRoguelike}
type="button"
>
Start Run
</button>
</div>
</>
)}
{roguelikeVariant === 'pvp' && (
+33 -12
View File
@@ -10,6 +10,10 @@ import {
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
@@ -561,6 +565,12 @@ export function CombatScreen({
const directTargets = new Set([targetId])
const hotTargets = new Set<string>()
const shieldTargets = new Set<string>()
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot') hotTargets.add(targetId)
if (spell.kind === 'shield') shieldTargets.add(targetId)
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
@@ -574,7 +584,6 @@ export function CombatScreen({
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
hotTargets.add(targetId)
}
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
if (spell.kind === 'hot') {
@@ -594,6 +603,7 @@ export function CombatScreen({
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power)
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
@@ -902,11 +912,17 @@ export function CombatScreen({
}
const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier)
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -966,7 +982,7 @@ export function CombatScreen({
return
}
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage
const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) {
setCombat({
...current,
@@ -1351,7 +1367,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && (
<div className="result-screen">
<div>
<div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow">
{encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1359,13 +1375,18 @@ export function CombatScreen({
</p>
<h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
<strong>{upgrade.name}</strong>
<small>{upgrade.description}</small>
</button>
))}
</div>
</div>
</div>
{roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list">
+41 -2
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile,
type GameClass,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
}
export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class')
const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0)
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
)
}
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() {
saveScroll()
setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return (
<section className="content-screen customize-screen">
<div className="screen-heading">
<div className="screen-heading customize-heading">
<div>
<p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([
{ key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' },
] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && (
<EquipmentScreen
embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
+191 -67
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot,
type Item,
} from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon',
@@ -24,16 +25,28 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
}
const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6
const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
type Props = {
profile: CharacterProfile
onBack?: () => void
onUpdated: (profile: CharacterProfile) => void
embedded?: boolean
mode?: 'equipment' | 'crafting'
showModeTabs?: boolean
}
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
export function EquipmentScreen({
profile,
onBack,
onUpdated,
embedded = false,
mode,
showModeTabs = true,
}: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity,
0,
@@ -49,7 +62,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment')
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('')
@@ -173,6 +186,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}
}, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() {
scrollRef.current = window.scrollY
}
@@ -247,6 +264,143 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}
}
function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<>
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
@@ -273,22 +427,24 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
@@ -297,9 +453,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
@@ -313,41 +466,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button
className="primary-button"
disabled={selectedItem.equipped || equipping || breakingDown || upgrading}
onClick={equipSelected}
type="button"
>
{selectedItem.equipped ? 'Equipped' : equipping ? 'Equipping...' : 'Equip Item'}
</button>
{upgradeRecipe && (
<button
className="primary-button"
disabled={!upgradeRecipe.canCraft || equipping || breakingDown || upgrading}
onClick={upgradeSelected}
type="button"
>
{upgrading ? 'Upgrading...' : `Upgrade to iLvl ${upgradeRecipe.item.itemLevel}`}
</button>
)}
{(!selectedItem.equipped || selectedItem.quantity > 1) && (
<button
className="breakdown-button"
disabled={equipping || breakingDown || upgrading}
onClick={breakdownSelected}
type="button"
>
{breakingDown
? 'Breaking Down...'
: selectedItem.quantity > 1
? 'Break Down Duplicate'
: 'Break Down'}
</button>
)}
</div>
</>
)
) : (
@@ -355,6 +473,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)}
</section>
<section className="equipment-action-strip">
{renderEquipmentActions()}
</section>
<div className="equipment-layout">
<section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
@@ -469,7 +591,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
<strong>All</strong>
<span>{profile.craftingRecipes.length}</span>
</button>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => (
{CRAFTING_FILTER_SLOTS.map((slot) => (
<button
className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
@@ -480,7 +602,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}}
type="button"
>
<strong>{label}</strong>
<strong>{SLOT_LABELS[slot]}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button>
))}
@@ -557,6 +679,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0}
/>
)}
<div className="crafting-action-row">
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
</section>
<section className="crafting-detail-panel">
@@ -579,14 +711,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div>
))}
</div>
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
) : (
<p className="inventory-empty">Select a recipe.</p>
+158 -33
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game'
import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
@@ -78,6 +88,17 @@ type FloatingCombatText = {
value: number
}
type PvpRunSummary = {
bossesKilled: number
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse',
'searing-mark',
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
}
function createEmptyPvpRunSummary(): PvpRunSummary {
return {
bossesKilled: 0,
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
loot: [],
}
}
function buffStacks<T extends string>(items: T[], id: T) {
return items.filter((item) => item === id).length
}
@@ -411,11 +445,13 @@ export function PvPRoguelikeScreen({
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
const selectedIdRef = useRef(partyTemplate[0].id)
const [elapsedTicks, setElapsedTicks] = useState(0)
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
const [queueMessage, setQueueMessage] = useState('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null)
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
@@ -433,6 +469,8 @@ export function PvPRoguelikeScreen({
const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide)
const encounter = encounters[encounterIndex]
@@ -459,6 +497,12 @@ export function PvPRoguelikeScreen({
const {
enabled: dualScreenEnabled,
} = useDualScreen()
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, [])
@@ -473,11 +517,16 @@ export function PvPRoguelikeScreen({
}, [])
useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue)
@@ -497,6 +546,20 @@ export function PvPRoguelikeScreen({
)
.then((result) => {
setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile)
if (result.bonusItem) {
addLog(
@@ -532,8 +595,9 @@ export function PvPRoguelikeScreen({
: null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => {
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType)
const startMatch = useCallback((nextStartStage?: number) => {
const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -543,15 +607,18 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu
nextLogId.current = 2
playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment)
setEncounterIndex(0)
setStage(startStage)
setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0)
setStatus('queueing')
setPlayerSide(basePlayer)
setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id)
setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([])
setPlayerDebuffChoices([])
setSelectedBuff(null)
@@ -560,6 +627,7 @@ export function PvPRoguelikeScreen({
setPaused(false)
setTargetGroup(0)
setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError('')
setShowEndLog(false)
setFloatingTexts([])
@@ -569,26 +637,28 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false
if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`)
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }])
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => {
setStatus('playing')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system')
addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500)
return () => window.clearTimeout(timer)
}
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }])
setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400)
return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage])
}, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback((
current: SideState,
@@ -611,6 +681,11 @@ export function PvPRoguelikeScreen({
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break
if (spell.kind === 'hot') {
@@ -629,6 +704,7 @@ export function PvPRoguelikeScreen({
const nextParty = current.party.map((member) => {
if (member.health <= 0) return member
if (spell.kind === 'group') {
if (!groupTargets.has(member.id)) return member
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
const nextHealth = healMember(member, groupPower, debuffs)
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
@@ -687,29 +763,30 @@ export function PvPRoguelikeScreen({
const castPlayerSpell = useCallback((spell: Spell) => {
if (status !== 'playing' || playerDone || !playerAlive) return
const targetId = selectedIdRef.current
const succeeded = applySpell(playerRef.current, (value) => {
const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next
setPlayerSide(next)
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status])
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, status])
const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = playerRef.current.party.filter((member) => member.health > 0)
if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId)
const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0
? 0
: (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id)
}, [selectedId])
setSelectedTargetId(living[nextIndex].id)
}, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id)
if (firstLiving) setSelectedTargetId(firstLiving.id)
return
}
const currentRow = Math.floor(currentIndex / partyColumns)
@@ -736,14 +813,14 @@ export function PvPRoguelikeScreen({
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary
})
if (candidates[0]) setSelectedId(candidates[0].member.id)
}, [partyColumns, selectedId])
if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [partyColumns, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id)
}, [contentType, targetGroup])
if (member?.health > 0) setSelectedTargetId(member.id)
}, [contentType, setSelectedTargetId, targetGroup])
const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -790,10 +867,14 @@ export function PvPRoguelikeScreen({
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
const tankPressure = tankPressureTargets(side.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = side.party.map((member) => {
if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
if (member.role === 'Tank') damage += encounterValue.tankDamage
if (tankPressureIds.has(member.id)) {
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
}
if (bossPulse) damage += 10
if (member.debuff) damage += 6
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -842,7 +923,7 @@ export function PvPRoguelikeScreen({
cooldowns: Object.fromEntries(
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
),
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage),
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
}
}, [addFloatingHeal, elapsedTicks, maxResource])
@@ -895,6 +976,12 @@ export function PvPRoguelikeScreen({
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
}
if (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
beginUpgradePhase()
}
@@ -955,10 +1042,17 @@ export function PvPRoguelikeScreen({
}
const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) {
finishRoguelikeRun()
setStatus('won')
addLog('No further encounters remain.', 'loot')
return
@@ -1007,7 +1101,7 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0)
setStatus('playing')
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => {
if (action === 'pause' || action === 'back') {
@@ -1036,9 +1130,9 @@ export function PvPRoguelikeScreen({
setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId)
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id)
if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
return next
})
return
@@ -1128,7 +1222,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat
state={dualScreenState}
onSelectTarget={setSelectedId}
onSelectTarget={setSelectedTargetId}
/>
)}
@@ -1152,7 +1246,7 @@ export function PvPRoguelikeScreen({
<button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
key={`player-${member.id}`}
onClick={() => setSelectedId(member.id)}
onClick={() => setSelectedTargetId(member.id)}
type="button"
>
<div className="member-header">
@@ -1351,9 +1445,39 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary">
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>}
<p>{runSummary.bossesKilled} bosses killed.</p>
<p>+{runSummary.experienceGained} XP</p>
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>}
{reward && (
{runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
<p className="level-gain">
Level {runSummary.previousLevel} to {runSummary.newLevel}
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
</p>
)}
{runSummary.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
<div className="run-loot-rolls">
{runSummary.loot.length > 0 ? runSummary.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>
{reward && runSummary.bossesKilled === 0 && (
<>
<p>+{reward.experienceGained} XP</p>
{reward.levelsGained > 0 && (
@@ -1392,6 +1516,7 @@ export function PvPRoguelikeScreen({
)}
</>
)}
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div>
</div>
+99 -1
View File
@@ -56,12 +56,28 @@ export type DualScreenCombatState = {
targetGroup: 0 | 1 | 2
}
export type DualScreenWorkshopState = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
title: string
subtitle: string
summary?: string
items: Array<{
glyph?: string
title: string
meta?: string
detail?: string
status?: string
}>
}
type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' }
| { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = {
enabled: boolean
@@ -280,16 +296,64 @@ export function useDualScreenPublisher(
}, [enabled, state])
}
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => {
const channel = createChannel()
if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state)
if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
}
announce()
const timer = window.setInterval(() => {
@@ -307,6 +371,40 @@ export function DualScreenBottomDisplay() {
channel?.close()
}
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) {
return (
<main className="dual-bottom-display dual-bottom-waiting">
+29 -1
View File
@@ -44,6 +44,9 @@ export type CombatLogEntry = {
tone: 'system' | 'heal' | 'danger' | 'loot'
}
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
export const DEFAULT_GROUP_HEAL_TARGETS = 4
export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
@@ -101,7 +104,7 @@ export const SPELLS: Spell[] = [
id: 'radiance',
key: '3',
name: 'Radiance',
description: 'Restores health to every living party member.',
description: 'Restores health to up to 4 injured party members.',
cost: 12,
cooldown: 8,
power: 18,
@@ -164,3 +167,28 @@ export const ENCOUNTERS: Encounter[] = [
isBoss: true,
},
]
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
const livingCount = party.filter((member) => member.health > 0).length
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
}
export function tankPressureTargets(party: PartyMember[]) {
const living = party.filter((member) => member.health > 0)
const tanks = living.filter((member) => member.role === 'Tank')
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
const damageDealer = living
.filter((member) => member.role === 'Damage')
.sort((left, right) => right.health - left.health)[0]
return {
targets: damageDealer ? [damageDealer] : [],
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
}
}
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
return party
.filter((member) => member.health > 0)
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
.slice(0, targetCount)
}
-15
View File
@@ -121,7 +121,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
type CaptureState = {
device: InputDevice
@@ -277,14 +276,6 @@ function hasUiOverlay() {
).some(isVisible)
}
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross',
1: 'B / Circle',
@@ -398,7 +389,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => {
bindingsRef.current = bindings
@@ -445,11 +435,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
lastCombatNavigationRef.current = now
}
setLastDevice(device)
document.documentElement.dataset.inputDevice = device
+5 -5
View File
@@ -62,7 +62,7 @@
"power": 18,
"unlockLevel": 1,
"glyph": "*",
"description": "Restores health to every living party member."
"description": "Restores health to up to 4 injured party members."
},
{
"id": 4,
@@ -101,7 +101,7 @@
"power": 28,
"unlockLevel": 5,
"glyph": "D",
"description": "A brilliant wave of healing for the entire party."
"description": "A brilliant wave of healing for up to 4 injured allies."
},
{
"id": 7,
@@ -140,7 +140,7 @@
"power": 48,
"unlockLevel": 20,
"glyph": "A",
"description": "Floods the party with the full strength of dawn."
"description": "Floods up to 4 injured allies with the full strength of dawn."
}
],
"talents": [
@@ -345,7 +345,7 @@
"power": 17,
"unlockLevel": 1,
"glyph": "*",
"description": "Restorative growth spreads through the party."
"description": "Restorative growth spreads to up to 4 injured allies."
},
{
"id": 23,
@@ -589,7 +589,7 @@
"power": 18,
"unlockLevel": 1,
"glyph": "*",
"description": "Links the party through a shared healing pattern."
"description": "Links up to 4 injured allies through a shared healing pattern."
},
{
"id": 33,