Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 207fcd1a15 | |||
| fd6a1ce3c7 | |||
| f8b98e6b23 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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' && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user