Android build v1.0.35
This commit is contained in:
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.warren.iwanttoheal"
|
applicationId "com.warren.iwanttoheal"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 53
|
versionCode 54
|
||||||
versionName "1.0.34"
|
versionName "1.0.35"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+13
-9
@@ -678,18 +678,22 @@ JOIN coin_sources
|
|||||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
ON coin_sources.encounter_id = crafting_recipes.source_encounter_id
|
||||||
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
AND coin_sources.difficulty_id = crafting_recipes.difficulty_id;
|
||||||
|
|
||||||
|
DELETE FROM character_talents
|
||||||
|
WHERE talent_id IN (SELECT id FROM talents WHERE class_id = 1);
|
||||||
|
|
||||||
|
DELETE FROM talents WHERE class_id = 1;
|
||||||
|
|
||||||
INSERT OR IGNORE INTO talents
|
INSERT OR IGNORE INTO talents
|
||||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||||
VALUES
|
VALUES
|
||||||
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
|
(1, 1, 'shield-applies-renew', 'Shield applies Renew', 1, 1, 1, NULL, 0, 'shield_applies_renew', 0, '~', 'Sun Ward also applies Renew to the target.'),
|
||||||
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
|
(2, 1, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
|
||||||
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
|
(10, 1, 'mend-adds-shield', 'Mend adds Shield', 1, 1, 3, NULL, 0, 'mend_applies_shield', 0, 'O', 'Mend also applies a shield at 50% strength to the target.'),
|
||||||
(11, 1, 'overflowing-light', 'Overflowing Light', 5, 2, 1, 1, 3, 'resource_regen_percent', 2, 'O', 'Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.'),
|
(11, 1, 'radiance-adds-shield', 'Radiance adds Shield', 1, 1, 4, NULL, 0, 'radiance_applies_shield', 0, 'O', 'Radiance applies a shield at 30% strength to affected party members.'),
|
||||||
(12, 1, 'lingering-rays', 'Lingering Rays', 5, 2, 2, 2, 3, 'hot_duration_percent', 4, 'L', 'Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.'),
|
(12, 1, 'radiance-applies-renew', 'Radiance applies Renew', 1, 1, 5, NULL, 0, 'radiance_applies_renew', 0, '~', 'Radiance applies Renew at 50% duration to affected party members.'),
|
||||||
(13, 1, 'radiant-precision', 'Radiant Precision', 5, 2, 3, 10, 3, 'critical_heal_percent', 1, '!', 'Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.'),
|
(13, 1, 'shielded-damage-reduction', 'Shielded takes less', 1, 1, 6, NULL, 0, 'shielded_damage_reduction', 0, 'D', 'While shielded, the target receives 20% less damage.'),
|
||||||
(14, 1, 'sunlit-aegis', 'Sunlit Aegis', 3, 3, 1, 11, 5, 'absorb_power_percent', 5, 'A', 'Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.'),
|
(14, 1, 'shielded-healing-bonus', 'Shielded healing boost', 1, 1, 7, NULL, 0, 'shielded_healing_bonus', 0, '+', 'While shielded, the target receives 20% more healing.'),
|
||||||
(15, 1, 'shared-dawn', 'Shared Dawn', 3, 3, 2, 12, 5, 'party_heal_percent', 5, '*', 'Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.'),
|
(15, 1, 'mend-reduces-radiance', 'Mend lowers Radiance', 1, 1, 8, NULL, 0, 'mend_reduces_radiance_cooldown', 0, '*', 'Casting Mend reduces the cooldown of Radiance by 2 seconds.'),
|
||||||
(16, 1, 'miracle-worker', 'Miracle Worker', 1, 4, 2, 15, 3, 'cooldown_reduction_percent', 10, 'S', 'Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.'),
|
|
||||||
|
|
||||||
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
|
(3, 2, 'deep-roots', 'Deep Roots', 5, 1, 1, NULL, 0, 'max_resource', 2, 'R', 'Increases maximum Bloom by 2 per rank.'),
|
||||||
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
|
(20, 2, 'patient-growth', 'Patient Growth', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, 's', 'Increases healing-over-time power by 2% per rank.'),
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="540" viewBox="0 0 620 540" role="img" aria-label="Ayn Thor secondary display spell effect quick swap mockup">
|
||||||
|
<rect width="620" height="540" fill="#111219"/>
|
||||||
|
<rect x="16" y="16" width="588" height="48" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="32" y="36" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SPELL EFFECTS</text>
|
||||||
|
<text x="32" y="56" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Quick Swap</text>
|
||||||
|
<text x="460" y="47" fill="#83d99b" font-family="monospace" font-size="13" font-weight="900">4/4 ACTIVE</text>
|
||||||
|
|
||||||
|
<g transform="translate(16 82)">
|
||||||
|
<rect width="588" height="118" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">ACTIVE SLOTS</text>
|
||||||
|
<g transform="translate(14 46)">
|
||||||
|
<rect width="130" height="54" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="10" y="20" fill="#e5b95f" font-family="monospace" font-size="11" font-weight="900">LV 5</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(154 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#6da7df" font-family="monospace" font-size="11" font-weight="900">LV 10</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Rad Shield</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(294 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#4fb978" font-family="monospace" font-size="11" font-weight="900">LV 15</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Shield DR</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(434 46)">
|
||||||
|
<rect width="130" height="54" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="10" y="20" fill="#b16dde" font-family="monospace" font-size="11" font-weight="900">LV 20</text>
|
||||||
|
<text x="10" y="39" fill="#f7f2d7" font-family="monospace" font-size="12" font-weight="900">Mend CD</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(16 218)">
|
||||||
|
<rect width="278" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">POOL</text>
|
||||||
|
<g transform="translate(14 46)">
|
||||||
|
<rect width="250" height="42" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="12" y="26" fill="#f5e6b2" font-family="monospace" font-size="13" font-weight="900">Mend applies Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 98)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Shield applies Renew</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 150)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Mend adds Shield</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(14 202)">
|
||||||
|
<rect width="250" height="42" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="12" y="26" fill="#f7f2d7" font-family="monospace" font-size="13" font-weight="900">Radiance Renew</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(310 218)">
|
||||||
|
<rect width="294" height="286" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="16" y="28" fill="#8aa0b7" font-family="monospace" font-size="10" font-weight="700">DETAIL</text>
|
||||||
|
<text x="16" y="58" fill="#f5e6b2" font-family="monospace" font-size="18" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="16" y="92" fill="#d7dbe0" font-family="monospace" font-size="13">Mend also applies Renew to</text>
|
||||||
|
<text x="16" y="112" fill="#d7dbe0" font-family="monospace" font-size="13">the target.</text>
|
||||||
|
<text x="16" y="150" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Rule: same HoT refreshes.</text>
|
||||||
|
<text x="16" y="172" fill="#83d99b" font-family="monospace" font-size="12" font-weight="900">Different HoTs coexist.</text>
|
||||||
|
<rect x="16" y="216" width="120" height="44" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="50" y="244" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||||
|
<rect x="154" y="216" width="120" height="44" rx="3" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="184" y="244" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Clear</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Ayn Thor main display talent effect planner mockup">
|
||||||
|
<rect width="960" height="540" fill="#111219"/>
|
||||||
|
<rect x="20" y="18" width="920" height="50" fill="#20222d" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="38" y="39" fill="#8aa0b7" font-family="monospace" font-size="12" font-weight="700">CHARACTER WORKSHOP</text>
|
||||||
|
<text x="38" y="59" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Spell Effects</text>
|
||||||
|
<rect x="764" y="28" width="154" height="30" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="786" y="49" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">Save Loadout</text>
|
||||||
|
|
||||||
|
<g transform="translate(20 88)">
|
||||||
|
<rect width="294" height="420" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">UNLOCKED SLOTS</text>
|
||||||
|
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">4 active effects</text>
|
||||||
|
|
||||||
|
<g transform="translate(16 76)">
|
||||||
|
<rect width="262" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#e5b95f"/>
|
||||||
|
<text x="22" y="34" fill="#21180a" font-family="monospace" font-size="13" font-weight="900">5</text>
|
||||||
|
<text x="56" y="25" fill="#f5e6b2" font-family="monospace" font-size="15" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="56" y="45" fill="#83d99b" font-family="monospace" font-size="11">Selected</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 148)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#6da7df"/>
|
||||||
|
<text x="18" y="34" fill="#08111c" font-family="monospace" font-size="12" font-weight="900">10</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Radiance adds shield</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">30 percent strength</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 220)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#4fb978"/>
|
||||||
|
<text x="18" y="34" fill="#071408" font-family="monospace" font-size="12" font-weight="900">15</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Shielded takes less</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">20 percent damage cut</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(16 292)">
|
||||||
|
<rect width="262" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<circle cx="29" cy="29" r="15" fill="#b16dde"/>
|
||||||
|
<text x="18" y="34" fill="#15071c" font-family="monospace" font-size="12" font-weight="900">20</text>
|
||||||
|
<text x="56" y="25" fill="#f7f2d7" font-family="monospace" font-size="15" font-weight="900">Mend lowers Radiance</text>
|
||||||
|
<text x="56" y="45" fill="#8aa0b7" font-family="monospace" font-size="11">-2 sec cooldown</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(334 88)">
|
||||||
|
<rect width="606" height="286" fill="#191b25" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="28" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">EFFECT POOL</text>
|
||||||
|
<text x="18" y="53" fill="#f7f2d7" font-family="monospace" font-size="19" font-weight="900">Pick effects, swap anytime</text>
|
||||||
|
|
||||||
|
<g transform="translate(18 76)">
|
||||||
|
<rect width="276" height="58" fill="#29291f" stroke="#e5b95f" stroke-width="3"/>
|
||||||
|
<text x="14" y="24" fill="#f5e6b2" font-family="monospace" font-size="14" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Direct heal also applies Renew.</text>
|
||||||
|
<text x="226" y="24" fill="#83d99b" font-family="monospace" font-size="10" font-weight="900">ON</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 76)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shield applies Renew</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">Sun Ward adds a Renew effect.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(18 148)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Mend adds Shield</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent shield strength.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 148)">
|
||||||
|
<rect width="276" height="58" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="24" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance adds Shield</text>
|
||||||
|
<text x="14" y="45" fill="#b7c3d0" font-family="monospace" font-size="11">30 percent to affected allies.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(18 220)">
|
||||||
|
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Radiance applies Renew</text>
|
||||||
|
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">50 percent duration.</text>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(312 220)">
|
||||||
|
<rect width="276" height="46" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="14" y="20" fill="#f7f2d7" font-family="monospace" font-size="14" font-weight="900">Shielded gets +healing</text>
|
||||||
|
<text x="14" y="38" fill="#b7c3d0" font-family="monospace" font-size="11">20 percent more healing.</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(334 392)">
|
||||||
|
<rect width="606" height="116" fill="#20222d" stroke="#3a3944" stroke-width="2"/>
|
||||||
|
<text x="18" y="26" fill="#8aa0b7" font-family="monospace" font-size="11" font-weight="700">SELECTED EFFECT</text>
|
||||||
|
<text x="18" y="52" fill="#f5e6b2" font-family="monospace" font-size="20" font-weight="900">Mend applies Renew</text>
|
||||||
|
<text x="18" y="78" fill="#d7dbe0" font-family="monospace" font-size="13">Casting Mend also applies Renew to the same target. Renew refreshes itself; different HoTs coexist.</text>
|
||||||
|
<rect x="470" y="32" width="112" height="42" rx="3" fill="#e5b95f" stroke="#0a0b0e" stroke-width="3"/>
|
||||||
|
<text x="497" y="58" fill="#21180a" font-family="monospace" font-size="14" font-weight="900">Equip</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.6 KiB |
+50
-1
@@ -1862,9 +1862,13 @@ function upgradeItem(database, characterId, itemId) {
|
|||||||
return getProfile(database, characterId)
|
return getProfile(database, characterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function talentEffectCapacity(level) {
|
||||||
|
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||||
|
}
|
||||||
|
|
||||||
function allocateTalent(database, characterId, talentId) {
|
function allocateTalent(database, characterId, talentId) {
|
||||||
const character = database.prepare(`
|
const character = database.prepare(`
|
||||||
SELECT class_id AS classId, talent_points AS talentPoints
|
SELECT class_id AS classId, level, talent_points AS talentPoints
|
||||||
FROM characters
|
FROM characters
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).get(characterId)
|
`).get(characterId)
|
||||||
@@ -1884,6 +1888,49 @@ function allocateTalent(database, characterId, talentId) {
|
|||||||
if (!talent || talent.classId !== character.classId) {
|
if (!talent || talent.classId !== character.classId) {
|
||||||
throw new Error('That talent does not belong to the active class.')
|
throw new Error('That talent does not belong to the active class.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (character.classId === 1) {
|
||||||
|
const currentRank = database.prepare(`
|
||||||
|
SELECT rank
|
||||||
|
FROM character_talents
|
||||||
|
WHERE character_id = ? AND talent_id = ?
|
||||||
|
`).get(characterId, talentId)?.rank ?? 0
|
||||||
|
database.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
if (currentRank > 0) {
|
||||||
|
database.prepare(`
|
||||||
|
DELETE FROM character_talents
|
||||||
|
WHERE character_id = ? AND talent_id = ?
|
||||||
|
`).run(characterId, talentId)
|
||||||
|
} else {
|
||||||
|
const capacity = talentEffectCapacity(character.level)
|
||||||
|
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||||
|
const activeCount = database.prepare(`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM character_talents
|
||||||
|
JOIN talents ON talents.id = character_talents.talent_id
|
||||||
|
WHERE character_talents.character_id = ?
|
||||||
|
AND talents.class_id = ?
|
||||||
|
AND character_talents.rank > 0
|
||||||
|
`).get(characterId, character.classId).count
|
||||||
|
if (activeCount >= capacity) {
|
||||||
|
throw new Error(`Level ${character.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||||
|
}
|
||||||
|
database.prepare(`
|
||||||
|
INSERT INTO character_talents (character_id, talent_id, rank)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON CONFLICT(character_id, talent_id)
|
||||||
|
DO UPDATE SET rank = 1
|
||||||
|
`).run(characterId, talentId)
|
||||||
|
}
|
||||||
|
database.exec('COMMIT')
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return getProfile(database, characterId)
|
||||||
|
}
|
||||||
|
|
||||||
if (character.talentPoints <= 0) {
|
if (character.talentPoints <= 0) {
|
||||||
throw new Error('No talent points are available.')
|
throw new Error('No talent points are available.')
|
||||||
}
|
}
|
||||||
@@ -1962,11 +2009,13 @@ function resetTalents(database, characterId) {
|
|||||||
WHERE character_id = ?
|
WHERE character_id = ?
|
||||||
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||||
`).run(characterId, character.classId)
|
`).run(characterId, character.classId)
|
||||||
|
if (character.classId !== 1) {
|
||||||
database.prepare(`
|
database.prepare(`
|
||||||
UPDATE characters
|
UPDATE characters
|
||||||
SET talent_points = MIN(level, talent_points + ?)
|
SET talent_points = MIN(level, talent_points + ?)
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(refunded, characterId)
|
`).run(refunded, characterId)
|
||||||
|
}
|
||||||
database.exec('COMMIT')
|
database.exec('COMMIT')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
|
|||||||
+181
@@ -3454,6 +3454,174 @@ h2 {
|
|||||||
margin-top: 17px;
|
margin-top: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.talent-empty-state {
|
||||||
|
background: var(--panel-light);
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
margin-top: 17px;
|
||||||
|
outline: 2px solid #41404a;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spell-effect-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr) 260px;
|
||||||
|
margin-top: 17px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slots-panel,
|
||||||
|
.effect-pool-panel,
|
||||||
|
.effect-detail-panel {
|
||||||
|
background: #191b25;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
min-height: 0;
|
||||||
|
outline: 2px solid #3a3944;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slots-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-auto-rows: minmax(76px, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot {
|
||||||
|
background: #20222d;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: 2px solid #3a3944;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot.filled {
|
||||||
|
background: #29291f;
|
||||||
|
outline-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot.locked {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot span,
|
||||||
|
.effect-pool > button i {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot strong,
|
||||||
|
.effect-slot small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot strong {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-slot small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-panel-heading {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-panel-heading > span {
|
||||||
|
color: var(--gold);
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool > button {
|
||||||
|
align-items: center;
|
||||||
|
background: #20222d;
|
||||||
|
border: 2px solid #090a0d;
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||||
|
min-height: 72px;
|
||||||
|
outline: 2px solid #3a3944;
|
||||||
|
padding: 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool > button.active {
|
||||||
|
background: #29291f;
|
||||||
|
outline-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool > button.selected {
|
||||||
|
border-color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool > button:disabled:not(.active) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool > button > span {
|
||||||
|
align-items: center;
|
||||||
|
background: #15161c;
|
||||||
|
border: 1px solid #55515f;
|
||||||
|
color: var(--gold);
|
||||||
|
display: flex;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
height: 34px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool strong,
|
||||||
|
.effect-pool small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool strong {
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-pool small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-detail-panel h2 {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 22px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-detail-panel p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.talent-tier {
|
.talent-tier {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-bottom: 1px solid #393943;
|
border-bottom: 1px solid #393943;
|
||||||
@@ -5109,6 +5277,19 @@ h2 {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speed-badge {
|
||||||
|
background: var(--gold);
|
||||||
|
border: 2px solid #0a0b0e;
|
||||||
|
color: #21180a;
|
||||||
|
display: inline-block;
|
||||||
|
font-family: 'Press Start 2P', monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 0 5px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.action-panel .resource-row {
|
.action-panel .resource-row {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-33
@@ -105,18 +105,18 @@ function effectiveMaxHealth(member: PartyMember) {
|
|||||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function healAmount(member: PartyMember, amount: number) {
|
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
function healMember(member: PartyMember, amount: number) {
|
function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||||
}
|
}
|
||||||
|
|
||||||
function memberHotEffects(member: PartyMember) {
|
function memberHotEffects(member: PartyMember) {
|
||||||
if (member.hotEffects?.length) return member.hotEffects
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
return member.hotTicks > 0
|
return member.hotTicks > 0
|
||||||
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,15 +125,15 @@ function effectId(prefix: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||||
return [
|
const nextEffect = {
|
||||||
...memberHotEffects(member),
|
|
||||||
{
|
|
||||||
id: effectId(spell.id),
|
id: effectId(spell.id),
|
||||||
|
spellId: spell.id,
|
||||||
label: spell.name,
|
label: spell.name,
|
||||||
ticks,
|
ticks,
|
||||||
power: Math.max(1, Math.round(spell.power / 2)),
|
power: Math.max(1, Math.round(spell.power / 2)),
|
||||||
},
|
}
|
||||||
]
|
const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
|
||||||
|
return [...currentEffects, nextEffect]
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBounceHeal(member: PartyMember, spell: Spell) {
|
function addBounceHeal(member: PartyMember, spell: Spell) {
|
||||||
@@ -418,6 +418,7 @@ export function CombatScreen({
|
|||||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||||
const [paused, setPaused] = useState(false)
|
const [paused, setPaused] = useState(false)
|
||||||
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||||
@@ -445,6 +446,7 @@ export function CombatScreen({
|
|||||||
const lastCombatTickAtRef = useRef(performance.now())
|
const lastCombatTickAtRef = useRef(performance.now())
|
||||||
const statusRef = useRef(status)
|
const statusRef = useRef(status)
|
||||||
const pausedRef = useRef(paused)
|
const pausedRef = useRef(paused)
|
||||||
|
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
||||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||||
const encounter = encounters[encounterIndex]
|
const encounter = encounters[encounterIndex]
|
||||||
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||||
@@ -463,11 +465,21 @@ export function CombatScreen({
|
|||||||
const playerHealer = party.find((member) => member.id === 'mira')
|
const playerHealer = party.find((member) => member.id === 'mira')
|
||||||
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
||||||
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
||||||
const activeSetEffects = useMemo(
|
const activeEffects = useMemo(
|
||||||
() => isRoguelike
|
() => {
|
||||||
? new Set<string>()
|
const effects = new Set<string>(
|
||||||
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
gameClass.talents
|
||||||
[isRoguelike, profile.setBonuses],
|
.filter((talent) => talent.rank > 0)
|
||||||
|
.map((talent) => talent.effectType),
|
||||||
|
)
|
||||||
|
if (!isRoguelike) {
|
||||||
|
profile.setBonuses
|
||||||
|
.filter((bonus) => bonus.active)
|
||||||
|
.forEach((bonus) => effects.add(bonus.effectType))
|
||||||
|
}
|
||||||
|
return effects
|
||||||
|
},
|
||||||
|
[gameClass.talents, isRoguelike, profile.setBonuses],
|
||||||
)
|
)
|
||||||
const {
|
const {
|
||||||
bindings,
|
bindings,
|
||||||
@@ -481,6 +493,7 @@ export function CombatScreen({
|
|||||||
|
|
||||||
statusRef.current = status
|
statusRef.current = status
|
||||||
pausedRef.current = paused
|
pausedRef.current = paused
|
||||||
|
speedMultiplierRef.current = speedMultiplier
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -619,6 +632,14 @@ export function CombatScreen({
|
|||||||
const extraTarget = (blockedIds: string[]) => current.party
|
const extraTarget = (blockedIds: string[]) => current.party
|
||||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
|
const effectSpell = (name: string) => {
|
||||||
|
const ability = gameClass.spells.find((candidate) => candidate.name === name)
|
||||||
|
return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
|
||||||
|
}
|
||||||
|
const renewEffect = effectSpell('Renew')
|
||||||
|
const shieldEffect = effectSpell('Sun Ward')
|
||||||
|
const healingMultiplier = (member: PartyMember) =>
|
||||||
|
activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||||
const directTargets = new Set([targetId])
|
const directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set<string>()
|
const hotTargets = new Set<string>()
|
||||||
const shieldTargets = new Set<string>()
|
const shieldTargets = new Set<string>()
|
||||||
@@ -630,15 +651,15 @@ export function CombatScreen({
|
|||||||
)
|
)
|
||||||
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
|
||||||
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
if (spell.kind === 'shield') shieldTargets.add(targetId)
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_extra_target')) {
|
if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
|
||||||
const extra = extraTarget([targetId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Renew' && activeSetEffects.has('renew_extra_target')) {
|
if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
|
||||||
const extra = extraTarget([targetId])
|
const extra = extraTarget([targetId])
|
||||||
if (extra) hotTargets.add(extra.id)
|
if (extra) hotTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
if (spell.name === 'Mend' && activeSetEffects.has('mend_applies_renew')) {
|
if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
|
||||||
hotTargets.add(targetId)
|
hotTargets.add(targetId)
|
||||||
}
|
}
|
||||||
for (let index = 0; index < extraTargets; index += 1) {
|
for (let index = 0; index < extraTargets; index += 1) {
|
||||||
@@ -673,9 +694,20 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, power)
|
const nextHealth = healMember(member, power, healingMultiplier(member))
|
||||||
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
|
||||||
return { ...member, health: nextHealth }
|
const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield')
|
||||||
|
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
|
||||||
|
: member.shield
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
health: nextHealth,
|
||||||
|
shield: nextShield,
|
||||||
|
hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks,
|
||||||
|
hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect
|
||||||
|
? addHotEffect(member, renewEffect, 3)
|
||||||
|
: member.hotEffects,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!directTargets.has(member.id)
|
!directTargets.has(member.id)
|
||||||
@@ -685,7 +717,14 @@ export function CombatScreen({
|
|||||||
) return member
|
) return member
|
||||||
if (spell.kind === 'shield') {
|
if (spell.kind === 'shield') {
|
||||||
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
|
||||||
return { ...member, shield: Math.max(member.shield, power) }
|
return {
|
||||||
|
...member,
|
||||||
|
hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks,
|
||||||
|
hotEffects: activeEffects.has('shield_applies_renew') && renewEffect
|
||||||
|
? addHotEffect(member, renewEffect)
|
||||||
|
: member.hotEffects,
|
||||||
|
shield: Math.max(member.shield, power),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (spell.kind === 'damage_reduction') {
|
if (spell.kind === 'damage_reduction') {
|
||||||
return { ...member, damageReductionTicks: 12 }
|
return { ...member, damageReductionTicks: 12 }
|
||||||
@@ -696,7 +735,7 @@ export function CombatScreen({
|
|||||||
if (spell.kind === 'cleanse') {
|
if (spell.kind === 'cleanse') {
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: healMember(member, spell.power),
|
health: healMember(member, spell.power, healingMultiplier(member)),
|
||||||
debuff: undefined,
|
debuff: undefined,
|
||||||
debuffTicks: undefined,
|
debuffTicks: undefined,
|
||||||
poisonStacks: undefined,
|
poisonStacks: undefined,
|
||||||
@@ -705,14 +744,23 @@ export function CombatScreen({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextHealth = directTargets.has(member.id)
|
const nextHealth = directTargets.has(member.id)
|
||||||
? healMember(member, spell.power)
|
? healMember(member, spell.power, healingMultiplier(member))
|
||||||
: member.health
|
: member.health
|
||||||
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
|
if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
|
||||||
|
const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
|
||||||
|
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||||
|
: member.shield
|
||||||
|
const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
|
||||||
|
? renewEffect
|
||||||
|
: spell
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: nextHealth,
|
health: nextHealth,
|
||||||
|
shield: nextShield,
|
||||||
hotTicks: 0,
|
hotTicks: 0,
|
||||||
hotEffects: hotTargets.has(member.id) ? addHotEffect(member, spell) : member.hotEffects,
|
hotEffects: hotTargets.has(member.id)
|
||||||
|
? addHotEffect(member, appliedHotSpell)
|
||||||
|
: member.hotEffects,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
|
||||||
@@ -730,20 +778,26 @@ export function CombatScreen({
|
|||||||
&& !current.freeCastReady
|
&& !current.freeCastReady
|
||||||
&& current.castsTowardFree + 1 >= 5
|
&& current.castsTowardFree + 1 >= 5
|
||||||
resourceSpentRef.current += effectiveCost
|
resourceSpentRef.current += effectiveCost
|
||||||
|
const nextCooldowns = {
|
||||||
|
...current.cooldowns,
|
||||||
|
}
|
||||||
|
if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
|
||||||
|
const radiance = spells.find((candidate) => candidate.name === 'Radiance')
|
||||||
|
if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
|
||||||
|
}
|
||||||
|
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)
|
||||||
|
|
||||||
setCombat({
|
setCombat({
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: current.resource - effectiveCost,
|
resource: current.resource - effectiveCost,
|
||||||
cooldowns: {
|
cooldowns: nextCooldowns,
|
||||||
...current.cooldowns,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
|
||||||
},
|
|
||||||
castsTowardFree: nextCastsTowardFree,
|
castsTowardFree: nextCastsTowardFree,
|
||||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
})
|
})
|
||||||
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
|
||||||
},
|
},
|
||||||
[activeSetEffects, addFloatingHeal, addLog, roguelikeUpgrades, setCombat, status],
|
[activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishRun = useCallback(
|
const finishRun = useCallback(
|
||||||
@@ -909,6 +963,10 @@ export function CombatScreen({
|
|||||||
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||||
|
|
||||||
useGameAction((action, device) => {
|
useGameAction((action, device) => {
|
||||||
|
if (action === 'toggleSpeed') {
|
||||||
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
return
|
return
|
||||||
@@ -1021,13 +1079,17 @@ export function CombatScreen({
|
|||||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||||
damage = Math.round(damage * 0.5)
|
damage = Math.round(damage * 0.5)
|
||||||
}
|
}
|
||||||
|
if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) {
|
||||||
|
damage = Math.round(damage * 0.8)
|
||||||
|
}
|
||||||
const absorbed = Math.min(member.shield, damage)
|
const absorbed = Math.min(member.shield, damage)
|
||||||
const hotEffects = memberHotEffects(member)
|
const hotEffects = memberHotEffects(member)
|
||||||
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power), 0)
|
const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
|
||||||
|
let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
|
||||||
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
let nextBounceHeals = [...(member.bounceHeals ?? [])]
|
||||||
if (damage > 0 && nextBounceHeals.length > 0) {
|
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||||
healing += healAmount(member, effect.power)
|
healing += healAmount(member, effect.power, healingMultiplier)
|
||||||
const nextCharges = effect.charges - 1
|
const nextCharges = effect.charges - 1
|
||||||
if (nextCharges <= 0) return []
|
if (nextCharges <= 0) return []
|
||||||
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||||
@@ -1192,6 +1254,7 @@ export function CombatScreen({
|
|||||||
})
|
})
|
||||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||||
}, [
|
}, [
|
||||||
|
activeEffects,
|
||||||
addLog,
|
addLog,
|
||||||
addFloatingHeal,
|
addFloatingHeal,
|
||||||
difficulty.damageMultiplier,
|
difficulty.damageMultiplier,
|
||||||
@@ -1239,9 +1302,10 @@ export function CombatScreen({
|
|||||||
|| pausedRef.current
|
|| pausedRef.current
|
||||||
) return
|
) return
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
const dueTicks = Math.min(4, Math.floor((now - lastCombatTickAtRef.current) / TICK_MS))
|
const tickMs = TICK_MS / speedMultiplierRef.current
|
||||||
|
const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
|
||||||
if (dueTicks <= 0) return
|
if (dueTicks <= 0) return
|
||||||
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
lastCombatTickAtRef.current += dueTicks * tickMs
|
||||||
for (let index = 0; index < dueTicks; index += 1) {
|
for (let index = 0; index < dueTicks; index += 1) {
|
||||||
if (statusRef.current !== 'playing' || pausedRef.current) return
|
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||||
runCombatTickRef.current()
|
runCombatTickRef.current()
|
||||||
@@ -1310,6 +1374,7 @@ export function CombatScreen({
|
|||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
paused,
|
paused,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
|
speedMultiplier,
|
||||||
}), [
|
}), [
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
@@ -1340,6 +1405,7 @@ export function CombatScreen({
|
|||||||
spells,
|
spells,
|
||||||
freeCastReady,
|
freeCastReady,
|
||||||
roguelikeUpgrades,
|
roguelikeUpgrades,
|
||||||
|
speedMultiplier,
|
||||||
status,
|
status,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
])
|
])
|
||||||
@@ -1403,6 +1469,7 @@ export function CombatScreen({
|
|||||||
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
||||||
: `${profile.character.name} is defeated`}
|
: `${profile.character.name} is defeated`}
|
||||||
</span>
|
</span>
|
||||||
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -251,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
|||||||
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
|
||||||
}
|
}
|
||||||
|
|
||||||
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||||
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
|
||||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs))
|
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||||
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
|
||||||
}
|
}
|
||||||
|
|
||||||
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
||||||
@@ -446,6 +446,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||||
|
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||||
const [queueMessage, setQueueMessage] = useState('')
|
const [queueMessage, setQueueMessage] = useState('')
|
||||||
@@ -483,6 +484,14 @@ export function PvPRoguelikeScreen({
|
|||||||
? Math.max(encountersCleared, encounterIndex + 1)
|
? Math.max(encountersCleared, encounterIndex + 1)
|
||||||
: encountersCleared
|
: encountersCleared
|
||||||
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
|
||||||
|
const activeSpellEffects = useMemo(
|
||||||
|
() => new Set(
|
||||||
|
gameClass.talents
|
||||||
|
.filter((talent) => talent.rank > 0)
|
||||||
|
.map((talent) => talent.effectType),
|
||||||
|
),
|
||||||
|
[gameClass.talents],
|
||||||
|
)
|
||||||
const playerDone = playerSide.enemyHealth <= 0
|
const playerDone = playerSide.enemyHealth <= 0
|
||||||
const cpuDone = cpuSide.enemyHealth <= 0
|
const cpuDone = cpuSide.enemyHealth <= 0
|
||||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||||
@@ -677,6 +686,12 @@ export function PvPRoguelikeScreen({
|
|||||||
const extraTarget = (blockedIds: string[]) => livingTargets
|
const extraTarget = (blockedIds: string[]) => livingTargets
|
||||||
.filter((member) => !blockedIds.includes(member.id))
|
.filter((member) => !blockedIds.includes(member.id))
|
||||||
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
|
||||||
|
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||||
|
const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
|
||||||
|
const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
|
||||||
|
const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
|
||||||
|
const healingMultiplier = (member: PartyMember) =>
|
||||||
|
hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
|
||||||
const directTargets = new Set([targetId])
|
const directTargets = new Set([targetId])
|
||||||
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||||
@@ -701,22 +716,45 @@ export function PvPRoguelikeScreen({
|
|||||||
const extra = extraTarget([...directTargets])
|
const extra = extraTarget([...directTargets])
|
||||||
if (extra) directTargets.add(extra.id)
|
if (extra) directTargets.add(extra.id)
|
||||||
}
|
}
|
||||||
|
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
|
||||||
|
directTargets.forEach((id) => hotTargets.add(id))
|
||||||
|
}
|
||||||
|
if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
|
||||||
|
directTargets.forEach((id) => shieldTargets.add(id))
|
||||||
|
}
|
||||||
|
if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
|
||||||
|
shieldTargets.forEach((id) => hotTargets.add(id))
|
||||||
|
}
|
||||||
const nextParty = current.party.map((member) => {
|
const nextParty = current.party.map((member) => {
|
||||||
if (member.health <= 0) return member
|
if (member.health <= 0) return member
|
||||||
if (spell.kind === 'group') {
|
if (spell.kind === 'group') {
|
||||||
if (!groupTargets.has(member.id)) return member
|
if (!groupTargets.has(member.id)) return member
|
||||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||||
const nextHealth = healMember(member, groupPower, debuffs)
|
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
||||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
return { ...member, health: nextHealth }
|
const nextShield = hasSpellEffect('radiance_applies_shield')
|
||||||
|
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
|
||||||
|
: member.shield
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
health: nextHealth,
|
||||||
|
shield: nextShield,
|
||||||
|
hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect
|
||||||
|
? Math.max(member.hotTicks, 3)
|
||||||
|
: member.hotTicks,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
|
||||||
if (spell.kind === 'shield') {
|
if (spell.kind === 'shield') {
|
||||||
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
|
||||||
return { ...member, shield: Math.max(member.shield, shieldPower) }
|
return {
|
||||||
|
...member,
|
||||||
|
shield: Math.max(member.shield, shieldPower),
|
||||||
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (spell.kind === 'cleanse') {
|
if (spell.kind === 'cleanse') {
|
||||||
const nextHealth = healMember(member, spell.power, debuffs)
|
const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||||
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
@@ -728,11 +766,17 @@ export function PvPRoguelikeScreen({
|
|||||||
healingReductionTicks: undefined,
|
healingReductionTicks: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const nextHealth = directTargets.has(member.id) ? healMember(member, spell.power, debuffs) : member.health
|
const nextHealth = directTargets.has(member.id)
|
||||||
|
? healMember(member, spell.power, debuffs, healingMultiplier(member))
|
||||||
|
: member.health
|
||||||
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
|
if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
|
||||||
|
const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield')
|
||||||
|
? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
|
||||||
|
: member.shield
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
health: nextHealth,
|
health: nextHealth,
|
||||||
|
shield: nextShield,
|
||||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -746,20 +790,24 @@ export function PvPRoguelikeScreen({
|
|||||||
: current.castsTowardFree + 1
|
: current.castsTowardFree + 1
|
||||||
: current.castsTowardFree
|
: current.castsTowardFree
|
||||||
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
|
||||||
|
const nextCooldowns = {
|
||||||
|
...current.cooldowns,
|
||||||
|
}
|
||||||
|
if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
|
||||||
|
nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
|
||||||
|
}
|
||||||
|
nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs)
|
||||||
const nextState: SideState = {
|
const nextState: SideState = {
|
||||||
...current,
|
...current,
|
||||||
party: nextParty,
|
party: nextParty,
|
||||||
resource: current.resource - effectiveCost,
|
resource: current.resource - effectiveCost,
|
||||||
cooldowns: {
|
cooldowns: nextCooldowns,
|
||||||
...current.cooldowns,
|
|
||||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
|
||||||
},
|
|
||||||
castsTowardFree: nextCastsTowardFree,
|
castsTowardFree: nextCastsTowardFree,
|
||||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||||
}
|
}
|
||||||
setCurrent(nextState)
|
setCurrent(nextState)
|
||||||
return true
|
return true
|
||||||
}, [addFloatingHeal])
|
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
||||||
|
|
||||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||||
if (status !== 'playing' || playerDone || !playerAlive) return
|
if (status !== 'playing' || playerDone || !playerAlive) return
|
||||||
@@ -867,6 +915,7 @@ export function PvPRoguelikeScreen({
|
|||||||
const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
|
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 appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||||
|
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||||
const tankPressure = tankPressureTargets(side.party)
|
const tankPressure = tankPressureTargets(side.party)
|
||||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||||
const nextParty = side.party.map((member) => {
|
const nextParty = side.party.map((member) => {
|
||||||
@@ -882,8 +931,12 @@ export function PvPRoguelikeScreen({
|
|||||||
: member.poisonStacks ?? 0
|
: member.poisonStacks ?? 0
|
||||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||||
damage = Math.round(damage * damageMultiplier)
|
damage = Math.round(damage * damageMultiplier)
|
||||||
|
if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
|
||||||
|
damage = Math.round(damage * 0.8)
|
||||||
|
}
|
||||||
const absorbed = Math.min(member.shield, damage)
|
const absorbed = Math.min(member.shield, damage)
|
||||||
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs) : 0
|
const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1
|
||||||
|
const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0
|
||||||
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
if (healing > 0) addFloatingHeal(sideName, member.id, healing)
|
||||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||||
? 14
|
? 14
|
||||||
@@ -925,7 +978,7 @@ export function PvPRoguelikeScreen({
|
|||||||
),
|
),
|
||||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||||
}
|
}
|
||||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||||
|
|
||||||
const beginUpgradePhase = useCallback(() => {
|
const beginUpgradePhase = useCallback(() => {
|
||||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||||
@@ -985,9 +1038,9 @@ export function PvPRoguelikeScreen({
|
|||||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||||
beginUpgradePhase()
|
beginUpgradePhase()
|
||||||
}
|
}
|
||||||
}, TICK_MS)
|
}, TICK_MS / speedMultiplier)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, stage, status])
|
}, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, paused, profile.character.id, speedMultiplier, stage, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
|
||||||
@@ -1104,6 +1157,10 @@ export function PvPRoguelikeScreen({
|
|||||||
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
|
||||||
|
|
||||||
useGameAction((action) => {
|
useGameAction((action) => {
|
||||||
|
if (action === 'toggleSpeed') {
|
||||||
|
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action === 'pause' || action === 'back') {
|
if (action === 'pause' || action === 'back') {
|
||||||
if (status === 'playing') setPaused((value) => !value)
|
if (status === 'playing') setPaused((value) => !value)
|
||||||
return
|
return
|
||||||
@@ -1175,6 +1232,7 @@ export function PvPRoguelikeScreen({
|
|||||||
directPartyTargeting,
|
directPartyTargeting,
|
||||||
paused,
|
paused,
|
||||||
targetGroup,
|
targetGroup,
|
||||||
|
speedMultiplier,
|
||||||
}), [
|
}), [
|
||||||
bindings,
|
bindings,
|
||||||
controllerIconStyle,
|
controllerIconStyle,
|
||||||
@@ -1199,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
|||||||
playerSide.party,
|
playerSide.party,
|
||||||
playerSide.resource,
|
playerSide.resource,
|
||||||
selectedId,
|
selectedId,
|
||||||
|
speedMultiplier,
|
||||||
stage,
|
stage,
|
||||||
starterSpells,
|
starterSpells,
|
||||||
status,
|
status,
|
||||||
@@ -1237,6 +1296,7 @@ export function PvPRoguelikeScreen({
|
|||||||
<div className="resource-row pvp-resource-row">
|
<div className="resource-row pvp-resource-row">
|
||||||
<div className="pvp-resource-wrap">
|
<div className="pvp-resource-wrap">
|
||||||
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
<span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
|
||||||
|
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
<div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+143
-103
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
allocateTalent,
|
allocateTalent,
|
||||||
resetTalents,
|
resetTalents,
|
||||||
type CharacterProfile,
|
type CharacterProfile,
|
||||||
type Talent,
|
type Talent,
|
||||||
} from '../profile'
|
} from '../profile'
|
||||||
|
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: CharacterProfile
|
profile: CharacterProfile
|
||||||
@@ -13,199 +14,238 @@ type Props = {
|
|||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||||
|
const EFFECT_CLASS_ID = 1
|
||||||
|
|
||||||
|
function effectCapacity(level: number) {
|
||||||
|
return EFFECT_SLOT_LEVELS.filter((slotLevel) => level >= slotLevel).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeEffects(talents: Talent[]) {
|
||||||
|
return talents.filter((talent) => talent.rank > 0)
|
||||||
|
}
|
||||||
|
|
||||||
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
export function TalentScreen({ profile, onBack, onUpdated, embedded = false }: Props) {
|
||||||
|
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||||
const [talentPage, setTalentPage] = useState(0)
|
|
||||||
const [resetting, setResetting] = useState(false)
|
const [resetting, setResetting] = useState(false)
|
||||||
|
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const scrollRef = useRef<number>(0)
|
const scrollRef = useRef<number>(0)
|
||||||
const gameClass = profile.classes.find(
|
const gameClass = profile.classes.find(
|
||||||
(candidate) => candidate.id === profile.character.classId,
|
(candidate) => candidate.id === profile.character.classId,
|
||||||
)!
|
)!
|
||||||
const classPointsSpent = gameClass.talents.reduce(
|
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||||
(total, talent) => total + talent.rank,
|
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||||
0,
|
const selectedEffects = activeEffects(gameClass.talents)
|
||||||
)
|
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
||||||
const tiers = Array.from(
|
?? selectedEffects[0]
|
||||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
?? gameClass.talents[0]
|
||||||
).sort((a, b) => a - b)
|
?? null
|
||||||
const tierPages = Array.from(
|
|
||||||
{ length: Math.ceil(tiers.length / 2) },
|
|
||||||
(_, index) => tiers.slice(index * 2, index * 2 + 2),
|
|
||||||
)
|
|
||||||
const visibleTiers = tierPages[talentPage] ?? tierPages[0] ?? []
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.scrollTo(0, scrollRef.current)
|
window.scrollTo(0, scrollRef.current)
|
||||||
}, [profile])
|
}, [profile])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||||
|
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||||
|
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||||
|
|
||||||
function saveScroll() {
|
function saveScroll() {
|
||||||
scrollRef.current = window.scrollY
|
scrollRef.current = window.scrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
function lowerTierPoints(talent: Talent) {
|
|
||||||
return gameClass.talents
|
|
||||||
.filter((candidate) => candidate.tier < talent.tier)
|
|
||||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockReason(talent: Talent) {
|
function lockReason(talent: Talent) {
|
||||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
if (!isEffectClass) return 'Coming soon'
|
||||||
|
if (talent.rank > 0) return ''
|
||||||
const requiredTierPoints = (talent.tier - 1) * 5
|
if (capacity <= 0) return 'Unlocks at level 5'
|
||||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
if (selectedEffects.length >= capacity) {
|
||||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
return `Active slots full (${capacity}/${capacity})`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (talent.prerequisiteTalentId) {
|
|
||||||
const prerequisite = gameClass.talents.find(
|
|
||||||
(candidate) => candidate.id === talent.prerequisiteTalentId,
|
|
||||||
)
|
|
||||||
if ((prerequisite?.rank ?? 0) < talent.prerequisiteRank) {
|
|
||||||
return `Requires ${talent.prerequisiteName} rank ${talent.prerequisiteRank}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.character.talentPoints <= 0) return 'No points available'
|
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purchaseRank(talent: Talent) {
|
async function toggleEffect(talent: Talent) {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setBusyTalentId(talent.id)
|
setBusyTalentId(talent.id)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await allocateTalent(talent.id)
|
const updated = await allocateTalent(talent.id)
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
setMessage(`${talent.name} increased to rank ${talent.rank + 1}.`)
|
setSelectedTalentId(talent.id)
|
||||||
|
setMessage(talent.rank > 0 ? `${talent.name} removed.` : `${talent.name} activated.`)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||||
} finally {
|
} finally {
|
||||||
setBusyTalentId(null)
|
setBusyTalentId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refundTree() {
|
async function clearEffects() {
|
||||||
saveScroll()
|
saveScroll()
|
||||||
setResetting(true)
|
setResetting(true)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const updated = await resetTalents()
|
const updated = await resetTalents()
|
||||||
onUpdated(updated)
|
onUpdated(updated)
|
||||||
setMessage('All points in this talent tree were refunded.')
|
setMessage('Spell effects cleared.')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false)
|
setResetting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workshopState = useMemo<DualScreenWorkshopState | null>(() => {
|
||||||
|
if (!isEffectClass) return null
|
||||||
|
return {
|
||||||
|
mode: 'talents',
|
||||||
|
title: 'Spell Effects',
|
||||||
|
subtitle: `${selectedEffects.length}/${capacity} active`,
|
||||||
|
summary: selectedTalent
|
||||||
|
? `${selectedTalent.name}: ${selectedTalent.description}`
|
||||||
|
: 'Choose effects to modify your spells.',
|
||||||
|
items: gameClass.talents.map((talent) => ({
|
||||||
|
glyph: talent.glyph,
|
||||||
|
title: talent.name,
|
||||||
|
meta: talent.rank > 0 ? 'Active' : lockReason(talent) || 'Available',
|
||||||
|
detail: talent.description,
|
||||||
|
status: talent.rank > 0 ? 'Selected' : '',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}, [capacity, gameClass.talents, isEffectClass, selectedEffects.length, selectedTalent])
|
||||||
|
|
||||||
|
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<div className="screen-heading">
|
<div className="screen-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Character Growth</p>
|
<p className="eyebrow">Character Growth</p>
|
||||||
<h1>Talents</h1>
|
<h1>Spell Effects</h1>
|
||||||
</div>
|
</div>
|
||||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="talent-toolbar">
|
<div className="talent-toolbar spell-effect-toolbar">
|
||||||
<div className="talent-class-summary">
|
<div className="talent-class-summary">
|
||||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||||
{gameClass.name[0]}
|
{gameClass.name[0]}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||||
<h2>Shape Your Healing Style</h2>
|
<h2>Modify Your Spells</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="talent-points">
|
<div className="talent-points">
|
||||||
<strong>{profile.character.talentPoints}</strong>
|
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||||
<span>Available</span>
|
<span>Active</span>
|
||||||
<small>{classPointsSpent} spent in this tree</small>
|
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
{!isEffectClass ? (
|
||||||
{tierPages.map((pageTiers, index) => (
|
<div className="talent-empty-state">
|
||||||
|
<h2>Spell effects coming soon for {gameClass.name}.</h2>
|
||||||
|
<p>This replacement system starts with the first class.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="spell-effect-layout">
|
||||||
|
<section className="effect-slots-panel">
|
||||||
|
<p className="eyebrow">Active Slots</p>
|
||||||
|
{EFFECT_SLOT_LEVELS.map((level, index) => {
|
||||||
|
const effect = selectedEffects[index]
|
||||||
|
const unlocked = profile.character.level >= level
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
aria-selected={talentPage === index}
|
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||||
className={talentPage === index ? 'active' : ''}
|
disabled={!effect}
|
||||||
key={pageTiers.join('-')}
|
key={level}
|
||||||
onClick={() => setTalentPage(index)}
|
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||||
role="tab"
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Tiers {pageTiers[0]}-{pageTiers[pageTiers.length - 1]}
|
<span>Lv {level}</span>
|
||||||
|
<strong>{effect?.name ?? (unlocked ? 'Empty Slot' : 'Locked')}</strong>
|
||||||
|
<small>{effect?.description ?? (unlocked ? 'Choose an effect from the pool.' : `Reach level ${level}.`)}</small>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
</nav>
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="talent-tree">
|
<section className="effect-pool-panel">
|
||||||
{visibleTiers.map((tier) => {
|
<div className="effect-panel-heading">
|
||||||
const requiredPoints = (tier - 1) * 5
|
<div>
|
||||||
return (
|
<p className="eyebrow">Effect Pool</p>
|
||||||
<section className="talent-tier" key={tier}>
|
<h2>Choose and Swap</h2>
|
||||||
<div className="tier-label">
|
|
||||||
<span>Tier {tier}</span>
|
|
||||||
<small>
|
|
||||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tier-talents">
|
<span>{selectedEffects.length}/{capacity} active</span>
|
||||||
{gameClass.talents
|
</div>
|
||||||
.filter((talent) => talent.tier === tier)
|
<div className="effect-pool">
|
||||||
.sort((a, b) => a.branch - b.branch)
|
{gameClass.talents.map((talent) => {
|
||||||
.map((talent) => {
|
|
||||||
const reason = lockReason(talent)
|
const reason = lockReason(talent)
|
||||||
|
const active = talent.rank > 0
|
||||||
|
const selected = selectedTalent?.id === talent.id
|
||||||
const isBusy = busyTalentId === talent.id
|
const isBusy = busyTalentId === talent.id
|
||||||
return (
|
return (
|
||||||
<article
|
<button
|
||||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||||
|
disabled={Boolean(reason) || isBusy}
|
||||||
key={talent.id}
|
key={talent.id}
|
||||||
style={{ gridColumn: talent.branch }}
|
onClick={() => {
|
||||||
|
setSelectedTalentId(talent.id)
|
||||||
|
void toggleEffect(talent)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="talent-node-header">
|
|
||||||
<span>{talent.glyph}</span>
|
<span>{talent.glyph}</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{talent.name}</strong>
|
<strong>{talent.name}</strong>
|
||||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
<small>{talent.description}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||||
<p>{talent.description}</p>
|
|
||||||
<div className="rank-pips">
|
|
||||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
|
||||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
disabled={Boolean(reason) || isBusy}
|
|
||||||
onClick={() => purchaseRank(talent)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
|
||||||
</button>
|
</button>
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="talent-footer">
|
<aside className="effect-detail-panel">
|
||||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
<p className="eyebrow">Selected Effect</p>
|
||||||
|
{selectedTalent ? (
|
||||||
|
<>
|
||||||
|
<h2>{selectedTalent.name}</h2>
|
||||||
|
<p>{selectedTalent.description}</p>
|
||||||
<button
|
<button
|
||||||
className="text-button"
|
className="primary-button"
|
||||||
disabled={classPointsSpent === 0 || resetting}
|
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||||
onClick={refundTree}
|
onClick={() => toggleEffect(selectedTalent)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
{busyTalentId === selectedTalent.id
|
||||||
|
? 'Saving...'
|
||||||
|
: selectedTalent.rank > 0
|
||||||
|
? 'Remove Effect'
|
||||||
|
: 'Activate Effect'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>No effect selected.</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="talent-footer">
|
||||||
|
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
disabled={selectedEffects.length === 0 || resetting}
|
||||||
|
onClick={clearEffects}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+3
-1
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
|
|||||||
directPartyTargeting: boolean
|
directPartyTargeting: boolean
|
||||||
paused: boolean
|
paused: boolean
|
||||||
targetGroup: 0 | 1 | 2
|
targetGroup: 0 | 1 | 2
|
||||||
|
speedMultiplier: 1 | 2
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DualScreenWorkshopState = {
|
export type DualScreenWorkshopState = {
|
||||||
@@ -121,7 +122,7 @@ function loadRecentSnapshot() {
|
|||||||
function memberHotEffects(member: PartyMember) {
|
function memberHotEffects(member: PartyMember) {
|
||||||
if (member.hotEffects?.length) return member.hotEffects
|
if (member.hotEffects?.length) return member.hotEffects
|
||||||
return member.hotTicks > 0
|
return member.hotTicks > 0
|
||||||
? [{ id: 'legacy-renew', label: 'Renew', ticks: member.hotTicks }]
|
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="dual-controls-mana">
|
<div className="dual-controls-mana">
|
||||||
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
<span>{state.resourceName} {Math.floor(state.resource)} / {state.maxResource}</span>
|
||||||
|
{state.speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||||
<div className="bar mana-bar">
|
<div className="bar mana-bar">
|
||||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type PartyMember = {
|
|||||||
hotTicks: number
|
hotTicks: number
|
||||||
hotEffects?: Array<{
|
hotEffects?: Array<{
|
||||||
id: string
|
id: string
|
||||||
|
spellId: string
|
||||||
label: string
|
label: string
|
||||||
ticks: number
|
ticks: number
|
||||||
power: number
|
power: number
|
||||||
|
|||||||
@@ -428,6 +428,10 @@ function scaledPvpBossExperience(
|
|||||||
return { experience, level }
|
return { experience, level }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function talentEffectCapacity(level: number) {
|
||||||
|
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||||
|
}
|
||||||
|
|
||||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||||
@@ -1102,6 +1106,24 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
)!
|
)!
|
||||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||||
if (!talent) throw new Error('That talent does not belong to the active class.')
|
if (!talent) throw new Error('That talent does not belong to the active class.')
|
||||||
|
if (save.activeClassId === 1) {
|
||||||
|
if ((cd.talentRanks[String(talentId)] ?? 0) > 0) {
|
||||||
|
cd.talentRanks[String(talentId)] = 0
|
||||||
|
} else {
|
||||||
|
const capacity = talentEffectCapacity(cd.level)
|
||||||
|
if (capacity <= 0) throw new Error('Spell effects unlock at level 5.')
|
||||||
|
const activeCount = gameClass.talents.reduce(
|
||||||
|
(total, candidate) => total + ((cd.talentRanks[String(candidate.id)] ?? 0) > 0 ? 1 : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
if (activeCount >= capacity) {
|
||||||
|
throw new Error(`Level ${cd.level} allows ${capacity} active spell effect${capacity === 1 ? '' : 's'}.`)
|
||||||
|
}
|
||||||
|
cd.talentRanks[String(talentId)] = 1
|
||||||
|
}
|
||||||
|
store.writeSave(save)
|
||||||
|
return buildProfile(save)
|
||||||
|
}
|
||||||
if (cd.talentPoints <= 0) {
|
if (cd.talentPoints <= 0) {
|
||||||
throw new Error('No talent points are available.')
|
throw new Error('No talent points are available.')
|
||||||
}
|
}
|
||||||
@@ -1144,10 +1166,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
|||||||
for (const talent of gameClass.talents) {
|
for (const talent of gameClass.talents) {
|
||||||
cd.talentRanks[String(talent.id)] = 0
|
cd.talentRanks[String(talent.id)] = 0
|
||||||
}
|
}
|
||||||
|
if (save.activeClassId !== 1) {
|
||||||
cd.talentPoints = Math.min(
|
cd.talentPoints = Math.min(
|
||||||
profile.maxTalentPoints,
|
profile.maxTalentPoints,
|
||||||
cd.talentPoints + refunded,
|
cd.talentPoints + refunded,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
store.writeSave(save)
|
store.writeSave(save)
|
||||||
return buildProfile(save)
|
return buildProfile(save)
|
||||||
},
|
},
|
||||||
|
|||||||
+18
-2
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
|
|||||||
'targetParty5',
|
'targetParty5',
|
||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
|
'toggleSpeed',
|
||||||
'pause',
|
'pause',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
|||||||
targetParty5: 'Target Party Member 5',
|
targetParty5: 'Target Party Member 5',
|
||||||
targetParty6: 'Target Party Member 6',
|
targetParty6: 'Target Party Member 6',
|
||||||
toggleTargetGroup: 'Switch Raid Target Group',
|
toggleTargetGroup: 'Switch Raid Target Group',
|
||||||
|
toggleSpeed: 'Toggle 2x Speed',
|
||||||
pause: 'Pause Menu',
|
pause: 'Pause Menu',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty5: 'F5',
|
targetParty5: 'F5',
|
||||||
targetParty6: 'F6',
|
targetParty6: 'F6',
|
||||||
toggleTargetGroup: 'Tab',
|
toggleTargetGroup: 'Tab',
|
||||||
|
toggleSpeed: 'Backquote',
|
||||||
pause: 'Escape',
|
pause: 'Escape',
|
||||||
},
|
},
|
||||||
controller: {
|
controller: {
|
||||||
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
|||||||
targetParty3: 'Button15',
|
targetParty3: 'Button15',
|
||||||
targetParty4: 'Button13',
|
targetParty4: 'Button13',
|
||||||
targetParty5: 'Button4',
|
targetParty5: 'Button4',
|
||||||
targetParty6: 'Button11',
|
targetParty6: 'Button10',
|
||||||
toggleTargetGroup: 'Button6',
|
toggleTargetGroup: 'Button6',
|
||||||
|
toggleSpeed: 'Button11',
|
||||||
pause: 'Button9',
|
pause: 'Button9',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -145,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
|||||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
|
||||||
const controller = { ...DEFAULT_BINDINGS.controller, ...saved.controller }
|
const savedController = saved.controller
|
||||||
|
const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
|
||||||
const usesLegacyAbilityDefaults = [
|
const usesLegacyAbilityDefaults = [
|
||||||
'Button2',
|
'Button2',
|
||||||
'Button3',
|
'Button3',
|
||||||
@@ -166,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
|||||||
ability6: DEFAULT_BINDINGS.controller.ability6,
|
ability6: DEFAULT_BINDINGS.controller.ability6,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (savedController?.toggleSpeed === 'Button7') {
|
||||||
|
controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
|
||||||
|
}
|
||||||
|
if (savedController?.ability6 === 'Button10') {
|
||||||
|
controller.ability6 = DEFAULT_BINDINGS.controller.ability6
|
||||||
|
}
|
||||||
|
if (savedController?.targetParty6 === 'Button11') {
|
||||||
|
controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||||
controller,
|
controller,
|
||||||
@@ -504,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
|||||||
'targetParty5',
|
'targetParty5',
|
||||||
'targetParty6',
|
'targetParty6',
|
||||||
'toggleTargetGroup',
|
'toggleTargetGroup',
|
||||||
|
'toggleSpeed',
|
||||||
] satisfies InputAction[]
|
] satisfies InputAction[]
|
||||||
const combatPriority = [
|
const combatPriority = [
|
||||||
'pause',
|
'pause',
|
||||||
|
'toggleSpeed',
|
||||||
'ability1',
|
'ability1',
|
||||||
'ability2',
|
'ability2',
|
||||||
'ability3',
|
'ability3',
|
||||||
|
|||||||
@@ -147,154 +147,137 @@
|
|||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "bright-reserves",
|
"slug": "shield-applies-renew",
|
||||||
"name": "Bright Reserves",
|
"name": "Shield applies Renew",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
"branch": 1,
|
"branch": 1,
|
||||||
"prerequisiteTalentId": null,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 0,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": null,
|
"prerequisiteName": null,
|
||||||
"effectType": "max_resource",
|
"effectType": "shield_applies_renew",
|
||||||
"effectValuePerRank": 2,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "M",
|
"glyph": "~",
|
||||||
"description": "Increases maximum Mana by 2 per rank.",
|
"description": "Sun Ward also applies Renew to the target.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "gentle-dawn",
|
"slug": "mend-applies-renew",
|
||||||
"name": "Gentle Dawn",
|
"name": "Mend applies Renew",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
"branch": 2,
|
"branch": 2,
|
||||||
"prerequisiteTalentId": null,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 0,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": null,
|
"prerequisiteName": null,
|
||||||
"effectType": "hot_power_percent",
|
"effectType": "mend_applies_renew",
|
||||||
"effectValuePerRank": 2,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "~",
|
"glyph": "~",
|
||||||
"description": "Increases healing-over-time power by 2% per rank.",
|
"description": "Mend also applies Renew to the target.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "steady-hands",
|
"slug": "mend-adds-shield",
|
||||||
"name": "Steady Hands",
|
"name": "Mend adds Shield",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
"branch": 3,
|
"branch": 3,
|
||||||
"prerequisiteTalentId": null,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 0,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": null,
|
"prerequisiteName": null,
|
||||||
"effectType": "direct_heal_percent",
|
"effectType": "mend_applies_shield",
|
||||||
"effectValuePerRank": 2,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "+",
|
"glyph": "O",
|
||||||
"description": "Increases direct healing by 2% per rank.",
|
"description": "Mend also applies a shield at 50% strength to the target.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 11,
|
"id": 11,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "overflowing-light",
|
"slug": "radiance-adds-shield",
|
||||||
"name": "Overflowing Light",
|
"name": "Radiance adds Shield",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 2,
|
"tier": 1,
|
||||||
"branch": 1,
|
"branch": 4,
|
||||||
"prerequisiteTalentId": 1,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 3,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": "Bright Reserves",
|
"prerequisiteName": null,
|
||||||
"effectType": "resource_regen_percent",
|
"effectType": "radiance_applies_shield",
|
||||||
"effectValuePerRank": 2,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "O",
|
"glyph": "O",
|
||||||
"description": "Improves Mana regeneration by 2% per rank. Requires Bright Reserves rank 3.",
|
"description": "Radiance applies a shield at 30% strength to affected party members.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "lingering-rays",
|
"slug": "radiance-applies-renew",
|
||||||
"name": "Lingering Rays",
|
"name": "Radiance applies Renew",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 2,
|
"tier": 1,
|
||||||
"branch": 2,
|
"branch": 5,
|
||||||
"prerequisiteTalentId": 2,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 3,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": "Gentle Dawn",
|
"prerequisiteName": null,
|
||||||
"effectType": "hot_duration_percent",
|
"effectType": "radiance_applies_renew",
|
||||||
"effectValuePerRank": 4,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "L",
|
"glyph": "~",
|
||||||
"description": "Extends healing-over-time duration by 4% per rank. Requires Gentle Dawn rank 3.",
|
"description": "Radiance applies Renew at 50% duration to affected party members.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "radiant-precision",
|
"slug": "shielded-damage-reduction",
|
||||||
"name": "Radiant Precision",
|
"name": "Shielded takes less",
|
||||||
"maxRank": 5,
|
"maxRank": 1,
|
||||||
"tier": 2,
|
"tier": 1,
|
||||||
"branch": 3,
|
"branch": 6,
|
||||||
"prerequisiteTalentId": 10,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 3,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": "Steady Hands",
|
"prerequisiteName": null,
|
||||||
"effectType": "critical_heal_percent",
|
"effectType": "shielded_damage_reduction",
|
||||||
"effectValuePerRank": 1,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "!",
|
"glyph": "D",
|
||||||
"description": "Adds 1% healing critical chance per rank. Requires Steady Hands rank 3.",
|
"description": "While shielded, the target receives 20% less damage.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 14,
|
"id": 14,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "sunlit-aegis",
|
"slug": "shielded-healing-bonus",
|
||||||
"name": "Sunlit Aegis",
|
"name": "Shielded healing boost",
|
||||||
"maxRank": 3,
|
"maxRank": 1,
|
||||||
"tier": 3,
|
"tier": 1,
|
||||||
"branch": 1,
|
"branch": 7,
|
||||||
"prerequisiteTalentId": 11,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 5,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": "Overflowing Light",
|
"prerequisiteName": null,
|
||||||
"effectType": "absorb_power_percent",
|
"effectType": "shielded_healing_bonus",
|
||||||
"effectValuePerRank": 5,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "A",
|
"glyph": "+",
|
||||||
"description": "Strengthens absorb effects by 5% per rank. Requires Overflowing Light rank 5.",
|
"description": "While shielded, the target receives 20% more healing.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 15,
|
"id": 15,
|
||||||
"classId": 1,
|
"classId": 1,
|
||||||
"slug": "shared-dawn",
|
"slug": "mend-reduces-radiance",
|
||||||
"name": "Shared Dawn",
|
"name": "Mend lowers Radiance",
|
||||||
"maxRank": 3,
|
|
||||||
"tier": 3,
|
|
||||||
"branch": 2,
|
|
||||||
"prerequisiteTalentId": 12,
|
|
||||||
"prerequisiteRank": 5,
|
|
||||||
"prerequisiteName": "Lingering Rays",
|
|
||||||
"effectType": "party_heal_percent",
|
|
||||||
"effectValuePerRank": 5,
|
|
||||||
"glyph": "*",
|
|
||||||
"description": "Increases party-wide healing by 5% per rank. Requires Lingering Rays rank 5.",
|
|
||||||
"rank": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 16,
|
|
||||||
"classId": 1,
|
|
||||||
"slug": "miracle-worker",
|
|
||||||
"name": "Miracle Worker",
|
|
||||||
"maxRank": 1,
|
"maxRank": 1,
|
||||||
"tier": 4,
|
"tier": 1,
|
||||||
"branch": 2,
|
"branch": 8,
|
||||||
"prerequisiteTalentId": 15,
|
"prerequisiteTalentId": null,
|
||||||
"prerequisiteRank": 3,
|
"prerequisiteRank": 0,
|
||||||
"prerequisiteName": "Shared Dawn",
|
"prerequisiteName": null,
|
||||||
"effectType": "cooldown_reduction_percent",
|
"effectType": "mend_reduces_radiance_cooldown",
|
||||||
"effectValuePerRank": 10,
|
"effectValuePerRank": 0,
|
||||||
"glyph": "S",
|
"glyph": "*",
|
||||||
"description": "Reduces major healing cooldowns by 10%. Requires Shared Dawn rank 3.",
|
"description": "Casting Mend reduces the cooldown of Radiance by 2 seconds.",
|
||||||
"rank": 0
|
"rank": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user