Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8a1fbc5e2 | |||
| bab2dce6c3 | |||
| cb38042eca | |||
| 753bba581a | |||
| 2300973164 | |||
| 1281be69d8 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "com.warren.iwanttoheal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 52
|
||||
versionName "1.0.33"
|
||||
versionCode 58
|
||||
versionName "1.0.38"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
+22
-11
@@ -561,7 +561,9 @@ SET name = (
|
||||
SELECT
|
||||
CASE items.item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
WHEN 20 THEN 'Purple '
|
||||
WHEN 25 THEN 'Orange '
|
||||
ELSE ''
|
||||
@@ -676,18 +678,22 @@ JOIN coin_sources
|
||||
ON coin_sources.encounter_id = crafting_recipes.source_encounter_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
|
||||
(id, class_id, slug, name, max_rank, tier, branch, prerequisite_talent_id, prerequisite_rank, effect_type, effect_value_per_rank, glyph, description)
|
||||
VALUES
|
||||
(1, 1, 'bright-reserves', 'Bright Reserves', 5, 1, 1, NULL, 0, 'max_resource', 2, 'M', 'Increases maximum Mana by 2 per rank.'),
|
||||
(2, 1, 'gentle-dawn', 'Gentle Dawn', 5, 1, 2, NULL, 0, 'hot_power_percent', 2, '~', 'Increases healing-over-time power by 2% per rank.'),
|
||||
(10, 1, 'steady-hands', 'Steady Hands', 5, 1, 3, NULL, 0, 'direct_heal_percent', 2, '+', 'Increases direct healing by 2% per rank.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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.'),
|
||||
(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, 'mend-applies-renew', 'Mend applies Renew', 1, 1, 2, NULL, 0, 'mend_applies_renew', 0, '~', 'Mend also applies Renew to the target.'),
|
||||
(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, '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, '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, '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, '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, '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.'),
|
||||
|
||||
(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.'),
|
||||
@@ -794,6 +800,7 @@ INSERT INTO generated_loot_tiers
|
||||
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
|
||||
VALUES
|
||||
(10, 3, 2, 2, 101, 1100, 2),
|
||||
(15, 4, 5, 3, 103, 1200, 3),
|
||||
(20, 6, 7, 4, 104, 1300, 4),
|
||||
(25, 8, 9, 5, 105, 1400, 5);
|
||||
|
||||
@@ -1347,18 +1354,20 @@ WHERE recipe_id IN (
|
||||
SELECT crafting_recipes.id
|
||||
FROM crafting_recipes
|
||||
JOIN items ON items.id = crafting_recipes.item_id
|
||||
WHERE items.item_level NOT IN (1, 10, 20, 25)
|
||||
WHERE items.item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
DELETE FROM crafting_recipes
|
||||
WHERE item_id IN (
|
||||
SELECT id FROM items WHERE item_level NOT IN (1, 10, 20, 25)
|
||||
SELECT id FROM items WHERE item_level NOT IN (1, 5, 10, 15, 20, 25)
|
||||
);
|
||||
|
||||
UPDATE items
|
||||
SET rarity = CASE item_level
|
||||
WHEN 1 THEN 'common'
|
||||
WHEN 5 THEN 'uncommon'
|
||||
WHEN 10 THEN 'uncommon'
|
||||
WHEN 15 THEN 'rare'
|
||||
WHEN 20 THEN 'epic'
|
||||
WHEN 25 THEN 'legendary'
|
||||
ELSE rarity
|
||||
@@ -1370,7 +1379,9 @@ SET name = (
|
||||
SELECT
|
||||
CASE items.item_level
|
||||
WHEN 1 THEN 'Raw '
|
||||
WHEN 5 THEN 'Honed '
|
||||
WHEN 10 THEN 'Green '
|
||||
WHEN 15 THEN 'Blue '
|
||||
WHEN 20 THEN 'Purple '
|
||||
WHEN 25 THEN 'Orange '
|
||||
ELSE ''
|
||||
|
||||
@@ -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 |
+70
-2
@@ -1862,9 +1862,20 @@ function upgradeItem(database, characterId, itemId) {
|
||||
return getProfile(database, characterId)
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
function allocateTalent(database, characterId, talentId) {
|
||||
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
|
||||
WHERE id = ?
|
||||
`).get(characterId)
|
||||
@@ -1876,7 +1887,8 @@ function allocateTalent(database, characterId, talentId) {
|
||||
max_rank AS maxRank,
|
||||
tier,
|
||||
prerequisite_talent_id AS prerequisiteTalentId,
|
||||
prerequisite_rank AS prerequisiteRank
|
||||
prerequisite_rank AS prerequisiteRank,
|
||||
effect_type AS effectType
|
||||
FROM talents
|
||||
WHERE id = ?
|
||||
`).get(talentId)
|
||||
@@ -1884,6 +1896,60 @@ function allocateTalent(database, characterId, talentId) {
|
||||
if (!talent || talent.classId !== character.classId) {
|
||||
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 activeTalents = database.prepare(`
|
||||
SELECT
|
||||
talents.id,
|
||||
talents.name,
|
||||
talents.effect_type AS effectType
|
||||
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
|
||||
`).all(characterId, character.classId)
|
||||
const source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = activeTalents.find(
|
||||
(candidate) => candidate.id !== talentId && talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
const activeCount = activeTalents.length
|
||||
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) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1962,11 +2028,13 @@ function resetTalents(database, characterId) {
|
||||
WHERE character_id = ?
|
||||
AND talent_id IN (SELECT id FROM talents WHERE class_id = ?)
|
||||
`).run(characterId, character.classId)
|
||||
if (character.classId !== 1) {
|
||||
database.prepare(`
|
||||
UPDATE characters
|
||||
SET talent_points = MIN(level, talent_points + ?)
|
||||
WHERE id = ?
|
||||
`).run(refunded, characterId)
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
|
||||
+388
-1
@@ -1683,7 +1683,8 @@ h2 {
|
||||
|
||||
.equipment-screen .equipment-layout,
|
||||
.equipment-screen .crafting-panel,
|
||||
.talent-screen .talent-tree {
|
||||
.talent-screen .talent-tree,
|
||||
.talent-screen .spell-effect-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -3454,6 +3455,260 @@ h2 {
|
||||
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: 220px minmax(0, 1fr);
|
||||
margin-top: 17px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.effect-slots-panel,
|
||||
.effect-pool-panel {
|
||||
background: #191b25;
|
||||
border: 2px solid #090a0d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
|
||||
.selected-effect-strip {
|
||||
align-items: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin-top: 12px;
|
||||
outline: 2px solid #3a3944;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.selected-effect-strip strong,
|
||||
.selected-effect-strip small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selected-effect-strip strong {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selected-effect-strip small {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.selected-effect-strip .primary-button {
|
||||
min-width: 120px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.effect-pool {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 62px);
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.effect-pool > button {
|
||||
align-content: center;
|
||||
background: #20222d;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
outline: 2px solid #3a3944;
|
||||
overflow: hidden;
|
||||
padding: 7px;
|
||||
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: 26px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.effect-pool strong,
|
||||
.effect-pool small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.effect-pool strong {
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.effect-pool small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.effect-pool > button i {
|
||||
grid-column: 1 / -1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.effect-pager {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.effect-pager button {
|
||||
background: #15161c;
|
||||
border: 2px solid #090a0d;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 7px;
|
||||
min-height: 28px;
|
||||
outline: 2px solid #41404a;
|
||||
padding: 4px 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.effect-pager button:disabled {
|
||||
color: #676773;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.effect-pager span {
|
||||
color: var(--gold);
|
||||
font-family: 'Press Start 2P', monospace;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.spell-effect-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.effect-slots-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.effect-pool {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.selected-effect-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.talent-tier {
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid #393943;
|
||||
@@ -5109,6 +5364,19 @@ h2 {
|
||||
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 {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -7354,6 +7622,125 @@ h2 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .spell-effect-layout {
|
||||
gap: 6px;
|
||||
grid-template-columns: 172px minmax(0, 1fr);
|
||||
margin-top: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slots-panel,
|
||||
.workshop-shell .effect-pool-panel {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slots-panel {
|
||||
gap: 5px;
|
||||
grid-auto-rows: minmax(43px, 1fr);
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot span,
|
||||
.workshop-shell .effect-pool > button i,
|
||||
.workshop-shell .effect-panel-heading > span,
|
||||
.workshop-shell .effect-pager span {
|
||||
font-size: 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot strong {
|
||||
font-size: 6px;
|
||||
line-height: 1.15;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-slot small {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
max-height: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-panel-heading h2 {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip {
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin-top: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip strong {
|
||||
font-size: 7px;
|
||||
line-height: 1.1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip small {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
margin-top: 3px;
|
||||
max-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workshop-shell .selected-effect-strip .primary-button {
|
||||
font-size: 7px;
|
||||
min-height: 25px;
|
||||
min-width: 72px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool {
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, 52px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool > button {
|
||||
gap: 4px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool > button > span {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool strong {
|
||||
font-size: 6px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pool small {
|
||||
font-size: 9px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pager {
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.workshop-shell .effect-pager button {
|
||||
font-size: 6px;
|
||||
min-height: 22px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.workshop-shell .talent-tier {
|
||||
gap: 6px;
|
||||
grid-template-columns: 66px minmax(0, 1fr);
|
||||
|
||||
+108
-37
@@ -43,7 +43,7 @@ const TICK_MS = 700
|
||||
type RoguelikeMode = 'dungeon' | 'raid'
|
||||
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
|
||||
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type RoguelikeMechanic =
|
||||
| 'party-pulse'
|
||||
| 'searing-mark'
|
||||
@@ -105,38 +105,42 @@ function effectiveMaxHealth(member: PartyMember) {
|
||||
return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
|
||||
}
|
||||
|
||||
function healAmount(member: PartyMember, amount: number) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1))
|
||||
function healAmount(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number) {
|
||||
return clamp(member.health + healAmount(member, amount), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
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 }]
|
||||
: []
|
||||
}
|
||||
|
||||
function effectId(prefix: string) {
|
||||
return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
|
||||
}
|
||||
|
||||
function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
|
||||
return [
|
||||
...memberHotEffects(member),
|
||||
{
|
||||
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||
const nextEffect = {
|
||||
id: effectId(spell.id),
|
||||
spellId: spell.id,
|
||||
label: spell.name,
|
||||
ticks,
|
||||
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) {
|
||||
return [
|
||||
...(member.bounceHeals ?? []),
|
||||
{
|
||||
id: `${spell.id}-${crypto.randomUUID()}`,
|
||||
id: effectId(spell.id),
|
||||
label: spell.name,
|
||||
charges: 4,
|
||||
power: spell.power,
|
||||
@@ -164,7 +168,7 @@ function buildRoguelikeUpgrades(
|
||||
spells: Spell[],
|
||||
labelMode: RoguelikeAbilityLabelMode,
|
||||
): RoguelikeUpgrade[] {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotUpgrades = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -414,6 +418,7 @@ export function CombatScreen({
|
||||
const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
|
||||
const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
|
||||
const [log, setLog] = useState<CombatLogEntry[]>([
|
||||
{ id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
|
||||
@@ -441,6 +446,7 @@ export function CombatScreen({
|
||||
const lastCombatTickAtRef = useRef(performance.now())
|
||||
const statusRef = useRef(status)
|
||||
const pausedRef = useRef(paused)
|
||||
const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
|
||||
const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
|
||||
const encounter = encounters[encounterIndex]
|
||||
const encounterMaxHealth = encounter.maxHealth * enemyCount
|
||||
@@ -459,11 +465,21 @@ export function CombatScreen({
|
||||
const playerHealer = party.find((member) => member.id === 'mira')
|
||||
const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
|
||||
const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
|
||||
const activeSetEffects = useMemo(
|
||||
() => isRoguelike
|
||||
? new Set<string>()
|
||||
: new Set(profile.setBonuses.filter((bonus) => bonus.active).map((bonus) => bonus.effectType)),
|
||||
[isRoguelike, profile.setBonuses],
|
||||
const activeEffects = useMemo(
|
||||
() => {
|
||||
const effects = new Set<string>(
|
||||
gameClass.talents
|
||||
.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 {
|
||||
bindings,
|
||||
@@ -477,6 +493,7 @@ export function CombatScreen({
|
||||
|
||||
statusRef.current = status
|
||||
pausedRef.current = paused
|
||||
speedMultiplierRef.current = speedMultiplier
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now()
|
||||
@@ -615,6 +632,14 @@ export function CombatScreen({
|
||||
const extraTarget = (blockedIds: string[]) => current.party
|
||||
.filter((member) => member.health > 0 && !blockedIds.includes(member.id))
|
||||
.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 hotTargets = new Set<string>()
|
||||
const shieldTargets = new Set<string>()
|
||||
@@ -626,15 +651,15 @@ export function CombatScreen({
|
||||
)
|
||||
if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.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])
|
||||
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])
|
||||
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)
|
||||
}
|
||||
for (let index = 0; index < extraTargets; index += 1) {
|
||||
@@ -669,9 +694,20 @@ export function CombatScreen({
|
||||
}
|
||||
}
|
||||
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))
|
||||
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 (
|
||||
!directTargets.has(member.id)
|
||||
@@ -681,7 +717,14 @@ export function CombatScreen({
|
||||
) return member
|
||||
if (spell.kind === 'shield') {
|
||||
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') {
|
||||
return { ...member, damageReductionTicks: 12 }
|
||||
@@ -692,7 +735,7 @@ export function CombatScreen({
|
||||
if (spell.kind === 'cleanse') {
|
||||
return {
|
||||
...member,
|
||||
health: healMember(member, spell.power),
|
||||
health: healMember(member, spell.power, healingMultiplier(member)),
|
||||
debuff: undefined,
|
||||
debuffTicks: undefined,
|
||||
poisonStacks: undefined,
|
||||
@@ -701,14 +744,23 @@ export function CombatScreen({
|
||||
}
|
||||
}
|
||||
const nextHealth = directTargets.has(member.id)
|
||||
? healMember(member, spell.power)
|
||||
? healMember(member, spell.power, healingMultiplier(member))
|
||||
: 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 {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
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')
|
||||
@@ -726,20 +778,26 @@ export function CombatScreen({
|
||||
&& !current.freeCastReady
|
||||
&& current.castsTowardFree + 1 >= 5
|
||||
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({
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
})
|
||||
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(
|
||||
@@ -905,6 +963,10 @@ export function CombatScreen({
|
||||
}, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])
|
||||
|
||||
useGameAction((action, device) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || (action === 'back' && device === 'pc')) {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -1017,13 +1079,17 @@ export function CombatScreen({
|
||||
if ((member.damageReductionTicks ?? 0) > 0) {
|
||||
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 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 ?? [])]
|
||||
if (damage > 0 && nextBounceHeals.length > 0) {
|
||||
nextBounceHeals = nextBounceHeals.flatMap((effect) => {
|
||||
healing += healAmount(member, effect.power)
|
||||
healing += healAmount(member, effect.power, healingMultiplier)
|
||||
const nextCharges = effect.charges - 1
|
||||
if (nextCharges <= 0) return []
|
||||
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
|
||||
@@ -1188,6 +1254,7 @@ export function CombatScreen({
|
||||
})
|
||||
addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
|
||||
}, [
|
||||
activeEffects,
|
||||
addLog,
|
||||
addFloatingHeal,
|
||||
difficulty.damageMultiplier,
|
||||
@@ -1235,9 +1302,10 @@ export function CombatScreen({
|
||||
|| pausedRef.current
|
||||
) return
|
||||
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
|
||||
lastCombatTickAtRef.current += dueTicks * TICK_MS
|
||||
lastCombatTickAtRef.current += dueTicks * tickMs
|
||||
for (let index = 0; index < dueTicks; index += 1) {
|
||||
if (statusRef.current !== 'playing' || pausedRef.current) return
|
||||
runCombatTickRef.current()
|
||||
@@ -1306,6 +1374,7 @@ export function CombatScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1336,6 +1405,7 @@ export function CombatScreen({
|
||||
spells,
|
||||
freeCastReady,
|
||||
roguelikeUpgrades,
|
||||
speedMultiplier,
|
||||
status,
|
||||
targetGroup,
|
||||
])
|
||||
@@ -1399,6 +1469,7 @@ export function CombatScreen({
|
||||
? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
|
||||
: `${profile.character.name} is defeated`}
|
||||
</span>
|
||||
{speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
|
||||
<div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
|
||||
function chooseClass(nextClass: GameClass) {
|
||||
const starterAbilities = nextClass.spells
|
||||
.filter((ability) => ability.unlockLevel <= profile.character.level)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((ability) => ability.id)
|
||||
setClassId(nextClass.id)
|
||||
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
|
||||
|
||||
@@ -44,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
|
||||
sourceEncounterId?: number
|
||||
}
|
||||
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5'
|
||||
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
|
||||
type AbilityLabelMode = 'ability' | 'slot'
|
||||
|
||||
type SelfBuffId =
|
||||
@@ -164,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
|
||||
}
|
||||
|
||||
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -193,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
|
||||
}
|
||||
|
||||
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
|
||||
const slotChoices = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
|
||||
const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
|
||||
const label = slotLabel(slot, spells, labelMode)
|
||||
return [
|
||||
{
|
||||
@@ -251,13 +251,13 @@ function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
|
||||
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
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs))
|
||||
return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
|
||||
}
|
||||
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[]) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs), 0, effectiveMaxHealth(member))
|
||||
function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
|
||||
return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
|
||||
}
|
||||
|
||||
function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
|
||||
@@ -334,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
|
||||
if (buff.id === 'fifth-cast-free') return 8
|
||||
if (buff.id === 'group-heal-boost') return 8
|
||||
if (buff.id === 'shield-boost') return 6
|
||||
const slot = buff.id.match(/slot([1-5])/i)?.[1] as SlotKey | undefined
|
||||
const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
|
||||
const spell = spells.find((candidate) => candidate.key === slot)
|
||||
if (!spell) return 5
|
||||
if (buff.id.endsWith('extra-target')) {
|
||||
@@ -408,7 +408,7 @@ export function PvPRoguelikeScreen({
|
||||
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
|
||||
const starterSpells = useMemo(() => gameClass.spells
|
||||
.filter((spell) => spell.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, 6)
|
||||
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
|
||||
const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
|
||||
const selfBuffChoicesCatalog = useMemo(
|
||||
@@ -446,6 +446,7 @@ export function PvPRoguelikeScreen({
|
||||
const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
|
||||
const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
|
||||
const selectedIdRef = useRef(partyTemplate[0].id)
|
||||
const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
|
||||
const [elapsedTicks, setElapsedTicks] = useState(0)
|
||||
const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
|
||||
const [queueMessage, setQueueMessage] = useState('')
|
||||
@@ -483,6 +484,14 @@ export function PvPRoguelikeScreen({
|
||||
? Math.max(encountersCleared, encounterIndex + 1)
|
||||
: encountersCleared
|
||||
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 cpuDone = cpuSide.enemyHealth <= 0
|
||||
const playerAlive = playerSide.party.some((member) => member.health > 0)
|
||||
@@ -677,6 +686,12 @@ export function PvPRoguelikeScreen({
|
||||
const extraTarget = (blockedIds: string[]) => livingTargets
|
||||
.filter((member) => !blockedIds.includes(member.id))
|
||||
.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 hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
|
||||
const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
|
||||
@@ -701,22 +716,45 @@ export function PvPRoguelikeScreen({
|
||||
const extra = extraTarget([...directTargets])
|
||||
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) => {
|
||||
if (member.health <= 0) return member
|
||||
if (spell.kind === 'group') {
|
||||
if (!groupTargets.has(member.id)) return member
|
||||
const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
|
||||
const nextHealth = healMember(member, groupPower, debuffs)
|
||||
const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
|
||||
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 (spell.kind === 'shield') {
|
||||
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') {
|
||||
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))
|
||||
return {
|
||||
...member,
|
||||
@@ -728,11 +766,17 @@ export function PvPRoguelikeScreen({
|
||||
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)
|
||||
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 {
|
||||
...member,
|
||||
health: nextHealth,
|
||||
shield: nextShield,
|
||||
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
|
||||
}
|
||||
})
|
||||
@@ -746,20 +790,24 @@ export function PvPRoguelikeScreen({
|
||||
: current.castsTowardFree + 1
|
||||
: current.castsTowardFree
|
||||
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 = {
|
||||
...current,
|
||||
party: nextParty,
|
||||
resource: current.resource - effectiveCost,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[spell.id]: spell.cooldown * cooldownMultiplier(spell, buffs, debuffs),
|
||||
},
|
||||
cooldowns: nextCooldowns,
|
||||
castsTowardFree: nextCastsTowardFree,
|
||||
freeCastReady: gainedFreeCast || nextFreeCastReady,
|
||||
}
|
||||
setCurrent(nextState)
|
||||
return true
|
||||
}, [addFloatingHeal])
|
||||
}, [activeSpellEffects, addFloatingHeal, starterSpells])
|
||||
|
||||
const castPlayerSpell = useCallback((spell: Spell) => {
|
||||
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 appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
|
||||
const damageMultiplier = incomingDamageMultiplier(side.debuffs)
|
||||
const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
|
||||
const tankPressure = tankPressureTargets(side.party)
|
||||
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
|
||||
const nextParty = side.party.map((member) => {
|
||||
@@ -882,8 +931,12 @@ export function PvPRoguelikeScreen({
|
||||
: member.poisonStacks ?? 0
|
||||
if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
|
||||
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 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)
|
||||
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
|
||||
? 14
|
||||
@@ -925,7 +978,7 @@ export function PvPRoguelikeScreen({
|
||||
),
|
||||
enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
|
||||
}
|
||||
}, [addFloatingHeal, elapsedTicks, maxResource])
|
||||
}, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
|
||||
|
||||
const beginUpgradePhase = useCallback(() => {
|
||||
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
|
||||
@@ -985,9 +1038,9 @@ export function PvPRoguelikeScreen({
|
||||
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
|
||||
beginUpgradePhase()
|
||||
}
|
||||
}, TICK_MS)
|
||||
}, TICK_MS / speedMultiplier)
|
||||
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(() => {
|
||||
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])
|
||||
|
||||
useGameAction((action) => {
|
||||
if (action === 'toggleSpeed') {
|
||||
if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
|
||||
return
|
||||
}
|
||||
if (action === 'pause' || action === 'back') {
|
||||
if (status === 'playing') setPaused((value) => !value)
|
||||
return
|
||||
@@ -1175,6 +1232,7 @@ export function PvPRoguelikeScreen({
|
||||
directPartyTargeting,
|
||||
paused,
|
||||
targetGroup,
|
||||
speedMultiplier,
|
||||
}), [
|
||||
bindings,
|
||||
controllerIconStyle,
|
||||
@@ -1199,6 +1257,7 @@ export function PvPRoguelikeScreen({
|
||||
playerSide.party,
|
||||
playerSide.resource,
|
||||
selectedId,
|
||||
speedMultiplier,
|
||||
stage,
|
||||
starterSpells,
|
||||
status,
|
||||
@@ -1237,6 +1296,7 @@ export function PvPRoguelikeScreen({
|
||||
<div className="resource-row pvp-resource-row">
|
||||
<div className="pvp-resource-wrap">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
+185
-97
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
allocateTalent,
|
||||
resetTalents,
|
||||
type CharacterProfile,
|
||||
type Talent,
|
||||
} from '../profile'
|
||||
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
|
||||
|
||||
type Props = {
|
||||
profile: CharacterProfile
|
||||
@@ -13,199 +14,286 @@ type Props = {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const EFFECT_SLOT_LEVELS = [5, 10, 15, 20] as const
|
||||
const EFFECT_CLASS_ID = 1
|
||||
const EFFECTS_PER_PAGE = 8
|
||||
const EFFECT_SOURCE_LABELS: Record<string, string> = {
|
||||
mend: 'Mend',
|
||||
radiance: 'Radiance',
|
||||
shield: 'Shield',
|
||||
}
|
||||
|
||||
function effectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'mend'
|
||||
if (effectType.startsWith('radiance_')) return 'radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
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) {
|
||||
const { enabled: dualScreenEnabled } = useDualScreen()
|
||||
const [busyTalentId, setBusyTalentId] = useState<number | null>(null)
|
||||
const [talentPage, setTalentPage] = useState(0)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const [selectedTalentId, setSelectedTalentId] = useState<number | null>(null)
|
||||
const [effectPage, setEffectPage] = useState(0)
|
||||
const [message, setMessage] = useState('')
|
||||
const scrollRef = useRef<number>(0)
|
||||
const gameClass = profile.classes.find(
|
||||
(candidate) => candidate.id === profile.character.classId,
|
||||
)!
|
||||
const classPointsSpent = gameClass.talents.reduce(
|
||||
(total, talent) => total + talent.rank,
|
||||
0,
|
||||
const isEffectClass = gameClass.id === EFFECT_CLASS_ID
|
||||
const capacity = isEffectClass ? effectCapacity(profile.character.level) : 0
|
||||
const selectedEffects = activeEffects(gameClass.talents)
|
||||
const selectedTalent = gameClass.talents.find((talent) => talent.id === selectedTalentId)
|
||||
?? selectedEffects[0]
|
||||
?? gameClass.talents[0]
|
||||
?? null
|
||||
const effectPageCount = Math.max(1, Math.ceil(gameClass.talents.length / EFFECTS_PER_PAGE))
|
||||
const visibleTalents = gameClass.talents.slice(
|
||||
effectPage * EFFECTS_PER_PAGE,
|
||||
effectPage * EFFECTS_PER_PAGE + EFFECTS_PER_PAGE,
|
||||
)
|
||||
const tiers = Array.from(
|
||||
new Set(gameClass.talents.map((talent) => talent.tier)),
|
||||
).sort((a, b) => a - b)
|
||||
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(() => {
|
||||
window.scrollTo(0, scrollRef.current)
|
||||
}, [profile])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTalentId && gameClass.talents.some((talent) => talent.id === selectedTalentId)) return
|
||||
setSelectedTalentId(selectedTalent?.id ?? null)
|
||||
}, [gameClass.talents, selectedTalent?.id, selectedTalentId])
|
||||
|
||||
useEffect(() => {
|
||||
setEffectPage((page) => Math.min(page, effectPageCount - 1))
|
||||
}, [effectPageCount])
|
||||
|
||||
function saveScroll() {
|
||||
scrollRef.current = window.scrollY
|
||||
}
|
||||
|
||||
function lowerTierPoints(talent: Talent) {
|
||||
return gameClass.talents
|
||||
.filter((candidate) => candidate.tier < talent.tier)
|
||||
.reduce((total, candidate) => total + candidate.rank, 0)
|
||||
}
|
||||
|
||||
function lockReason(talent: Talent) {
|
||||
if (talent.rank >= talent.maxRank) return 'Maximum rank'
|
||||
|
||||
const requiredTierPoints = (talent.tier - 1) * 5
|
||||
if (lowerTierPoints(talent) < requiredTierPoints) {
|
||||
return `Requires ${requiredTierPoints} earlier-tier points`
|
||||
if (!isEffectClass) return 'Coming soon'
|
||||
if (talent.rank > 0) return ''
|
||||
const source = effectSource(talent.effectType)
|
||||
const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
|
||||
if (sourceConflict) return `${EFFECT_SOURCE_LABELS[source] ?? source} already selected`
|
||||
if (capacity <= 0) return 'Unlocks at level 5'
|
||||
if (selectedEffects.length >= capacity) {
|
||||
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 ''
|
||||
}
|
||||
|
||||
async function purchaseRank(talent: Talent) {
|
||||
async function toggleEffect(talent: Talent) {
|
||||
saveScroll()
|
||||
setBusyTalentId(talent.id)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await allocateTalent(talent.id)
|
||||
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) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to allocate talent.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to update spell effect.')
|
||||
} finally {
|
||||
setBusyTalentId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function refundTree() {
|
||||
async function clearEffects() {
|
||||
saveScroll()
|
||||
setResetting(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updated = await resetTalents()
|
||||
onUpdated(updated)
|
||||
setMessage('All points in this talent tree were refunded.')
|
||||
setMessage('Spell effects cleared.')
|
||||
} catch (reason) {
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to reset talents.')
|
||||
setMessage(reason instanceof Error ? reason.message : 'Unable to clear spell effects.')
|
||||
} finally {
|
||||
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 = (
|
||||
<>
|
||||
{!embedded && (
|
||||
<div className="screen-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Character Growth</p>
|
||||
<h1>Talents</h1>
|
||||
<h1>Spell Effects</h1>
|
||||
</div>
|
||||
<button className="back-button" onClick={onBack} type="button">Back</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="talent-toolbar">
|
||||
<div className="talent-toolbar spell-effect-toolbar">
|
||||
<div className="talent-class-summary">
|
||||
<span style={{ borderColor: gameClass.themeColor, color: gameClass.themeColor }}>
|
||||
{gameClass.name[0]}
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">{gameClass.name} Tree</p>
|
||||
<h2>Shape Your Healing Style</h2>
|
||||
<p className="eyebrow">{gameClass.name} Effects</p>
|
||||
<h2>Modify Your Spells</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="talent-points">
|
||||
<strong>{profile.character.talentPoints}</strong>
|
||||
<span>Available</span>
|
||||
<small>{classPointsSpent} spent in this tree</small>
|
||||
<strong>{selectedEffects.length}/{capacity}</strong>
|
||||
<span>Active</span>
|
||||
<small>Slots unlock at levels 5, 10, 15, 20</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="talent-page-tabs" role="tablist" aria-label="Talent tier pages">
|
||||
{tierPages.map((pageTiers, index) => (
|
||||
{!isEffectClass ? (
|
||||
<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
|
||||
aria-selected={talentPage === index}
|
||||
className={talentPage === index ? 'active' : ''}
|
||||
key={pageTiers.join('-')}
|
||||
onClick={() => setTalentPage(index)}
|
||||
role="tab"
|
||||
className={`effect-slot ${effect ? 'filled' : ''} ${unlocked ? '' : 'locked'}`}
|
||||
disabled={!effect}
|
||||
key={level}
|
||||
onClick={() => effect && setSelectedTalentId(effect.id)}
|
||||
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>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<div className="talent-tree">
|
||||
{visibleTiers.map((tier) => {
|
||||
const requiredPoints = (tier - 1) * 5
|
||||
return (
|
||||
<section className="talent-tier" key={tier}>
|
||||
<div className="tier-label">
|
||||
<span>Tier {tier}</span>
|
||||
<small>
|
||||
{tier === 1 ? 'Open' : `${requiredPoints} earlier-tier points`}
|
||||
</small>
|
||||
<section className="effect-pool-panel">
|
||||
<div className="effect-panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Effect Pool</p>
|
||||
<h2>Choose and Swap</h2>
|
||||
</div>
|
||||
<div className="tier-talents">
|
||||
{gameClass.talents
|
||||
.filter((talent) => talent.tier === tier)
|
||||
.sort((a, b) => a.branch - b.branch)
|
||||
.map((talent) => {
|
||||
<span>{selectedEffects.length}/{capacity} active</span>
|
||||
</div>
|
||||
<div className="selected-effect-strip">
|
||||
<div>
|
||||
<p className="eyebrow">Selected Effect</p>
|
||||
{selectedTalent ? (
|
||||
<>
|
||||
<strong>{selectedTalent.name}</strong>
|
||||
<small>{selectedTalent.description}</small>
|
||||
</>
|
||||
) : (
|
||||
<small>No effect selected.</small>
|
||||
)}
|
||||
</div>
|
||||
{selectedTalent && (
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={Boolean(lockReason(selectedTalent)) || busyTalentId === selectedTalent.id}
|
||||
onClick={() => toggleEffect(selectedTalent)}
|
||||
type="button"
|
||||
>
|
||||
{busyTalentId === selectedTalent.id
|
||||
? 'Saving...'
|
||||
: selectedTalent.rank > 0
|
||||
? 'Remove'
|
||||
: 'Activate'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="effect-pool">
|
||||
{visibleTalents.map((talent) => {
|
||||
const reason = lockReason(talent)
|
||||
const active = talent.rank > 0
|
||||
const selected = selectedTalent?.id === talent.id
|
||||
const isBusy = busyTalentId === talent.id
|
||||
return (
|
||||
<article
|
||||
className={`talent-node ${reason ? 'locked' : 'available'} ${talent.rank > 0 ? 'invested' : ''}`}
|
||||
<button
|
||||
className={`${active ? 'active' : ''} ${selected ? 'selected' : ''}`}
|
||||
disabled={Boolean(reason) || isBusy}
|
||||
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>
|
||||
<div>
|
||||
<strong>{talent.name}</strong>
|
||||
<small>Rank {talent.rank}/{talent.maxRank}</small>
|
||||
<small>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
|
||||
</div>
|
||||
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p>{talent.description}</p>
|
||||
<div className="rank-pips">
|
||||
{Array.from({ length: talent.maxRank }, (_, index) => (
|
||||
<i className={index < talent.rank ? 'filled' : ''} key={index} />
|
||||
))}
|
||||
</div>
|
||||
{effectPageCount > 1 && (
|
||||
<div className="effect-pager">
|
||||
<button
|
||||
disabled={Boolean(reason) || isBusy}
|
||||
onClick={() => purchaseRank(talent)}
|
||||
disabled={effectPage === 0}
|
||||
onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
|
||||
type="button"
|
||||
>
|
||||
{isBusy ? 'Saving...' : reason || 'Add Rank'}
|
||||
Prev
|
||||
</button>
|
||||
<span>{effectPage + 1}/{effectPageCount}</span>
|
||||
<button
|
||||
disabled={effectPage >= effectPageCount - 1}
|
||||
onClick={() => setEffectPage((page) => Math.min(effectPageCount - 1, page + 1))}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="talent-footer">
|
||||
<span>{message || 'Talent changes are saved immediately.'}</span>
|
||||
<span>{message || 'Spell effect changes are saved immediately.'}</span>
|
||||
<button
|
||||
className="text-button"
|
||||
disabled={classPointsSpent === 0 || resetting}
|
||||
onClick={refundTree}
|
||||
disabled={selectedEffects.length === 0 || resetting}
|
||||
onClick={clearEffects}
|
||||
type="button"
|
||||
>
|
||||
{resetting ? 'Refunding...' : 'Reset Tree'}
|
||||
{resetting ? 'Clearing...' : 'Clear Effects'}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
|
||||
+12
-1
@@ -54,6 +54,7 @@ export type DualScreenCombatState = {
|
||||
directPartyTargeting: boolean
|
||||
paused: boolean
|
||||
targetGroup: 0 | 1 | 2
|
||||
speedMultiplier: 1 | 2
|
||||
}
|
||||
|
||||
export type DualScreenWorkshopState = {
|
||||
@@ -118,6 +119,13 @@ function loadRecentSnapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
function memberHotEffects(member: PartyMember) {
|
||||
if (member.hotEffects?.length) return member.hotEffects
|
||||
return member.hotTicks > 0
|
||||
? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function DualScreenProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEY) === 'true',
|
||||
@@ -438,6 +446,7 @@ export function DualScreenBottomDisplay() {
|
||||
</div>
|
||||
<div className="dual-controls-mana">
|
||||
<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">
|
||||
<span style={{ width: `${(state.resource / state.maxResource) * 100}%` }} />
|
||||
</div>
|
||||
@@ -599,7 +608,9 @@ export function DualScreenTopCombat({
|
||||
</div>
|
||||
)}
|
||||
<div className="member-effects">
|
||||
{member.hotTicks > 0 && <span className="buff">Renew</span>}
|
||||
{memberHotEffects(member).map((effect) => (
|
||||
<span className="buff" key={effect.id}>{effect.label}</span>
|
||||
))}
|
||||
{member.debuff && <span className="debuff">{member.debuff}</span>}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PartyMember = {
|
||||
hotTicks: number
|
||||
hotEffects?: Array<{
|
||||
id: string
|
||||
spellId: string
|
||||
label: string
|
||||
ticks: number
|
||||
power: number
|
||||
|
||||
+71
-9
@@ -103,6 +103,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
|
||||
const onlineCacheKey = 'chronicle.onlineCache.v1'
|
||||
const authTokenKey = 'chronicle.authToken.v1'
|
||||
const offlineAccount = { id: -1, username: 'Offline' }
|
||||
const ABILITY_SLOT_COUNT = 6
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value)
|
||||
@@ -147,7 +148,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
level: cid === p.character.classId ? p.character.level : 1,
|
||||
experience: cid === p.character.classId ? p.character.experience : 0,
|
||||
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1,
|
||||
abilitySlots: cid === p.character.classId ? [...p.abilitySlots] : [],
|
||||
abilitySlots: cid === p.character.classId ? normalizeAbilitySlots(p.abilitySlots) : [],
|
||||
talentRanks,
|
||||
inventory: cid === p.character.classId ? clone(p.inventory) : [],
|
||||
}
|
||||
@@ -164,11 +165,32 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
|
||||
}
|
||||
|
||||
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
|
||||
return {
|
||||
return normalizeSaveAbilitySlots({
|
||||
...v2,
|
||||
version: 3,
|
||||
completedRaidPhases: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeAbilitySlots(abilitySlots: unknown): Array<number | null> {
|
||||
const slots = Array.isArray(abilitySlots)
|
||||
? abilitySlots
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((value) => {
|
||||
if (value === null || value === undefined) return null
|
||||
const id = Number(value)
|
||||
return Number.isInteger(id) ? id : null
|
||||
})
|
||||
: []
|
||||
while (slots.length < ABILITY_SLOT_COUNT) slots.push(null)
|
||||
return slots
|
||||
}
|
||||
|
||||
function normalizeSaveAbilitySlots(save: OfflineSave): OfflineSave {
|
||||
for (const character of Object.values(save.characters)) {
|
||||
character.abilitySlots = normalizeAbilitySlots(character.abilitySlots)
|
||||
}
|
||||
return save
|
||||
}
|
||||
|
||||
function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
@@ -178,12 +200,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
|
||||
profile?: CharacterProfile
|
||||
lootRolls?: Record<string, LootRoll>
|
||||
}
|
||||
if (candidate.version === 3) return candidate as OfflineSave
|
||||
if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave)
|
||||
if (candidate.version === 2) {
|
||||
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
|
||||
}
|
||||
if (candidate.version === 1 && candidate.profile) {
|
||||
return upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> })
|
||||
return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record<string, LootRoll> }))
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -406,6 +428,17 @@ function scaledPvpBossExperience(
|
||||
return { experience, level }
|
||||
}
|
||||
|
||||
function talentEffectCapacity(level: number) {
|
||||
return Math.min(4, Math.max(0, Math.floor(level / 5)))
|
||||
}
|
||||
|
||||
function talentEffectSource(effectType: string) {
|
||||
if (effectType.startsWith('mend_')) return 'Mend'
|
||||
if (effectType.startsWith('radiance_')) return 'Radiance'
|
||||
if (effectType.startsWith('shield_') || effectType.startsWith('shielded_')) return 'Shield'
|
||||
return effectType
|
||||
}
|
||||
|
||||
type ComponentTemplate = { id: number; slug: string; name: string; itemLevel: number; glyph: string; description: string }
|
||||
const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
|
||||
1: { id: 600, slug: 'minor-component', name: 'Minor Component', itemLevel: 1, glyph: '◆', description: 'A basic crafting component.' },
|
||||
@@ -448,7 +481,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
|
||||
level: profile.character.level,
|
||||
experience: profile.character.experience,
|
||||
talentPoints: profile.character.talentPoints,
|
||||
abilitySlots: [...profile.abilitySlots],
|
||||
abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
|
||||
talentRanks,
|
||||
inventory: clone(profile.inventory),
|
||||
}
|
||||
@@ -787,9 +820,9 @@ function emptyCharacterData(classId: number): CharacterData {
|
||||
const inventory: Item[] = []
|
||||
const startingAbilitySlots: Array<number | null> = gc.spells
|
||||
.filter((s) => s.unlockLevel === 1)
|
||||
.slice(0, 5)
|
||||
.slice(0, ABILITY_SLOT_COUNT)
|
||||
.map((s) => s.id)
|
||||
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null)
|
||||
while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
|
||||
return {
|
||||
level: 1,
|
||||
experience: 0,
|
||||
@@ -833,8 +866,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
const gameClass = static_.classes.find((candidate) => candidate.id === classId)
|
||||
if (!gameClass) throw new Error('Selected class does not exist.')
|
||||
|
||||
const slots = abilitySlots.slice(0, 6)
|
||||
while (slots.length < 6) slots.push(null)
|
||||
const slots = normalizeAbilitySlots(abilitySlots)
|
||||
const selectedIds = slots.filter((id): id is number => id !== null)
|
||||
if (new Set(selectedIds).size !== selectedIds.length) {
|
||||
throw new Error('The same ability cannot be equipped twice.')
|
||||
@@ -1081,6 +1113,34 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
)!
|
||||
const talent = gameClass.talents.find((candidate) => candidate.id === talentId)
|
||||
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 source = talentEffectSource(talent.effectType)
|
||||
const sourceConflict = gameClass.talents.find(
|
||||
(candidate) =>
|
||||
candidate.id !== talentId
|
||||
&& (cd.talentRanks[String(candidate.id)] ?? 0) > 0
|
||||
&& talentEffectSource(candidate.effectType) === source,
|
||||
)
|
||||
if (sourceConflict) {
|
||||
throw new Error(`Only one ${source} spell effect can be active.`)
|
||||
}
|
||||
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) {
|
||||
throw new Error('No talent points are available.')
|
||||
}
|
||||
@@ -1123,10 +1183,12 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
|
||||
for (const talent of gameClass.talents) {
|
||||
cd.talentRanks[String(talent.id)] = 0
|
||||
}
|
||||
if (save.activeClassId !== 1) {
|
||||
cd.talentPoints = Math.min(
|
||||
profile.maxTalentPoints,
|
||||
cd.talentPoints + refunded,
|
||||
)
|
||||
}
|
||||
store.writeSave(save)
|
||||
return buildProfile(save)
|
||||
},
|
||||
|
||||
+18
-2
@@ -35,6 +35,7 @@ export const INPUT_ACTIONS = [
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
'pause',
|
||||
] as const
|
||||
|
||||
@@ -63,6 +64,7 @@ export const ACTION_LABELS: Record<InputAction, string> = {
|
||||
targetParty5: 'Target Party Member 5',
|
||||
targetParty6: 'Target Party Member 6',
|
||||
toggleTargetGroup: 'Switch Raid Target Group',
|
||||
toggleSpeed: 'Toggle 2x Speed',
|
||||
pause: 'Pause Menu',
|
||||
}
|
||||
|
||||
@@ -89,6 +91,7 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty5: 'F5',
|
||||
targetParty6: 'F6',
|
||||
toggleTargetGroup: 'Tab',
|
||||
toggleSpeed: 'Backquote',
|
||||
pause: 'Escape',
|
||||
},
|
||||
controller: {
|
||||
@@ -111,8 +114,9 @@ export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
|
||||
targetParty3: 'Button15',
|
||||
targetParty4: 'Button13',
|
||||
targetParty5: 'Button4',
|
||||
targetParty6: 'Button11',
|
||||
targetParty6: 'Button10',
|
||||
toggleTargetGroup: 'Button6',
|
||||
toggleSpeed: 'Button11',
|
||||
pause: 'Button9',
|
||||
},
|
||||
}
|
||||
@@ -145,7 +149,8 @@ const InputContext = createContext<InputContextValue | null>(null)
|
||||
function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
try {
|
||||
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 = [
|
||||
'Button2',
|
||||
'Button3',
|
||||
@@ -166,6 +171,15 @@ function loadBindings(): Record<InputDevice, InputBindings> {
|
||||
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 {
|
||||
pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
|
||||
controller,
|
||||
@@ -504,9 +518,11 @@ export function InputProvider({ children }: { children: ReactNode }) {
|
||||
'targetParty5',
|
||||
'targetParty6',
|
||||
'toggleTargetGroup',
|
||||
'toggleSpeed',
|
||||
] satisfies InputAction[]
|
||||
const combatPriority = [
|
||||
'pause',
|
||||
'toggleSpeed',
|
||||
'ability1',
|
||||
'ability2',
|
||||
'ability3',
|
||||
|
||||
+1090
-97
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user