Compare commits

...

11 Commits

Author SHA1 Message Date
Warren H f8a1fbc5e2 Android build v1.0.38 2026-06-20 18:06:39 -04:00
Warren H bab2dce6c3 Android build v1.0.37 2026-06-20 17:52:12 -04:00
Warren H cb38042eca Android build v1.0.37 2026-06-20 17:50:57 -04:00
Warren H 753bba581a Android build v1.0.36 2026-06-20 16:57:03 -04:00
Warren H 2300973164 Android build v1.0.35 2026-06-20 16:10:35 -04:00
Warren H 1281be69d8 Android build v1.0.34 2026-06-20 15:08:51 -04:00
Warren H 4fc15ebe9a Android build v1.0.33 2026-06-20 13:21:49 -04:00
Warren H 7313c968e6 Android build v1.0.32 2026-06-20 12:50:48 -04:00
Warren H 207fcd1a15 Android build v1.0.31 2026-06-19 23:14:56 -04:00
Warren H fd6a1ce3c7 Android build v1.0.30 2026-06-19 22:10:14 -04:00
Warren H f8b98e6b23 Android build v1.0.29 2026-06-19 21:58:23 -04:00
25 changed files with 3946 additions and 697 deletions
+1
View File
@@ -4,5 +4,6 @@
- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz.
- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels. - AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels.
- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540. - Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540.
- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport.
- User rebuilds app; do not rebuild APK unless explicitly requested. - User rebuilds app; do not rebuild APK unless explicitly requested.
- Apply game changes to both web version and mobile app version. - Apply game changes to both web version and mobile app version.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "com.warren.iwanttoheal" applicationId "com.warren.iwanttoheal"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46 versionCode 58
versionName "1.0.28" versionName "1.0.38"
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.
+97 -16
View File
@@ -134,22 +134,22 @@ INSERT OR IGNORE INTO spells
VALUES VALUES
(1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'), (1, 1, 'mend', 'Mend', 'direct_heal', 5, 0.5, 30, 1, '+', 'A fast, efficient single-target heal.'),
(2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'), (2, 1, 'renew', 'Renew', 'heal_over_time', 7, 0.5, 12, 1, '~', 'Heals now and continues healing over time.'),
(3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to every living party member.'), (3, 1, 'radiance', 'Radiance', 'party_heal', 12, 8, 18, 1, '*', 'Restores health to up to 4 injured party members.'),
(4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'), (4, 1, 'sun-ward', 'Sun Ward', 'absorb', 8, 7, 36, 1, 'O', 'Places a damage-absorbing shield on your target.'),
(5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'), (5, 1, 'purify', 'Purify', 'cleanse', 5, 5, 10, 1, 'x', 'Removes a harmful effect and restores health.'),
(6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for the entire party.'), (6, 1, 'dawn-burst', 'Dawn Burst', 'party_heal', 16, 12, 28, 5, 'D', 'A brilliant wave of healing for up to 4 injured allies.'),
(7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'), (7, 1, 'guardian-light', 'Guardian Light', 'absorb', 13, 14, 55, 10, 'G', 'A powerful ward reserved for moments of extreme danger.'),
(8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'), (8, 1, 'second-sun', 'Second Sun', 'direct_heal', 20, 20, 85, 15, 'S', 'Calls down a delayed surge of restorative light.'),
(9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods the party with the full strength of dawn.'), (9, 1, 'daybreak', 'Daybreak', 'party_heal', 23, 30, 48, 20, 'A', 'Floods up to 4 injured allies with the full strength of dawn.'),
(20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'), (20, 2, 'verdant-touch', 'Verdant Touch', 'direct_heal', 5, 0.5, 28, 1, '+', 'A quick pulse of living energy.'),
(21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'), (21, 2, 'seed-of-life', 'Seed of Life', 'heal_over_time', 7, 0.5, 11, 1, 's', 'Plants a restorative seed that blooms over time.'),
(22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads through the party.'), (22, 2, 'wild-bloom', 'Wild Bloom', 'party_heal', 12, 8, 17, 1, '*', 'Restorative growth spreads to up to 4 injured allies.'),
(23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'), (23, 2, 'barkskin', 'Barkskin', 'absorb', 8, 7, 34, 1, 'B', 'Wraps an ally in protective living bark.'),
(24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'), (24, 2, 'purging-sap', 'Purging Sap', 'cleanse', 5, 5, 10, 1, 'p', 'Draws a harmful effect out through enchanted sap.'),
(25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'), (25, 2, 'ancient-grove', 'Ancient Grove', 'party_heal', 17, 12, 31, 5, 'T', 'Briefly summons the shelter of an ancient grove.'),
(30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'), (30, 3, 'etched-mend', 'Etched Mend', 'direct_heal', 5, 0.5, 29, 1, '+', 'Completes a simple rune of restoration.'),
(31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'), (31, 3, 'echo-rune', 'Echo Rune', 'heal_over_time', 7, 0.5, 12, 1, 'e', 'Repeats a restorative rune over several moments.'),
(32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links the party through a shared healing pattern.'), (32, 3, 'concordance', 'Concordance', 'party_heal', 12, 8, 18, 1, '*', 'Links up to 4 injured allies through a shared healing pattern.'),
(33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'), (33, 3, 'aegis-script', 'Aegis Script', 'absorb', 8, 7, 35, 1, 'O', 'Writes a temporary barrier around an ally.'),
(34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'), (34, 3, 'unravel', 'Unravel', 'cleanse', 5, 5, 10, 1, 'u', 'Unravels a hostile magical pattern.'),
(35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.'); (35, 3, 'grand-design', 'Grand Design', 'party_heal', 16, 12, 30, 5, 'R', 'Activates a prepared network of restorative runes.');
@@ -191,6 +191,76 @@ UPDATE spells SET unlock_level = 10 WHERE slug = 'guardian-light';
UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun'; UPDATE spells SET unlock_level = 15 WHERE slug = 'second-sun';
UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak'; UPDATE spells SET unlock_level = 20 WHERE slug = 'daybreak';
UPDATE spells SET
name = 'Verdant Touch',
spell_type = 'direct_hot',
resource_cost = 5,
cooldown_seconds = 0.5,
power = 20,
glyph = '+',
description = 'A weaker direct heal that also plants a stacking heal over time.'
WHERE slug = 'verdant-touch';
UPDATE spells SET
name = 'Wild Growth',
spell_type = 'party_hot',
resource_cost = 12,
cooldown_seconds = 8,
power = 14,
glyph = '*',
description = 'Applies a stacking heal over time to up to 4 injured allies.'
WHERE slug = 'wild-bloom';
UPDATE spells SET
name = 'Barkskin',
spell_type = 'damage_reduction',
resource_cost = 10,
cooldown_seconds = 14,
power = 0,
glyph = 'B',
description = 'Reduces the target ally''s damage taken by 50% for 8 seconds.'
WHERE slug = 'barkskin';
UPDATE spells SET
name = 'Ancient Grove',
spell_type = 'party_hot',
resource_cost = 17,
cooldown_seconds = 12,
power = 24,
glyph = 'T',
description = 'Applies a stronger stacking heal over time to up to 4 injured allies.'
WHERE slug = 'ancient-grove';
UPDATE spells SET
name = 'Mending Rune',
spell_type = 'bounce_heal',
resource_cost = 7,
cooldown_seconds = 0.5,
power = 18,
glyph = 'e',
description = 'Places a rune that heals when the ally takes damage, then jumps 4 times.'
WHERE slug = 'echo-rune';
UPDATE spells SET
name = 'Concordance',
spell_type = 'party_absorb',
resource_cost = 12,
cooldown_seconds = 8,
power = 28,
glyph = '*',
description = 'Shields up to 4 injured allies through a shared barrier pattern.'
WHERE slug = 'concordance';
UPDATE spells SET
name = 'Grand Design',
spell_type = 'party_absorb',
resource_cost = 16,
cooldown_seconds = 12,
power = 42,
glyph = 'R',
description = 'Raises a stronger shared barrier around up to 4 injured allies.'
WHERE slug = 'grand-design';
INSERT OR IGNORE INTO items INSERT OR IGNORE INTO items
(id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description) (id, slug, name, slot, rarity, item_level, healing_power, max_resource_bonus, glyph, description)
VALUES VALUES
@@ -491,7 +561,9 @@ SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
@@ -606,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.'),
@@ -724,6 +800,7 @@ INSERT INTO generated_loot_tiers
(item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity) (item_level, dungeon_id, raid_id, dungeon_difficulty_id, raid_difficulty_id, recipe_base, craft_quantity)
VALUES VALUES
(10, 3, 2, 2, 101, 1100, 2), (10, 3, 2, 2, 101, 1100, 2),
(15, 4, 5, 3, 103, 1200, 3),
(20, 6, 7, 4, 104, 1300, 4), (20, 6, 7, 4, 104, 1300, 4),
(25, 8, 9, 5, 105, 1400, 5); (25, 8, 9, 5, 105, 1400, 5);
@@ -1277,18 +1354,20 @@ WHERE recipe_id IN (
SELECT crafting_recipes.id SELECT crafting_recipes.id
FROM crafting_recipes FROM crafting_recipes
JOIN items ON items.id = crafting_recipes.item_id 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 DELETE FROM crafting_recipes
WHERE item_id IN ( 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 UPDATE items
SET rarity = CASE item_level SET rarity = CASE item_level
WHEN 1 THEN 'common' WHEN 1 THEN 'common'
WHEN 5 THEN 'uncommon'
WHEN 10 THEN 'uncommon' WHEN 10 THEN 'uncommon'
WHEN 15 THEN 'rare'
WHEN 20 THEN 'epic' WHEN 20 THEN 'epic'
WHEN 25 THEN 'legendary' WHEN 25 THEN 'legendary'
ELSE rarity ELSE rarity
@@ -1300,7 +1379,9 @@ SET name = (
SELECT SELECT
CASE items.item_level CASE items.item_level
WHEN 1 THEN 'Raw ' WHEN 1 THEN 'Raw '
WHEN 5 THEN 'Honed '
WHEN 10 THEN 'Green ' WHEN 10 THEN 'Green '
WHEN 15 THEN 'Blue '
WHEN 20 THEN 'Purple ' WHEN 20 THEN 'Purple '
WHEN 25 THEN 'Orange ' WHEN 25 THEN 'Orange '
ELSE '' ELSE ''
+2 -2
View File
@@ -79,9 +79,9 @@ cd /Users/warren/Documents/testgame/testgame
export GITEA_URL="https://git.whoagland.com" export GITEA_URL="https://git.whoagland.com"
export GITEA_OWNER="phenom" export GITEA_OWNER="phenom"
export GITEA_REPO="i-want-to-heal" export GITEA_REPO="i-want-to-heal"
export GITEA_TOKEN="PASTE_TOKEN_HERE" export GITEA_TOKEN="ed2db3fd54546e9658377d0551b3fc3961583f1d"
VERSION="1.0.26" VERSION="1.0.27"
APK="IWantToHeal-Thor-v$VERSION.apk" APK="IWantToHeal-Thor-v$VERSION.apk"
RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \ RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \
@@ -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

+126 -20
View File
@@ -22,6 +22,7 @@ const bossImageContentTypes = {
} }
const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket'] const equipmentSlots = ['weapon', 'helmet', 'chest', 'gloves', 'boots', 'pants', 'ring', 'necklace', 'trinket']
const componentSlot = 'component' const componentSlot = 'component'
const directCraftItemLevels = new Set([1, 10, 20, 25])
const sessionCookieName = 'chronicle_session' const sessionCookieName = 'chronicle_session'
const sessionLifetimeSeconds = 60 * 60 * 24 * 30 const sessionLifetimeSeconds = 60 * 60 * 24 * 30
const rateLimitBuckets = new Map() const rateLimitBuckets = new Map()
@@ -232,6 +233,25 @@ function consumeRateLimit(key, limit, windowMs) {
} }
} }
function catchUpExperienceReward(database, accountId, characterId, baseReward, currentExperience, currentLevel) {
const targetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
if (targetLevel <= currentLevel) return baseReward
const targetExperience = database.prepare(`
SELECT experience_required AS experienceRequired
FROM level_progression
WHERE level = ?
`).get(targetLevel)?.experienceRequired ?? currentExperience
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function normalizeUsername(value) { function normalizeUsername(value) {
const username = String(value ?? '').trim() const username = String(value ?? '').trim()
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
@@ -1693,16 +1713,9 @@ function craftItem(database, characterId, recipeId) {
WHERE crafting_recipes.id = ? WHERE crafting_recipes.id = ?
`).get(recipeId) `).get(recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const lowerTierRecipe = database.prepare(` if (!directCraftItemLevels.has(recipe.itemLevel)) {
SELECT crafting_recipes.id throw new Error('Upgrade the previous item tier instead.')
FROM crafting_recipes }
JOIN items ON items.id = crafting_recipes.item_id
WHERE crafting_recipes.source_encounter_id = ?
AND items.slot = ?
AND items.item_level < ?
LIMIT 1
`).get(recipe.sourceEncounterId, recipe.slot, recipe.itemLevel)
if (lowerTierRecipe) throw new Error('Upgrade the previous item tier instead.')
const components = database.prepare(` const components = database.prepare(`
SELECT SELECT
@@ -1849,9 +1862,20 @@ 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 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) { 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)
@@ -1863,7 +1887,8 @@ function allocateTalent(database, characterId, talentId) {
max_rank AS maxRank, max_rank AS maxRank,
tier, tier,
prerequisite_talent_id AS prerequisiteTalentId, prerequisite_talent_id AS prerequisiteTalentId,
prerequisite_rank AS prerequisiteRank prerequisite_rank AS prerequisiteRank,
effect_type AS effectType
FROM talents FROM talents
WHERE id = ? WHERE id = ?
`).get(talentId) `).get(talentId)
@@ -1871,6 +1896,60 @@ 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 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) { if (character.talentPoints <= 0) {
throw new Error('No talent points are available.') throw new Error('No talent points are available.')
} }
@@ -1949,11 +2028,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')
@@ -2024,12 +2105,21 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3) const completedPart = Math.min(Math.max(Number(runMetrics?.completedPart) || 1, 1), 3)
const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3) const startPart = Math.min(Math.max(Number(runMetrics?.startPart) || 1, 1), 3)
const completedParts = completedPart - startPart + 1 const completedParts = completedPart - startPart + 1
const rewardMultiplier = runMetrics?.hardMode ? 2 : 1
const rawPartDurations = runMetrics?.partDurationSeconds const rawPartDurations = runMetrics?.partDurationSeconds
const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3 const partDurationSeconds = Array.isArray(rawPartDurations) && rawPartDurations.length === 3
? rawPartDurations.map(Number) ? rawPartDurations.map(Number)
: null : null
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * completedPart, dungeon.experienceReward * dungeon.experienceMultiplier * completedPart * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
) )
const newExperience = Math.min(character.experience + experienceReward, maxExperience) const newExperience = Math.min(character.experience + experienceReward, maxExperience)
const newLevel = database.prepare(` const newLevel = database.prepare(`
@@ -2127,17 +2217,18 @@ function completeDungeon(database, characterId, accountId, dungeonId, difficulty
`).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3) `).all(dungeonId, dungeon.completionItemLevel ?? dungeon.droppedItemLevel + 3)
if (bonusItems.length > 0) { if (bonusItems.length > 0) {
bonusItem = bonusItems[0] bonusItem = bonusItems[0]
const rewardQuantity = rewardMultiplier
const previousQuantity = database.prepare(` const previousQuantity = database.prepare(`
SELECT quantity FROM character_inventory SELECT quantity FROM character_inventory
WHERE character_id = ? AND item_id = ? WHERE character_id = ? AND item_id = ?
`).get(characterId, bonusItem.id)?.quantity ?? 0 `).get(characterId, bonusItem.id)?.quantity ?? 0
database.prepare(` database.prepare(`
INSERT INTO character_inventory (character_id, item_id, quantity, equipped) INSERT INTO character_inventory (character_id, item_id, quantity, equipped)
VALUES (?, ?, 1, 0) VALUES (?, ?, ?, 0)
ON CONFLICT(character_id, item_id) ON CONFLICT(character_id, item_id)
DO UPDATE SET quantity = quantity + 1 DO UPDATE SET quantity = quantity + ?
`).run(characterId, bonusItem.id) `).run(characterId, bonusItem.id, rewardQuantity, rewardQuantity)
bonusItem = { ...bonusItem, quantity: 1, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + 1 } bonusItem = { ...bonusItem, quantity: rewardQuantity, duplicate: previousQuantity > 0, quantityAfter: previousQuantity + rewardQuantity }
} }
} }
@@ -2234,6 +2325,12 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
let newExperience = character.experience let newExperience = character.experience
let newLevel = character.level let newLevel = character.level
if (experienceMode === 'pvp-boss-quarter-level') { if (experienceMode === 'pvp-boss-quarter-level') {
const catchUpTargetLevel = database.prepare(`
SELECT COALESCE(MAX(level), 0) AS level
FROM characters
WHERE account_id = ?
AND id != ?
`).get(accountId, characterId).level
for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) { for (let bossIndex = 0; bossIndex < bossesCleared && newExperience < maxExperience; bossIndex += 1) {
const currentLevelFloor = database.prepare(` const currentLevelFloor = database.prepare(`
SELECT experience_required AS experienceRequired SELECT experience_required AS experienceRequired
@@ -2248,7 +2345,8 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
WHERE level = ? WHERE level = ?
`).get(newLevel + 1).experienceRequired `).get(newLevel + 1).experienceRequired
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * 0.25)) const rewardRate = catchUpTargetLevel > newLevel ? 0.5 : 0.25
newExperience = Math.min(maxExperience, newExperience + Math.round(levelBand * rewardRate))
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
FROM level_progression FROM level_progression
@@ -2256,9 +2354,17 @@ function completeRoguelike(database, characterId, accountId, runMetrics) {
`).get(newExperience).level `).get(newExperience).level
} }
} else { } else {
const experienceReward = Math.round( const baseExperienceReward = Math.round(
dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3), dungeon.experienceReward * dungeon.experienceMultiplier * (encountersCleared / 3),
) )
const experienceReward = catchUpExperienceReward(
database,
accountId,
characterId,
baseExperienceReward,
character.experience,
character.level,
)
newExperience = Math.min(character.experience + experienceReward, maxExperience) newExperience = Math.min(character.experience + experienceReward, maxExperience)
newLevel = database.prepare(` newLevel = database.prepare(`
SELECT MAX(level) AS level SELECT MAX(level) AS level
+937 -10
View File
File diff suppressed because it is too large Load Diff
+72 -34
View File
@@ -88,6 +88,7 @@ function App() {
const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability') const [roguelikeAbilityLabelMode, setRoguelikeAbilityLabelMode] = useState<RoguelikeAbilityLabelMode>('ability')
const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon') const [pvpContentType, setPvpContentType] = useState<PvpContentType>('dungeon')
const [selectedPart, setSelectedPart] = useState(1) const [selectedPart, setSelectedPart] = useState(1)
const [selectedHardMode, setSelectedHardMode] = useState(false)
const [combatContentId, setCombatContentId] = useState(1) const [combatContentId, setCombatContentId] = useState(1)
const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1') const [leaderboardCategory, setLeaderboardCategory] = useState<'part_1' | 'part_2' | 'part_3' | 'full_run'>('part_1')
const [showLoot, setShowLoot] = useState(false) const [showLoot, setShowLoot] = useState(false)
@@ -235,6 +236,7 @@ function App() {
<CombatScreen <CombatScreen
difficulty={difficulty} difficulty={difficulty}
dungeon={dungeon} dungeon={dungeon}
hardMode={selectedHardMode && combatContentId > 0}
profile={profile} profile={profile}
roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined} roguelikeMode={combatContentId < 0 ? roguelikeKind : undefined}
roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined} roguelikeUpgradeTiming={combatContentId < 0 ? roguelikeUpgradeTiming : undefined}
@@ -283,6 +285,20 @@ function App() {
const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId) const raid = raidOptions.find((candidate) => candidate.id === selectedRaidId)
?? raidOptions[0] ?? raidOptions[0]
const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions const activityOptions = screen === 'raids' ? raidOptions : dungeonOptions
const startPveRoguelike = () => {
const baseDungeon = dungeonOptions[0]
const baseRaid = raidOptions[0]
if (roguelikeKind === 'raid') {
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
} else {
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon?.difficulties[0]?.id ?? 1)
}
setSelectedPart(1)
setSelectedHardMode(false)
setScreen('combat')
}
const tierOptions = activityOptions const tierOptions = activityOptions
.flatMap((option) => option.difficulties) .flatMap((option) => option.difficulties)
.filter((difficulty, index, all) => ( .filter((difficulty, index, all) => (
@@ -315,9 +331,9 @@ function App() {
: profile.completedDungeonParts : profile.completedDungeonParts
const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = activity.contentType === 'raid' ? 'Phase' : 'Part'
const parts = [ const parts = [
{ part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true }, { part: 1, name: `${sectionName} 1`, encounterCount: 3, unlocked: true, hardUnlocked: completedSections >= 1 },
{ part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1 }, { part: 2, name: `${sectionName} 2`, encounterCount: 3, unlocked: completedSections >= 1, hardUnlocked: completedSections >= 2 },
{ part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2 }, { part: 3, name: `${sectionName} 3`, encounterCount: 3, unlocked: completedSections >= 2, hardUnlocked: completedSections >= 3 },
] ]
const cloudSync = getCloudSyncStatus() const cloudSync = getCloudSyncStatus()
const canShowCloudSync = account.id !== -1 && cloudSync.available const canShowCloudSync = account.id !== -1 && cloudSync.available
@@ -425,6 +441,28 @@ function App() {
</div> </div>
{roguelikeVariant === 'pve' && ( {roguelikeVariant === 'pve' && (
<> <>
<div className="roguelike-option-panel">
<div>
<p className="eyebrow">Run Type</p>
<h2>PvE Roguelike</h2>
</div>
<div className="roguelike-timing-row">
<button
className={`text-button ${roguelikeKind === 'dungeon' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('dungeon')}
type="button"
>
Dungeon
</button>
<button
className={`text-button ${roguelikeKind === 'raid' ? 'active' : ''}`}
onClick={() => setRoguelikeKind('raid')}
type="button"
>
Raid
</button>
</div>
</div>
<div className="roguelike-option-panel"> <div className="roguelike-option-panel">
<div> <div>
<p className="eyebrow">Upgrade Timing</p> <p className="eyebrow">Upgrade Timing</p>
@@ -469,38 +507,22 @@ function App() {
</button> </button>
</div> </div>
</div> </div>
<div className="roguelike-mode-grid"> <div className="menu-card pvp-queue-panel">
<span>{roguelikeKind === 'raid' ? 'R' : 'D'}</span>
<div>
<strong>{roguelikeKind === 'raid' ? 'Raid Roguelike' : 'Dungeon Roguelike'}</strong>
<small>
{roguelikeKind === 'raid'
? 'Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.'
: 'Five-player party. Two random trash enemies and a boss with a lighter early ramp.'}
</small>
</div>
<button <button
className="menu-card" className="text-button"
onClick={() => { onClick={startPveRoguelike}
const baseDungeon = dungeonOptions[0]
setRoguelikeKind('dungeon')
setCombatContentId(-1)
setSelectedDifficultyId(baseDungeon.difficulties[0]?.id ?? 1)
setSelectedPart(1)
setScreen('combat')
}}
type="button" type="button"
> >
<span>D</span> Start Run
<strong>Dungeon Roguelike</strong>
<small>Five-player party. Two random trash enemies and a boss with a lighter early ramp.</small>
</button>
<button
className="menu-card"
onClick={() => {
const baseRaid = raidOptions[0]
setRoguelikeKind('raid')
setCombatContentId(-2)
setSelectedDifficultyId(baseRaid?.difficulties[0]?.id ?? 101)
setSelectedPart(1)
setScreen('combat')
}}
type="button"
>
<span>R</span>
<strong>Raid Roguelike</strong>
<small>Ten-player party. Raid pools, lighter early scaling, and the same upgrade draft.</small>
</button> </button>
</div> </div>
</> </>
@@ -709,12 +731,13 @@ function App() {
</div> </div>
<div className="part-picker"> <div className="part-picker">
{parts.map((p) => ( {parts.map((p) => (
<div className="part-start-row" key={p.part}>
<button <button
key={p.part} className={`primary-button ${selectedPart === p.part && !selectedHardMode ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
className={`primary-button ${selectedPart === p.part ? 'selected-part' : ''} ${!p.unlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.unlocked} disabled={difficultyLocked || !p.unlocked}
onClick={() => { onClick={() => {
setSelectedPart(p.part) setSelectedPart(p.part)
setSelectedHardMode(false)
setCombatContentId(activity.id) setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id) setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat') setScreen('combat')
@@ -723,6 +746,21 @@ function App() {
> >
{p.name} {p.name}
</button> </button>
<button
className={`primary-button hard-mode-button ${selectedPart === p.part && selectedHardMode ? 'selected-part' : ''} ${!p.hardUnlocked ? 'locked' : ''}`}
disabled={difficultyLocked || !p.hardUnlocked}
onClick={() => {
setSelectedPart(p.part)
setSelectedHardMode(true)
setCombatContentId(activity.id)
setSelectedDifficultyId(selectedDifficulty.id)
setScreen('combat')
}}
type="button"
>
Hard
</button>
</div>
))} ))}
</div> </div>
</section> </section>
+317 -66
View File
@@ -10,6 +10,10 @@ import {
import { import {
INITIAL_PARTY, INITIAL_PARTY,
RAID_PARTY, RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry, type CombatLogEntry,
type PartyMember, type PartyMember,
type Spell, type Spell,
@@ -39,7 +43,7 @@ const TICK_MS = 700
type RoguelikeMode = 'dungeon' | 'raid' type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter' type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot' type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic = type RoguelikeMechanic =
| 'party-pulse' | 'party-pulse'
| 'searing-mark' | 'searing-mark'
@@ -101,12 +105,53 @@ 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) {
if (member.hotEffects?.length) return member.hotEffects
return member.hotTicks > 0
? [{ 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) {
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: effectId(spell.id),
label: spell.name,
charges: 4,
power: spell.power,
},
]
}
function tickHotEffects(effects: PartyMember['hotEffects']) {
return (effects ?? [])
.map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
.filter((effect) => effect.ticks > 0)
} }
function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) { function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
@@ -123,7 +168,7 @@ function buildRoguelikeUpgrades(
spells: Spell[], spells: Spell[],
labelMode: RoguelikeAbilityLabelMode, labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] { ): 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) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -191,9 +236,14 @@ function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastR
function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell { function toCombatSpell(ability: Ability, key: string, healingPower: number): Spell {
const kinds: Record<string, Spell['kind']> = { const kinds: Record<string, Spell['kind']> = {
direct_heal: 'direct', direct_heal: 'direct',
direct_hot: 'direct',
heal_over_time: 'hot', heal_over_time: 'hot',
party_heal: 'group', party_heal: 'group',
party_hot: 'group',
party_absorb: 'group',
absorb: 'shield', absorb: 'shield',
damage_reduction: 'damage_reduction',
bounce_heal: 'bounce_heal',
cleanse: 'cleanse', cleanse: 'cleanse',
} }
return { return {
@@ -206,6 +256,7 @@ function toCombatSpell(ability: Ability, key: string, healingPower: number): Spe
power: ability.power + healingPower, power: ability.power + healingPower,
glyph: ability.glyph, glyph: ability.glyph,
kind: kinds[ability.spellType] ?? 'direct', kind: kinds[ability.spellType] ?? 'direct',
effectType: ability.spellType,
} }
} }
@@ -289,6 +340,7 @@ function makeRoguelikeSegment(
export function CombatScreen({ export function CombatScreen({
difficulty, difficulty,
dungeon, dungeon,
hardMode = false,
profile, profile,
startPart = 1, startPart = 1,
roguelikeMode, roguelikeMode,
@@ -300,6 +352,7 @@ export function CombatScreen({
}: { }: {
difficulty: Difficulty difficulty: Difficulty
dungeon: Dungeon dungeon: Dungeon
hardMode?: boolean
profile: CharacterProfile profile: CharacterProfile
startPart?: number startPart?: number
roguelikeMode?: RoguelikeMode roguelikeMode?: RoguelikeMode
@@ -350,20 +403,22 @@ export function CombatScreen({
const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part' const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon' const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
const initialEncounterIndex = (startPart - 1) * 3 const initialEncounterIndex = (startPart - 1) * 3
const enemyCount = hardMode ? 2 : 1
const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({ const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
party: partyTemplate, party: partyTemplate,
resource: maxResource, resource: maxResource,
enemyHealth: encounters[initialEncounterIndex].maxHealth, enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
freeCastReady: false, freeCastReady: false,
}), [encounters, initialEncounterIndex, maxResource, partyTemplate]) }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState) const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
const [selectedId, setSelectedId] = useState(partyTemplate[0].id) const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
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' },
@@ -377,7 +432,7 @@ export function CombatScreen({
const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([]) const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
const rewardClaimedRef = useRef(false) const rewardClaimedRef = useRef(false)
const profileRefreshedRef = useRef(false) const profileRefreshedRef = useRef(false)
const rolledEncounterIdsRef = useRef(new Set<number>()) const rolledEncounterIdsRef = useRef(new Set<string>())
const runTokenRef = useRef(crypto.randomUUID()) const runTokenRef = useRef(crypto.randomUUID())
const resourceSpentRef = useRef(0) const resourceSpentRef = useRef(0)
const runStartedAtRef = useRef(0) const runStartedAtRef = useRef(0)
@@ -391,24 +446,40 @@ 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 currentPart = getCurrentPart(encounterIndex) const currentPart = getCurrentPart(encounterIndex)
const completedSections = dungeon.contentType === 'raid'
? profile.completedRaidPhases
: profile.completedDungeonParts
const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
const firstEncounterIndex = (startPart - 1) * 3 const firstEncounterIndex = (startPart - 1) * 3
const expectedLootRolls = encounters const expectedLootRolls = encounters
.slice(firstEncounterIndex, encounterIndex + 1) .slice(firstEncounterIndex, encounterIndex + 1)
.filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id)) .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
.length .length * enemyCount
const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2 const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1 const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
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,
@@ -422,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()
@@ -480,10 +552,12 @@ export function CombatScreen({
}, []) }, [])
const requestLootRoll = useCallback( const requestLootRoll = useCallback(
(encounterId: number) => { (encounterId: number, rollIndex = 0) => {
if (rolledEncounterIdsRef.current.has(encounterId)) return const rollKey = `${encounterId}:${rollIndex}`
rolledEncounterIdsRef.current.add(encounterId) if (rolledEncounterIdsRef.current.has(rollKey)) return
rollEncounterLoot(encounterId, difficulty.id, runTokenRef.current) rolledEncounterIdsRef.current.add(rollKey)
const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
rollEncounterLoot(encounterId, difficulty.id, runToken)
.then((result) => { .then((result) => {
setLootRolls((current) => [...current, result]) setLootRolls((current) => [...current, result])
const awarded = result.items const awarded = result.items
@@ -515,7 +589,7 @@ export function CombatScreen({
setCombat({ setCombat({
party: freshParty, party: freshParty,
resource: maxResource, resource: maxResource,
enemyHealth: nextEncounters[initialEncounterIndex].maxHealth, enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
cooldowns: {}, cooldowns: {},
elapsedTicks: 0, elapsedTicks: 0,
castsTowardFree: 0, castsTowardFree: 0,
@@ -543,7 +617,7 @@ export function CombatScreen({
runStartedAtRef.current = Date.now() runStartedAtRef.current = Date.now()
partStartTimesRef.current = { [startPart]: runStartedAtRef.current } partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }]) setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
}, [difficulty, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters]) }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])
const castSpell = useCallback( const castSpell = useCallback(
(spell: Spell) => { (spell: Spell) => {
@@ -558,23 +632,36 @@ 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>()
if (spell.kind === 'hot') hotTargets.add(targetId) const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
if (spell.kind === 'hot' || 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)
} }
const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot') {
@@ -594,20 +681,61 @@ export function CombatScreen({
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') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost'))) if (!groupTargets.has(member.id)) return member
const nextHealth = healMember(member, power) if (spell.effectType === 'party_absorb') {
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
return { ...member, health: nextHealth }
}
if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
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, shield: Math.max(member.shield, power) }
} }
if (spell.effectType === 'party_hot') {
return {
...member,
hotTicks: 0,
hotEffects: addHotEffect(member, spell),
}
}
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
const nextHealth = healMember(member, power, healingMultiplier(member))
addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
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)
&& !hotTargets.has(member.id)
&& !shieldTargets.has(member.id)
&& !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
) return member
if (spell.kind === 'shield') {
const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
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 }
}
if (spell.kind === 'bounce_heal') {
return { ...member, bounceHeals: addBounceHeal(member, spell) }
}
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,
@@ -616,13 +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,
hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks, shield: nextShield,
hotTicks: 0,
hotEffects: hotTargets.has(member.id)
? addHotEffect(member, appliedHotSpell)
: member.hotEffects,
} }
}) })
const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free') const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
@@ -640,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(
@@ -676,6 +820,7 @@ export function CombatScreen({
completedPart, completedPart,
runStartPart, runStartPart,
[partDuration(1), partDuration(2), partDuration(3)], [partDuration(1), partDuration(2), partDuration(3)],
hardMode,
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
@@ -688,7 +833,7 @@ export function CombatScreen({
) )
}) })
}, },
[difficulty.id, dungeon.id, onProfileUpdated], [difficulty.id, dungeon.id, hardMode, onProfileUpdated],
) )
const finishRoguelikeRun = useCallback( const finishRoguelikeRun = useCallback(
@@ -786,6 +931,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
const nextSegment = clearedBoss const nextSegment = clearedBoss
@@ -804,7 +952,7 @@ export function CombatScreen({
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
cooldowns: {}, cooldowns: {},
resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource), resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
@@ -812,9 +960,13 @@ export function CombatScreen({
setUpgradeChoices([]) setUpgradeChoices([])
setStatus('playing') setStatus('playing')
addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system') addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
}, [addLog, difficulty, encounterIndex, encounters, 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
@@ -902,19 +1054,53 @@ export function CombatScreen({
} }
const healerBeforeDamage = current.party.find((member) => member.id === 'mira') const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
const nextParty = current.party.map((member) => { const tankPressure = tankPressureTargets(current.party)
const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const pendingJumpHeals: Array<{
targetId: string
heal: NonNullable<PartyMember['bounceHeals']>[number]
}> = []
const damagedParty = current.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounter.damage : 0 let damage = member.id === primaryTarget.id ? encounter.damage : 0
if (member.role === 'Tank') damage += encounter.tankDamage if (tankPressureIds.has(member.id)) {
if (tankBuster && member.role === 'Tank') damage += Math.round(22 * difficulty.damageMultiplier) damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
}
if (tankBuster && tankPressureIds.has(member.id)) {
damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
}
if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier) if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier) if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
? Math.max(1, (member.poisonStacks ?? 0) + 1) ? Math.max(1, (member.poisonStacks ?? 0) + 1)
: member.poisonStacks ?? 0 : member.poisonStacks ?? 0
if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier) if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
damage *= enemyCount
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 absorbed = Math.min(member.shield, damage)
const healing = member.hotTicks > 0 ? healAmount(member, 6) : 0 const hotEffects = memberHotEffects(member)
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, healingMultiplier)
const nextCharges = effect.charges - 1
if (nextCharges <= 0) return []
const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
pendingJumpHeals.push({
targetId: jumpTarget.id,
heal: { ...effect, charges: nextCharges },
})
return []
})
}
if (healing > 0) addFloatingHeal(member.id, healing) if (healing > 0) addFloatingHeal(member.id, healing)
const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
? 15 ? 15
@@ -928,9 +1114,12 @@ export function CombatScreen({
: Math.max(0, (member.debuffTicks ?? 0) - 1) : Math.max(0, (member.debuffTicks ?? 0) - 1)
return { return {
...member, ...member,
health: clamp(member.health - damage + absorbed + healing, 0, nextEffectiveMaxHealth), health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
shield: Math.max(0, member.shield - damage), shield: Math.max(0, member.shield - damage),
hotTicks: Math.max(0, member.hotTicks - 1), hotTicks: 0,
hotEffects: tickHotEffects(hotEffects),
bounceHeals: nextBounceHeals,
damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
debuff: nextDebuffTicks > 0 debuff: nextDebuffTicks > 0
? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff) ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
: undefined, : undefined,
@@ -940,6 +1129,17 @@ export function CombatScreen({
healingReductionTicks: nextHealingReductionTicks, healingReductionTicks: nextHealingReductionTicks,
} }
}) })
const nextParty = damagedParty.map((member) => {
const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
if (jumped.length === 0) return member
return {
...member,
bounceHeals: [
...(member.bounceHeals ?? []),
...jumped.map((jump) => jump.heal),
],
}
})
const healerAfterDamage = nextParty.find((member) => member.id === 'mira') const healerAfterDamage = nextParty.find((member) => member.id === 'mira')
if ( if (
@@ -966,7 +1166,7 @@ export function CombatScreen({
return return
} }
const nextEnemyHealth = current.enemyHealth - encounter.partyDamage const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
if (nextEnemyHealth > 0) { if (nextEnemyHealth > 0) {
setCombat({ setCombat({
...current, ...current,
@@ -980,7 +1180,9 @@ export function CombatScreen({
} }
if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) { if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
requestLootRoll(encounter.id) for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
requestLootRoll(encounter.id, rollIndex)
}
} }
if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
@@ -1037,6 +1239,9 @@ export function CombatScreen({
poisonStacks: undefined, poisonStacks: undefined,
maxHealthPenaltyTicks: undefined, maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined, healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex((value) => value + 1) setEncounterIndex((value) => value + 1)
setCombat({ setCombat({
@@ -1045,13 +1250,15 @@ export function CombatScreen({
resource: nextResource, resource: nextResource,
cooldowns: nextCooldowns, cooldowns: nextCooldowns,
elapsedTicks: 0, elapsedTicks: 0,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
}) })
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,
enemyCount,
encounter, encounter,
encounterIndex, encounterIndex,
encounters, encounters,
@@ -1095,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()
@@ -1120,15 +1328,23 @@ export function CombatScreen({
}) })
}, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward]) }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])
const enemyPercent = (enemyHealth / encounter.maxHealth) * 100 const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
return {
index,
health: remaining,
percent: (remaining / encounter.maxHealth) * 100,
}
}).reverse()
const dualScreenState = useMemo<DualScreenCombatState>(() => ({ const dualScreenState = useMemo<DualScreenCombatState>(() => ({
difficultyName: difficulty.name, difficultyName: difficulty.name,
dungeonName: dungeon.name, dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
contentName, contentName,
encounterName: encounter.enemyName, encounterName: encounter.enemyName,
encounterDescription: encounter.description, encounterDescription: encounter.description,
encounterHealth: enemyHealth, encounterHealth: enemyHealth,
encounterMaxHealth: encounter.maxHealth, encounterMaxHealth,
encounterIsBoss: encounter.isBoss, encounterIsBoss: encounter.isBoss,
encounterIndex, encounterIndex,
encounterCount: encounters.length, encounterCount: encounters.length,
@@ -1158,6 +1374,7 @@ export function CombatScreen({
directPartyTargeting, directPartyTargeting,
paused, paused,
targetGroup, targetGroup,
speedMultiplier,
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
@@ -1170,7 +1387,8 @@ export function CombatScreen({
encounter.description, encounter.description,
encounter.enemyName, encounter.enemyName,
encounter.isBoss, encounter.isBoss,
encounter.maxHealth, encounterMaxHealth,
hardMode,
enemyHealth, enemyHealth,
encounterIndex, encounterIndex,
encounters.length, encounters.length,
@@ -1187,6 +1405,7 @@ export function CombatScreen({
spells, spells,
freeCastReady, freeCastReady,
roguelikeUpgrades, roguelikeUpgrades,
speedMultiplier,
status, status,
targetGroup, targetGroup,
]) ])
@@ -1199,7 +1418,7 @@ export function CombatScreen({
> >
{!dualScreenEnabled && <header className="topbar"> {!dualScreenEnabled && <header className="topbar">
<div> <div>
<p className="eyebrow">{difficulty.name} - Item Level {difficulty.droppedItemLevel}</p> <p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
<h1>{dungeon.name}</h1> <h1>{dungeon.name}</h1>
</div> </div>
<div className="combat-header-actions"> <div className="combat-header-actions">
@@ -1222,10 +1441,21 @@ export function CombatScreen({
</div> </div>
<div className="enemy-info"> <div className="enemy-info">
<div className="bar-label"> <div className="bar-label">
<strong>{encounter.enemyName}</strong> <strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
<span>{Math.ceil(enemyHealth)} / {encounter.maxHealth}</span> <span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
</div> </div>
{hardMode ? (
<div className="hard-enemy-bars">
{enemyHealthSegments.map((segment) => (
<div className="bar enemy-health" key={segment.index}>
<span style={{ width: `${segment.percent}%` }} />
<em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
</div>
))}
</div>
) : (
<div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div> <div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
)}
<p>{encounter.description}</p> <p>{encounter.description}</p>
</div> </div>
</section> </section>
@@ -1239,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>
@@ -1272,7 +1503,14 @@ export function CombatScreen({
.map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)} .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
</div> </div>
<div className="member-effects"> <div className="member-effects">
{member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>} {memberHotEffects(member).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
))}
{member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
{(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
{(member.bounceHeals ?? []).map((effect) => (
<span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
))}
{member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>} {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
{member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>} {member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
{member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>} {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
@@ -1351,7 +1589,7 @@ export function CombatScreen({
{status === 'upgrade-choice' && ( {status === 'upgrade-choice' && (
<div className="result-screen"> <div className="result-screen">
<div> <div className="pvp-upgrade-dialog pve-upgrade-dialog">
<p className="eyebrow"> <p className="eyebrow">
{encounter.isBoss {encounter.isBoss
? `Roguelike Stage ${roguelikeStage} Complete` ? `Roguelike Stage ${roguelikeStage} Complete`
@@ -1359,6 +1597,9 @@ export function CombatScreen({
</p> </p>
<h2>Choose Upgrade</h2> <h2>Choose Upgrade</h2>
<p>Pick one upgrade before the next fight.</p> <p>Pick one upgrade before the next fight.</p>
<div className="pvp-choice-columns">
<div>
<strong>Run Buff</strong>
<div className="upgrade-choice-grid"> <div className="upgrade-choice-grid">
{upgradeChoices.map((upgrade) => ( {upgradeChoices.map((upgrade) => (
<button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button"> <button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
@@ -1367,6 +1608,8 @@ export function CombatScreen({
</button> </button>
))} ))}
</div> </div>
</div>
</div>
{roguelikeUpgrades.length > 0 && ( {roguelikeUpgrades.length > 0 && (
<p className="roguelike-upgrade-list"> <p className="roguelike-upgrade-list">
Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)} Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
@@ -1497,7 +1740,8 @@ export function CombatScreen({
<div> <div>
<p className="eyebrow">{sectionName} Complete</p> <p className="eyebrow">{sectionName} Complete</p>
<h2>{encounter.enemyName} Defeated</h2> <h2>{encounter.enemyName} Defeated</h2>
<p>Proceed to {sectionName} {currentPart + 1} or end the run?</p> <p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
{canContinueAfterPart && (
<button <button
onClick={() => { onClick={() => {
const nextIndex = encounterIndex + 1 const nextIndex = encounterIndex + 1
@@ -1509,12 +1753,18 @@ export function CombatScreen({
health: clamp(member.health + 35, 0, member.maxHealth), health: clamp(member.health + 35, 0, member.maxHealth),
debuff: undefined, debuff: undefined,
debuffTicks: undefined, debuffTicks: undefined,
poisonStacks: undefined,
maxHealthPenaltyTicks: undefined,
healingReductionTicks: undefined,
hotEffects: [],
bounceHeals: [],
damageReductionTicks: undefined,
})) }))
setEncounterIndex(nextIndex) setEncounterIndex(nextIndex)
setCombat({ setCombat({
...current, ...current,
party: recoveredParty, party: recoveredParty,
enemyHealth: nextEncounter.maxHealth, enemyHealth: nextEncounter.maxHealth * enemyCount,
elapsedTicks: 0, elapsedTicks: 0,
}) })
setStatus('playing') setStatus('playing')
@@ -1524,6 +1774,7 @@ export function CombatScreen({
> >
Continue to {sectionName} {currentPart + 1} Continue to {sectionName} {currentPart + 1}
</button> </button>
)}
<button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button"> <button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
End Run End Run
</button> </button>
+42 -3
View File
@@ -4,6 +4,7 @@ import {
type CharacterProfile, type CharacterProfile,
type GameClass, type GameClass,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
import { EquipmentScreen } from './EquipmentScreen' import { EquipmentScreen } from './EquipmentScreen'
import { TalentScreen } from './TalentScreen' import { TalentScreen } from './TalentScreen'
@@ -14,7 +15,8 @@ type Props = {
} }
export function CustomizeScreen({ profile, onBack, onSaved }: Props) { export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
const [activeTab, setActiveTab] = useState<'equipment' | 'talents' | 'class'>('class') const [activeTab, setActiveTab] = useState<'equipment' | 'crafting' | 'talents' | 'class'>('class')
const { enabled: dualScreenEnabled } = useDualScreen()
const [classId, setClassId] = useState(profile.character.classId) const [classId, setClassId] = useState(profile.character.classId)
const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots) const [slots, setSlots] = useState<Array<number | null>>(profile.abilitySlots)
const [selectedSlot, setSelectedSlot] = useState(0) const [selectedSlot, setSelectedSlot] = useState(0)
@@ -38,7 +40,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
function chooseClass(nextClass: GameClass) { function chooseClass(nextClass: GameClass) {
const starterAbilities = nextClass.spells const starterAbilities = nextClass.spells
.filter((ability) => ability.unlockLevel <= profile.character.level) .filter((ability) => ability.unlockLevel <= profile.character.level)
.slice(0, 5) .slice(0, 6)
.map((ability) => ability.id) .map((ability) => ability.id)
setClassId(nextClass.id) setClassId(nextClass.id)
setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)]) setSlots([...starterAbilities, ...Array(6 - starterAbilities.length).fill(null)])
@@ -63,6 +65,29 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
) )
} }
const classWorkshopState = useMemo<DualScreenWorkshopState | null>(() => {
if (activeTab !== 'class') return null
return {
mode: 'class',
title: 'Ability Library',
subtitle: gameClass.name,
summary: `Selected slot ${selectedSlot + 1}. ${message || 'Choose an ability for the active loadout.'}`,
items: gameClass.spells.map((ability) => {
const locked = ability.unlockLevel > profile.character.level
const equipped = slots.includes(ability.id)
return {
glyph: locked ? 'L' : ability.glyph,
title: ability.name,
meta: locked ? `Level ${ability.unlockLevel}` : `${ability.cost} ${gameClass.resourceName}`,
detail: ability.description,
status: equipped ? 'Equipped' : locked ? 'Locked' : '',
}
}),
}
}, [activeTab, gameClass, message, profile.character.level, selectedSlot, slots])
useDualScreenWorkshopPublisher(classWorkshopState, dualScreenEnabled)
async function persistChanges() { async function persistChanges() {
saveScroll() saveScroll()
setSaving(true) setSaving(true)
@@ -80,7 +105,7 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
return ( return (
<section className="content-screen customize-screen"> <section className="content-screen customize-screen">
<div className="screen-heading"> <div className="screen-heading customize-heading">
<div> <div>
<p className="eyebrow">Character Workshop</p> <p className="eyebrow">Character Workshop</p>
<h1>Customize Character</h1> <h1>Customize Character</h1>
@@ -89,8 +114,10 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
</div> </div>
<div className="customize-tabs" role="tablist" aria-label="Customize character sections"> <div className="customize-tabs" role="tablist" aria-label="Customize character sections">
<button className="back-button customize-tab-back" onClick={onBack} type="button">Back</button>
{([ {([
{ key: 'equipment', label: 'Equipment' }, { key: 'equipment', label: 'Equipment' },
{ key: 'crafting', label: 'Crafting' },
{ key: 'talents', label: 'Talents' }, { key: 'talents', label: 'Talents' },
{ key: 'class', label: 'Class' }, { key: 'class', label: 'Class' },
] as const).map((tab) => ( ] as const).map((tab) => (
@@ -110,6 +137,18 @@ export function CustomizeScreen({ profile, onBack, onSaved }: Props) {
{activeTab === 'equipment' && ( {activeTab === 'equipment' && (
<EquipmentScreen <EquipmentScreen
embedded embedded
mode="equipment"
showModeTabs={false}
profile={profile}
onUpdated={onSaved}
/>
)}
{activeTab === 'crafting' && (
<EquipmentScreen
embedded
mode="crafting"
showModeTabs={false}
profile={profile} profile={profile}
onUpdated={onSaved} onUpdated={onSaved}
/> />
+219 -90
View File
@@ -9,6 +9,7 @@ import {
type EquipmentSlot, type EquipmentSlot,
type Item, type Item,
} from '../profile' } from '../profile'
import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen'
const SLOT_LABELS: Record<EquipmentSlot, string> = { const SLOT_LABELS: Record<EquipmentSlot, string> = {
weapon: 'Weapon', weapon: 'Weapon',
@@ -24,16 +25,29 @@ const SLOT_LABELS: Record<EquipmentSlot, string> = {
} }
const EQUIPMENT_LIST_PAGE_SIZE = 3 const EQUIPMENT_LIST_PAGE_SIZE = 3
const CRAFTING_LIST_PAGE_SIZE = 6 const CRAFTING_LIST_PAGE_SIZE = 3
const CRAFTING_FILTER_SLOTS = (Object.keys(SLOT_LABELS) as EquipmentSlot[])
.filter((slot) => slot !== 'component')
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type Props = { type Props = {
profile: CharacterProfile profile: CharacterProfile
onBack?: () => void onBack?: () => void
onUpdated: (profile: CharacterProfile) => void onUpdated: (profile: CharacterProfile) => void
embedded?: boolean embedded?: boolean
mode?: 'equipment' | 'crafting'
showModeTabs?: boolean
} }
export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }: Props) { export function EquipmentScreen({
profile,
onBack,
onUpdated,
embedded = false,
mode,
showModeTabs = true,
}: Props) {
const { enabled: dualScreenEnabled } = useDualScreen()
const totalItemCount = profile.inventory.reduce( const totalItemCount = profile.inventory.reduce(
(total, item) => total + item.quantity, (total, item) => total + item.quantity,
0, 0,
@@ -49,24 +63,23 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [crafting, setCrafting] = useState(false) const [crafting, setCrafting] = useState(false)
const [upgrading, setUpgrading] = useState(false) const [upgrading, setUpgrading] = useState(false)
const [showSetBonuses, setShowSetBonuses] = useState(false) const [showSetBonuses, setShowSetBonuses] = useState(false)
const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>('equipment') const [equipmentTab, setEquipmentTab] = useState<'equipment' | 'crafting'>(mode ?? 'equipment')
const [inventoryPage, setInventoryPage] = useState(0) const [inventoryPage, setInventoryPage] = useState(0)
const [recipePage, setRecipePage] = useState(0) const [recipePage, setRecipePage] = useState(0)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const scrollRef = useRef<number>(0) const scrollRef = useRef<number>(0)
const selectedItem = profile.inventory.find((item) => item.id === selectedItemId) const selectedItem = profile.inventory.find((item) => item.id === selectedItemId)
const firstRecipe = profile.craftingRecipes.find((recipe) => recipe.canCraft) const craftableRecipes = profile.craftingRecipes.filter((recipe) =>
?? profile.craftingRecipes[0] DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
)
const firstRecipe = craftableRecipes.find((recipe) => recipe.canCraft)
?? craftableRecipes[0]
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>( const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(
firstRecipe?.id ?? null, firstRecipe?.id ?? null,
) )
const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId) const selectedRecipe = profile.craftingRecipes.find((recipe) => recipe.id === selectedRecipeId)
const selectedRecipeRequiresUpgrade = selectedRecipe const selectedRecipeRequiresUpgrade = selectedRecipe
? profile.craftingRecipes.some((recipe) => ? !DIRECT_CRAFT_ITEM_LEVELS.has(selectedRecipe.item.itemLevel)
recipe.sourceEncounterId === selectedRecipe.sourceEncounterId
&& recipe.item.slot === selectedRecipe.item.slot
&& recipe.item.itemLevel < selectedRecipe.item.itemLevel,
)
: false : false
const selectedItemRecipe = selectedItem const selectedItemRecipe = selectedItem
? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id) ? profile.craftingRecipes.find((recipe) => recipe.item.id === selectedItem.id)
@@ -113,12 +126,14 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all') const [slotFilter, setSlotFilter] = useState<EquipmentSlot | 'all'>('all')
const [levelFilter, setLevelFilter] = useState<number | null>(null) const [levelFilter, setLevelFilter] = useState<number | null>(null)
const availableLevels = useMemo( const availableLevels = useMemo(
() => [...new Set(profile.craftingRecipes.map((r) => r.item.itemLevel))].sort((a, b) => b - a), () => [...new Set(profile.craftingRecipes
.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
.map((r) => r.item.itemLevel))].sort((a, b) => b - a),
[profile.craftingRecipes], [profile.craftingRecipes],
) )
const filteredRecipes = useMemo( const filteredRecipes = useMemo(
() => { () => {
let result = [...profile.craftingRecipes] let result = profile.craftingRecipes.filter((r) => DIRECT_CRAFT_ITEM_LEVELS.has(r.item.itemLevel))
if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter) if (slotFilter !== 'all') result = result.filter((r) => r.item.slot === slotFilter)
if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter) if (levelFilter !== null) result = result.filter((r) => r.item.itemLevel === levelFilter)
result.sort((a, b) => b.item.itemLevel - a.item.itemLevel) result.sort((a, b) => b.item.itemLevel - a.item.itemLevel)
@@ -131,7 +146,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
() => new Map( () => new Map(
(Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [ (Object.keys(SLOT_LABELS) as EquipmentSlot[]).map((slot) => [
slot, slot,
profile.craftingRecipes.filter((recipe) => recipe.item.slot === slot).length, profile.craftingRecipes.filter((recipe) =>
recipe.item.slot === slot
&& DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel),
).length,
]), ]),
), ),
[profile.craftingRecipes], [profile.craftingRecipes],
@@ -173,6 +191,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
} }
}, [equipmentTab]) }, [equipmentTab])
useEffect(() => {
if (mode) setEquipmentTab(mode)
}, [mode])
function saveScroll() { function saveScroll() {
scrollRef.current = window.scrollY scrollRef.current = window.scrollY
} }
@@ -247,73 +269,15 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
} }
} }
const content = ( function renderEquipmentActions() {
if (!selectedItem) {
return <p>Select an item to inspect it.</p>
}
if (selectedItem.slot === 'component') {
return <p className="component-note">Used in crafting.</p>
}
return (
<> <>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
<div className="equip-action">
<p className="component-note">Used in crafting.</p>
</div>
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
<div className="equip-action">
<ComparisonDelta selected={selectedItem} equipped={comparisonItem} /> <ComparisonDelta selected={selectedItem} equipped={comparisonItem} />
<button <button
className="primary-button" className="primary-button"
@@ -347,7 +311,166 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
: 'Break Down'} : 'Break Down'}
</button> </button>
)} )}
</>
)
}
const workshopState = useMemo<DualScreenWorkshopState>(() => {
if (equipmentTab === 'crafting') {
if (!selectedRecipe) {
return {
mode: 'crafting',
title: 'Craft Output',
subtitle: 'No recipe selected',
items: [],
}
}
return {
mode: 'crafting',
title: selectedRecipe.item.name,
subtitle: `${SLOT_LABELS[selectedRecipe.item.slot]} - Item Level ${selectedRecipe.item.itemLevel}`,
summary: selectedRecipe.item.description,
items: [
{
glyph: selectedRecipe.item.glyph,
title: 'Craft Output',
meta: `+${selectedRecipe.item.healingPower} Healing Power / +${selectedRecipe.item.maxResourceBonus} Max Resource`,
status: selectedRecipe.canCraft ? 'Ready' : 'Missing components',
},
...selectedRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Item Level ${component.item.itemLevel}`,
status: `${component.owned}/${component.quantity}`,
})),
],
}
}
if (!selectedItem) {
return {
mode: 'equipment',
title: 'Equipment Detail',
subtitle: 'No item selected',
items: [],
}
}
return {
mode: 'equipment',
title: selectedItem.slot === 'component' ? 'Crafting Component' : selectedItem.name,
subtitle: `${SLOT_LABELS[selectedItem.slot]} - Item Level ${selectedItem.itemLevel}`,
summary: selectedItem.description,
items: selectedItem.slot === 'component'
? [{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `Owned: ${selectedItem.quantity}`,
status: 'Component',
}]
: [
{
glyph: selectedItem.glyph,
title: selectedItem.name,
meta: `+${selectedItem.healingPower} Healing Power / +${selectedItem.maxResourceBonus} Max Resource`,
status: selectedItem.equipped ? 'Equipped' : 'Inventory',
},
...(comparisonItem && comparisonItem.id !== selectedItem.id
? [{
glyph: comparisonItem.glyph,
title: comparisonItem.name,
meta: `+${comparisonItem.healingPower} Healing Power / +${comparisonItem.maxResourceBonus} Max Resource`,
status: 'Currently Equipped',
}]
: [{
title: selectedItem.equipped ? 'Already Equipped' : 'Empty Slot',
status: 'Comparison',
}]),
...(upgradeRecipe
? [
{
glyph: upgradeRecipe.item.glyph,
title: `Upgrade to ${upgradeRecipe.item.name}`,
meta: `Item Level ${upgradeRecipe.item.itemLevel}`,
status: upgradeRecipe.canCraft ? 'Ready' : 'Missing materials',
},
...upgradeRecipe.components.map((component) => ({
glyph: component.item.glyph,
title: component.item.name,
meta: `Required for upgrade`,
status: `${component.owned}/${component.quantity}`,
})),
]
: []),
],
}
}, [comparisonItem, equipmentTab, selectedItem, selectedRecipe, upgradeRecipe])
useDualScreenWorkshopPublisher(workshopState, dualScreenEnabled)
const content = (
<>
{!embedded && (
<div className="screen-heading">
<div>
<p className="eyebrow">Character Loadout</p>
<h1>Equipment</h1>
</div> </div>
<button className="back-button" onClick={onBack} type="button">Back</button>
</div>
)}
<div className="gear-summary">
<div className="gear-character">
<span style={{ borderColor: profile.character.themeColor, color: profile.character.themeColor }}>
{profile.character.className[0]}
</span>
<div>
<p className="eyebrow">{profile.character.className}</p>
<h2>{profile.character.name}</h2>
</div>
</div>
<GearStat value={profile.gearStats.averageItemLevel.toFixed(1)} label="Average Item Level" />
<GearStat value={`+${profile.gearStats.healingPower}`} label="Healing Power" />
<GearStat value={`+${profile.gearStats.maxResourceBonus}`} label={`Max ${profile.character.resourceName}`} />
</div>
{showModeTabs && (
<nav className="equipment-tabs">
<button
className={`equipment-tab ${equipmentTab === 'equipment' ? 'active' : ''}`}
onClick={() => setEquipmentTab('equipment')}
type="button"
>
Equipment
</button>
<button
className={`equipment-tab ${equipmentTab === 'crafting' ? 'active' : ''}`}
onClick={() => setEquipmentTab('crafting')}
type="button"
>
Crafting
</button>
</nav>
)}
{equipmentTab === 'equipment' ? (
<>
<section className="item-comparison">
{selectedItem ? (
selectedItem.slot === 'component' ? (
<>
<ItemDetail title="Crafting Component" item={selectedItem} />
</>
) : (
<>
<ItemDetail title={selectedItem.equipped ? 'Selected Equipment' : 'Inventory Item'} item={selectedItem} />
<div className="comparison-arrow">vs</div>
{comparisonItem && comparisonItem.id !== selectedItem.id ? (
<ItemDetail title="Currently Equipped" item={comparisonItem} />
) : (
<div className="item-detail empty-comparison">
<p className="eyebrow">Comparison</p>
<h2>{selectedItem.equipped ? 'Already Equipped' : 'Empty Slot'}</h2>
</div>
)}
</> </>
) )
) : ( ) : (
@@ -355,6 +478,10 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
)} )}
</section> </section>
<section className="equipment-action-strip">
{renderEquipmentActions()}
</section>
<div className="equipment-layout"> <div className="equipment-layout">
<section className="equipped-panel"> <section className="equipped-panel">
<EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" /> <EquipmentHeading eyebrow="Currently Worn" title="Equipment Slots" />
@@ -467,9 +594,9 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
type="button" type="button"
> >
<strong>All</strong> <strong>All</strong>
<span>{profile.craftingRecipes.length}</span> <span>{profile.craftingRecipes.filter((recipe) => DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)).length}</span>
</button> </button>
{(Object.entries(SLOT_LABELS) as [EquipmentSlot, string][]).map(([slot, label]) => ( {CRAFTING_FILTER_SLOTS.map((slot) => (
<button <button
className={slotFilter === slot ? 'active' : ''} className={slotFilter === slot ? 'active' : ''}
disabled={(slotRecipeCounts.get(slot) ?? 0) === 0} disabled={(slotRecipeCounts.get(slot) ?? 0) === 0}
@@ -480,7 +607,7 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
}} }}
type="button" type="button"
> >
<strong>{label}</strong> <strong>{SLOT_LABELS[slot]}</strong>
<span>{slotRecipeCounts.get(slot) ?? 0}</span> <span>{slotRecipeCounts.get(slot) ?? 0}</span>
</button> </button>
))} ))}
@@ -557,6 +684,16 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
previousDisabled={recipePage <= 0} previousDisabled={recipePage <= 0}
/> />
)} )}
<div className="crafting-action-row">
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe?.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div>
</section> </section>
<section className="crafting-detail-panel"> <section className="crafting-detail-panel">
@@ -579,14 +716,6 @@ export function EquipmentScreen({ profile, onBack, onUpdated, embedded = false }
</div> </div>
))} ))}
</div> </div>
<button
className="primary-button"
disabled={selectedRecipeRequiresUpgrade || !selectedRecipe.canCraft || crafting}
onClick={craftSelected}
type="button"
>
{crafting ? 'Crafting...' : selectedRecipeRequiresUpgrade ? 'Upgrade Existing Item' : 'Craft Item'}
</button>
</div> </div>
) : ( ) : (
<p className="inventory-empty">Select a recipe.</p> <p className="inventory-empty">Select a recipe.</p>
+241 -56
View File
@@ -1,5 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { INITIAL_PARTY, RAID_PARTY, type CombatLogEntry, type PartyMember, type Spell } from '../game' import {
INITIAL_PARTY,
RAID_PARTY,
DEFAULT_GROUP_HEAL_TARGETS,
groupHealTargets,
partyDamageOutput,
tankPressureTargets,
type CombatLogEntry,
type PartyMember,
type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile' import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile' import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository' import type { GameMode } from '../gameRepository'
@@ -34,7 +44,7 @@ type PvpEncounter = DungeonEncounter & {
sourceEncounterId?: number sourceEncounterId?: number
} }
type SlotKey = '1' | '2' | '3' | '4' | '5' type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type AbilityLabelMode = 'ability' | 'slot' type AbilityLabelMode = 'ability' | 'slot'
type SelfBuffId = type SelfBuffId =
@@ -78,6 +88,17 @@ type FloatingCombatText = {
value: number value: number
} }
type PvpRunSummary = {
bossesKilled: number
experienceGained: number
previousLevel: number | null
newLevel: number | null
levelsGained: number
talentPointsGained: number
unlockedAbilities: DungeonReward['unlockedAbilities']
loot: Array<NonNullable<DungeonReward['bonusItem']>>
}
const BOSS_MECHANICS: BossMechanic[] = [ const BOSS_MECHANICS: BossMechanic[] = [
'party-pulse', 'party-pulse',
'searing-mark', 'searing-mark',
@@ -119,6 +140,19 @@ function formatEffectTime(ticks: number) {
return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s` return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
} }
function createEmptyPvpRunSummary(): PvpRunSummary {
return {
bossesKilled: 0,
experienceGained: 0,
previousLevel: null,
newLevel: null,
levelsGained: 0,
talentPointsGained: 0,
unlockedAbilities: [],
loot: [],
}
}
function buffStacks<T extends string>(items: T[], id: T) { function buffStacks<T extends string>(items: T[], id: T) {
return items.filter((item) => item === id).length return items.filter((item) => item === id).length
} }
@@ -130,7 +164,7 @@ function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode)
} }
function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> { 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) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -159,7 +193,7 @@ function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Arr
} }
function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> { 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) const label = slotLabel(slot, spells, labelMode)
return [ return [
{ {
@@ -217,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[]) {
@@ -300,7 +334,7 @@ function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
if (buff.id === 'fifth-cast-free') return 8 if (buff.id === 'fifth-cast-free') return 8
if (buff.id === 'group-heal-boost') return 8 if (buff.id === 'group-heal-boost') return 8
if (buff.id === 'shield-boost') return 6 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) const spell = spells.find((candidate) => candidate.key === slot)
if (!spell) return 5 if (!spell) return 5
if (buff.id.endsWith('extra-target')) { if (buff.id.endsWith('extra-target')) {
@@ -374,7 +408,7 @@ export function PvPRoguelikeScreen({
const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)! const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
const starterSpells = useMemo(() => gameClass.spells const starterSpells = useMemo(() => gameClass.spells
.filter((spell) => spell.unlockLevel === 1) .filter((spell) => spell.unlockLevel === 1)
.slice(0, 5) .slice(0, 6)
.map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells]) .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
const [abilityLabelMode] = useState<AbilityLabelMode>('ability') const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
const selfBuffChoicesCatalog = useMemo( const selfBuffChoicesCatalog = useMemo(
@@ -411,11 +445,14 @@ export function PvPRoguelikeScreen({
const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource)) const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
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 [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('')
const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }]) const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
const [reward, setReward] = useState<DungeonReward | null>(null) const [reward, setReward] = useState<DungeonReward | null>(null)
const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
const [rewardError, setRewardError] = useState('') const [rewardError, setRewardError] = useState('')
const [showEndLog, setShowEndLog] = useState(false) const [showEndLog, setShowEndLog] = useState(false)
const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([]) const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
@@ -433,6 +470,8 @@ export function PvPRoguelikeScreen({
const bossRewardClaimedRef = useRef(new Set<number>()) const bossRewardClaimedRef = useRef(new Set<number>())
const cpuDefeatedRef = useRef(false) const cpuDefeatedRef = useRef(false)
const playerClearedEncounterRef = useRef(-1) const playerClearedEncounterRef = useRef(-1)
const queuedMatchRef = useRef(false)
const encounterPoolRef = useRef(encounterPool)
const playerRef = useRef(playerSide) const playerRef = useRef(playerSide)
const cpuRef = useRef(cpuSide) const cpuRef = useRef(cpuSide)
const encounter = encounters[encounterIndex] const encounter = encounters[encounterIndex]
@@ -445,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)
@@ -459,6 +506,12 @@ export function PvPRoguelikeScreen({
const { const {
enabled: dualScreenEnabled, enabled: dualScreenEnabled,
} = useDualScreen() } = useDualScreen()
const setSelectedTargetId = useCallback((id: string) => {
selectedIdRef.current = id
setSelectedId(id)
}, [])
const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => { const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60)) setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
}, []) }, [])
@@ -473,11 +526,16 @@ export function PvPRoguelikeScreen({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (queuedMatchRef.current) return
const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType) const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
setCheckpointStage(loadedCheckpoint) setCheckpointStage(loadedCheckpoint)
setStartStage(loadedCheckpoint) setStartStage(loadedCheckpoint)
}, [contentType, profile.character.id]) }, [contentType, profile.character.id])
useEffect(() => {
encounterPoolRef.current = encounterPool
}, [encounterPool])
const awardBossReward = useCallback((encounterIndexValue: number) => { const awardBossReward = useCallback((encounterIndexValue: number) => {
if (bossRewardClaimedRef.current.has(encounterIndexValue)) return if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
bossRewardClaimedRef.current.add(encounterIndexValue) bossRewardClaimedRef.current.add(encounterIndexValue)
@@ -497,6 +555,20 @@ export function PvPRoguelikeScreen({
) )
.then((result) => { .then((result) => {
setReward(result) setReward(result)
setRunSummary((current) => {
const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
return {
bossesKilled: current.bossesKilled + 1,
experienceGained: current.experienceGained + result.experienceGained,
previousLevel: current.previousLevel ?? result.previousLevel,
newLevel: result.newLevel,
levelsGained: current.levelsGained + result.levelsGained,
talentPointsGained: current.talentPointsGained + result.talentPointsGained,
unlockedAbilities: Array.from(unlockedById.values()),
loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
}
})
onProfileUpdated(result.profile) onProfileUpdated(result.profile)
if (result.bonusItem) { if (result.bonusItem) {
addLog( addLog(
@@ -532,8 +604,9 @@ export function PvPRoguelikeScreen({
: null) : null)
}, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog]) }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])
useEffect(() => { const startMatch = useCallback((nextStartStage?: number) => {
const firstSegment = buildEncounterSegment(encounterPool, startStage, contentType) const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
const firstEncounter = firstSegment[0] const firstEncounter = firstSegment[0]
const basePlayer = starterSide(partyTemplate, maxResource) const basePlayer = starterSide(partyTemplate, maxResource)
const baseCpu = starterSide(cpuPartyTemplate, maxResource) const baseCpu = starterSide(cpuPartyTemplate, maxResource)
@@ -543,15 +616,18 @@ export function PvPRoguelikeScreen({
cpuRef.current = baseCpu cpuRef.current = baseCpu
nextLogId.current = 2 nextLogId.current = 2
playerClearedEncounterRef.current = -1 playerClearedEncounterRef.current = -1
queuedMatchRef.current = true
bossRewardClaimedRef.current = new Set() bossRewardClaimedRef.current = new Set()
setEncounters(firstSegment) setEncounters(firstSegment)
setEncounterIndex(0) setEncounterIndex(0)
setStage(startStage) setCheckpointStage(matchStartStage)
setStartStage(matchStartStage)
setStage(matchStartStage)
setElapsedTicks(0) setElapsedTicks(0)
setStatus('queueing') setStatus('queueing')
setPlayerSide(basePlayer) setPlayerSide(basePlayer)
setCpuSide(baseCpu) setCpuSide(baseCpu)
setSelectedId(partyTemplate[0].id) setSelectedTargetId(partyTemplate[0].id)
setPlayerBuffChoices([]) setPlayerBuffChoices([])
setPlayerDebuffChoices([]) setPlayerDebuffChoices([])
setSelectedBuff(null) setSelectedBuff(null)
@@ -560,6 +636,7 @@ export function PvPRoguelikeScreen({
setPaused(false) setPaused(false)
setTargetGroup(0) setTargetGroup(0)
setReward(null) setReward(null)
setRunSummary(createEmptyPvpRunSummary())
setRewardError('') setRewardError('')
setShowEndLog(false) setShowEndLog(false)
setFloatingTexts([]) setFloatingTexts([])
@@ -569,26 +646,28 @@ export function PvPRoguelikeScreen({
cpuDefeatedRef.current = false cpuDefeatedRef.current = false
if (gameMode === 'offline') { if (gameMode === 'offline') {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`) setQueueMessage(`Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${startStage}.`, tone: 'system' }]) setLog([{ id: 1, text: `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setStatus('playing') setStatus('playing')
addLog(`Stage ${startStage} begins against CPU ${randomCpu}.`, 'system') addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
}, 500) }, 500)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
} }
setQueueMessage(`Searching queue. Stage ${startStage} start ready.`) setQueueMessage(`Searching queue. Stage ${matchStartStage} start ready.`)
setLog([{ id: 1, text: `Searching queue. Stage ${startStage} start ready.`, tone: 'system' }]) setLog([{ id: 1, text: `Searching queue. Stage ${matchStartStage} start ready.`, tone: 'system' }])
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const randomCpu = randomCpuDifficulty() const randomCpu = randomCpuDifficulty()
setCpuDifficulty(randomCpu) setCpuDifficulty(randomCpu)
setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`) setQueueMessage(`No queued player found. CPU ${randomCpu} steps in.`)
setStatus('playing') setStatus('playing')
addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${startStage}.`, 'system') addLog(`No queued player found. CPU ${randomCpu} steps in at stage ${matchStartStage}.`, 'system')
}, 1400) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [addLog, contentType, cpuPartyTemplate, encounterPool, gameMode, maxResource, partyTemplate, startStage]) }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])
useEffect(() => startMatch(), [startMatch])
const applySpell = useCallback(( const applySpell = useCallback((
current: SideState, current: SideState,
@@ -607,10 +686,21 @@ 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] : [])
const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId) const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
const groupTargets = new Set(
spell.kind === 'group'
? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
: [],
)
for (let index = 0; index < extraTargets; index += 1) { for (let index = 0; index < extraTargets; index += 1) {
if (spell.kind === 'group') break if (spell.kind === 'group') break
if (spell.kind === 'hot') { if (spell.kind === 'hot') {
@@ -626,21 +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
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,
@@ -652,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,
} }
}) })
@@ -670,46 +790,51 @@ 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
const targetId = selectedIdRef.current
const succeeded = applySpell(playerRef.current, (value) => { const succeeded = applySpell(playerRef.current, (value) => {
const next = typeof value === 'function' ? value(playerRef.current) : value const next = typeof value === 'function' ? value(playerRef.current) : value
playerRef.current = next playerRef.current = next
setPlayerSide(next) setPlayerSide(next)
}, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, selectedId) }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === selectedId)?.name ?? 'target'}.`, 'heal') if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
}, [addLog, applySpell, playerAlive, playerDone, selectedId, status]) }, [addLog, applySpell, playerAlive, playerDone, status])
const selectRelativeTarget = useCallback((direction: -1 | 1) => { const selectRelativeTarget = useCallback((direction: -1 | 1) => {
const living = playerRef.current.party.filter((member) => member.health > 0) const living = playerRef.current.party.filter((member) => member.health > 0)
if (living.length === 0) return if (living.length === 0) return
const currentIndex = living.findIndex((member) => member.id === selectedId) const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
const nextIndex = currentIndex < 0 const nextIndex = currentIndex < 0
? 0 ? 0
: (currentIndex + direction + living.length) % living.length : (currentIndex + direction + living.length) % living.length
setSelectedId(living[nextIndex].id) setSelectedTargetId(living[nextIndex].id)
}, [selectedId]) }, [setSelectedTargetId])
const selectDirectionalTarget = useCallback((action: InputAction) => { const selectDirectionalTarget = useCallback((action: InputAction) => {
const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
if (currentIndex < 0) { if (currentIndex < 0) {
const firstLiving = playerRef.current.party.find((member) => member.health > 0) const firstLiving = playerRef.current.party.find((member) => member.health > 0)
if (firstLiving) setSelectedId(firstLiving.id) if (firstLiving) setSelectedTargetId(firstLiving.id)
return return
} }
const currentRow = Math.floor(currentIndex / partyColumns) const currentRow = Math.floor(currentIndex / partyColumns)
@@ -736,14 +861,14 @@ export function PvPRoguelikeScreen({
const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn) const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
return aPrimary - bPrimary || aSecondary - bSecondary return aPrimary - bPrimary || aSecondary - bSecondary
}) })
if (candidates[0]) setSelectedId(candidates[0].member.id) if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
}, [partyColumns, selectedId]) }, [partyColumns, setSelectedTargetId])
const selectDirectTarget = useCallback((slot: number) => { const selectDirectTarget = useCallback((slot: number) => {
const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0) const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
const member = playerRef.current.party[index] const member = playerRef.current.party[index]
if (member?.health > 0) setSelectedId(member.id) if (member?.health > 0) setSelectedTargetId(member.id)
}, [contentType, targetGroup]) }, [contentType, setSelectedTargetId, targetGroup])
const cpuTakeTurn = useCallback(() => { const cpuTakeTurn = useCallback(() => {
if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
@@ -790,10 +915,15 @@ 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 tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
const nextParty = side.party.map((member) => { const nextParty = side.party.map((member) => {
if (member.health <= 0) return member if (member.health <= 0) return member
let damage = member.id === primaryTarget.id ? encounterValue.damage : 0 let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
if (member.role === 'Tank') damage += encounterValue.tankDamage if (tankPressureIds.has(member.id)) {
damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
}
if (bossPulse) damage += 10 if (bossPulse) damage += 10
if (member.debuff) damage += 6 if (member.debuff) damage += 6
const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
@@ -801,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
@@ -842,9 +976,9 @@ export function PvPRoguelikeScreen({
cooldowns: Object.fromEntries( cooldowns: Object.fromEntries(
Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]), Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
), ),
enemyHealth: Math.max(0, side.enemyHealth - encounterValue.partyDamage), enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
} }
}, [addFloatingHeal, elapsedTicks, maxResource]) }, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])
const beginUpgradePhase = useCallback(() => { const beginUpgradePhase = useCallback(() => {
setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3)) setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
@@ -895,12 +1029,18 @@ export function PvPRoguelikeScreen({
addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot') addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
} }
if (nextPlayer.enemyHealth <= 0) { if (nextPlayer.enemyHealth <= 0) {
if (encounter.isBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot') 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
@@ -955,10 +1095,17 @@ export function PvPRoguelikeScreen({
} }
const clearedBoss = encounter.isBoss const clearedBoss = encounter.isBoss
if (clearedBoss && cpuDefeatedRef.current) {
finishRoguelikeRun()
setStatus('won')
addLog('CPU defeated. Match complete.', 'loot')
return
}
const nextStage = clearedBoss ? stage + 1 : stage const nextStage = clearedBoss ? stage + 1 : stage
const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : [] const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1] const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
if (!nextEncounter) { if (!nextEncounter) {
finishRoguelikeRun()
setStatus('won') setStatus('won')
addLog('No further encounters remain.', 'loot') addLog('No further encounters remain.', 'loot')
return return
@@ -1007,9 +1154,13 @@ export function PvPRoguelikeScreen({
setElapsedTicks(0) setElapsedTicks(0)
setStatus('playing') setStatus('playing')
addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system') addLog(`You chose ${selectedBuff.name} and ${selectedDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
}, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells]) }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])
useGameAction((action) => { 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
@@ -1036,9 +1187,9 @@ export function PvPRoguelikeScreen({
setTargetGroup((current) => { setTargetGroup((current) => {
const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6)) const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
const next = ((current + 1) % groupCount) as 0 | 1 | 2 const next = ((current + 1) % groupCount) as 0 | 1 | 2
const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedId) const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6] const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
if (nextMember?.health > 0) setSelectedId(nextMember.id) if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
return next return next
}) })
return return
@@ -1081,6 +1232,7 @@ export function PvPRoguelikeScreen({
directPartyTargeting, directPartyTargeting,
paused, paused,
targetGroup, targetGroup,
speedMultiplier,
}), [ }), [
bindings, bindings,
controllerIconStyle, controllerIconStyle,
@@ -1105,6 +1257,7 @@ export function PvPRoguelikeScreen({
playerSide.party, playerSide.party,
playerSide.resource, playerSide.resource,
selectedId, selectedId,
speedMultiplier,
stage, stage,
starterSpells, starterSpells,
status, status,
@@ -1128,7 +1281,7 @@ export function PvPRoguelikeScreen({
{dualScreenEnabled && status !== 'queueing' && ( {dualScreenEnabled && status !== 'queueing' && (
<DualScreenTopCombat <DualScreenTopCombat
state={dualScreenState} state={dualScreenState}
onSelectTarget={setSelectedId} onSelectTarget={setSelectedTargetId}
/> />
)} )}
@@ -1143,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>
@@ -1152,7 +1306,7 @@ export function PvPRoguelikeScreen({
<button <button
className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`} className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
key={`player-${member.id}`} key={`player-${member.id}`}
onClick={() => setSelectedId(member.id)} onClick={() => setSelectedTargetId(member.id)}
type="button" type="button"
> >
<div className="member-header"> <div className="member-header">
@@ -1351,9 +1505,39 @@ export function PvPRoguelikeScreen({
<h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2> <h2>{status === 'won' ? `CPU ${cpuDifficulty} Falls` : `CPU ${cpuDifficulty} Wins`}</h2>
<p>{finalEncountersCleared} encounters cleared.</p> <p>{finalEncountersCleared} encounters cleared.</p>
<div className="reward-summary"> <div className="reward-summary">
{!reward && !rewardError && <p>Boss kills grant XP immediately.</p>} <p>{runSummary.bossesKilled} bosses killed.</p>
<p>+{runSummary.experienceGained} XP</p>
{runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
{rewardError && <p className="reward-error">{rewardError}</p>} {rewardError && <p className="reward-error">{rewardError}</p>}
{reward && ( {runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
<p className="level-gain">
Level {runSummary.previousLevel} to {runSummary.newLevel}
<small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
</p>
)}
{runSummary.unlockedAbilities.map((ability) => (
<p className="ability-unlock" key={ability.id}>
<span>{ability.glyph}</span>
Ability Unlocked: {ability.name}
</p>
))}
<div className="run-loot-rolls">
{runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
<div className="dropped" key={`${item.id}-${index}`}>
<strong>Boss {index + 1}</strong>
<span>
{item.glyph} {item.name} x{item.quantity}
{item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
</span>
</div>
)) : (
<div>
<strong>Loot</strong>
<span>No boss loot awarded</span>
</div>
)}
</div>
{reward && runSummary.bossesKilled === 0 && (
<> <>
<p>+{reward.experienceGained} XP</p> <p>+{reward.experienceGained} XP</p>
{reward.levelsGained > 0 && ( {reward.levelsGained > 0 && (
@@ -1392,6 +1576,7 @@ export function PvPRoguelikeScreen({
)} )}
</> </>
)} )}
<button onClick={() => startMatch()} type="button">Queue Next Match</button>
<button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button> <button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
</div> </div>
</div> </div>
+185 -97
View File
@@ -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,286 @@ type Props = {
embedded?: boolean 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) { 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 [effectPage, setEffectPage] = useState(0)
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)
?? 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(() => { 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])
useEffect(() => {
setEffectPage((page) => Math.min(page, effectPageCount - 1))
}, [effectPageCount])
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 const source = effectSource(talent.effectType)
if (lowerTierPoints(talent) < requiredTierPoints) { const sourceConflict = selectedEffects.find((effect) => effectSource(effect.effectType) === source)
return `Requires ${requiredTierPoints} earlier-tier points` 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 '' 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="selected-effect-strip">
.sort((a, b) => a.branch - b.branch) <div>
.map((talent) => { <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 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>{EFFECT_SOURCE_LABELS[effectSource(talent.effectType)] ?? 'Spell'}</small>
</div> </div>
<i>{isBusy ? 'Saving' : active ? 'Active' : reason || 'Available'}</i>
</button>
)
})}
</div> </div>
<p>{talent.description}</p> {effectPageCount > 1 && (
<div className="rank-pips"> <div className="effect-pager">
{Array.from({ length: talent.maxRank }, (_, index) => (
<i className={index < talent.rank ? 'filled' : ''} key={index} />
))}
</div>
<button <button
disabled={Boolean(reason) || isBusy} disabled={effectPage === 0}
onClick={() => purchaseRank(talent)} onClick={() => setEffectPage((page) => Math.max(0, page - 1))}
type="button" 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> </button>
</article>
)
})}
</div> </div>
)}
</section> </section>
)
})}
</div> </div>
)}
<footer className="talent-footer"> <footer className="talent-footer">
<span>{message || 'Talent changes are saved immediately.'}</span> <span>{message || 'Spell effect changes are saved immediately.'}</span>
<button <button
className="text-button" className="text-button"
disabled={classPointsSpent === 0 || resetting} disabled={selectedEffects.length === 0 || resetting}
onClick={refundTree} onClick={clearEffects}
type="button" type="button"
> >
{resetting ? 'Refunding...' : 'Reset Tree'} {resetting ? 'Clearing...' : 'Clear Effects'}
</button> </button>
</footer> </footer>
</> </>
+111 -2
View File
@@ -54,14 +54,31 @@ 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 = {
mode: 'class' | 'equipment' | 'crafting' | 'talents'
title: string
subtitle: string
summary?: string
items: Array<{
glyph?: string
title: string
meta?: string
detail?: string
status?: string
}>
} }
type DualScreenMessage = type DualScreenMessage =
| { type: 'combat-state'; state: DualScreenCombatState } | { type: 'combat-state'; state: DualScreenCombatState }
| { type: 'workshop-state'; state: DualScreenWorkshopState }
| { type: 'companion-ready' } | { type: 'companion-ready' }
| { type: 'companion-heartbeat' } | { type: 'companion-heartbeat' }
| { type: 'control-action'; action: InputAction } | { type: 'control-action'; action: InputAction }
| { type: 'combat-ended' } | { type: 'combat-ended' }
| { type: 'workshop-ended' }
type DualScreenContextValue = { type DualScreenContextValue = {
enabled: boolean enabled: boolean
@@ -102,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 }) { export function DualScreenProvider({ children }: { children: ReactNode }) {
const [enabled, setEnabledState] = useState( const [enabled, setEnabledState] = useState(
() => localStorage.getItem(STORAGE_KEY) === 'true', () => localStorage.getItem(STORAGE_KEY) === 'true',
@@ -280,16 +304,64 @@ export function useDualScreenPublisher(
}, [enabled, state]) }, [enabled, state])
} }
export function useDualScreenWorkshopPublisher(
state: DualScreenWorkshopState | null,
enabled: boolean,
) {
const stateRef = useRef(state)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
if (!channel) return
const publish = () => {
if (stateRef.current) {
channel.postMessage({
type: 'workshop-state',
state: stateRef.current,
} satisfies DualScreenMessage)
}
}
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'companion-ready') publish()
}
publish()
return () => {
channel.postMessage({ type: 'workshop-ended' } satisfies DualScreenMessage)
channel.close()
}
}, [enabled, state])
useEffect(() => {
if (!enabled || !state) return
const channel = createChannel()
channel?.postMessage({ type: 'workshop-state', state } satisfies DualScreenMessage)
channel?.close()
}, [enabled, state])
}
export function DualScreenBottomDisplay() { export function DualScreenBottomDisplay() {
const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot) const [state, setState] = useState<DualScreenCombatState | null>(loadRecentSnapshot)
const [workshopState, setWorkshopState] = useState<DualScreenWorkshopState | null>(null)
useEffect(() => { useEffect(() => {
const channel = createChannel() const channel = createChannel()
if (!channel) return if (!channel) return
const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage) const announce = () => channel.postMessage({ type: 'companion-ready' } satisfies DualScreenMessage)
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === 'combat-state') setState(event.data.state) if (event.data.type === 'combat-state') {
setState(event.data.state)
setWorkshopState(null)
}
if (event.data.type === 'workshop-state') {
setWorkshopState(event.data.state)
setState(null)
}
if (event.data.type === 'combat-ended') setState(null) if (event.data.type === 'combat-ended') setState(null)
if (event.data.type === 'workshop-ended') setWorkshopState(null)
} }
announce() announce()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -307,6 +379,40 @@ export function DualScreenBottomDisplay() {
channel?.close() channel?.close()
} }
if (!state && workshopState) {
return (
<main className="dual-bottom-display workshop-bottom-display">
<header className="dual-controls-header">
<div>
<p className="eyebrow">{workshopState.mode}</p>
<h1>{workshopState.title}</h1>
</div>
<div className="dual-controls-progress">
<span>{workshopState.subtitle}</span>
</div>
</header>
{workshopState.summary && (
<section className="workshop-bottom-summary">
{workshopState.summary}
</section>
)}
<section className="workshop-bottom-grid">
{workshopState.items.map((item, index) => (
<article key={`${item.title}-${index}`}>
{item.glyph && <span>{item.glyph}</span>}
<div>
<strong>{item.title}</strong>
{item.meta && <small>{item.meta}</small>}
{item.detail && <p>{item.detail}</p>}
</div>
{item.status && <i>{item.status}</i>}
</article>
))}
</section>
</main>
)
}
if (!state) { if (!state) {
return ( return (
<main className="dual-bottom-display dual-bottom-waiting"> <main className="dual-bottom-display dual-bottom-waiting">
@@ -340,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>
@@ -501,7 +608,9 @@ export function DualScreenTopCombat({
</div> </div>
)} )}
<div className="member-effects"> <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>} {member.debuff && <span className="debuff">{member.debuff}</span>}
</div> </div>
</button> </button>
+45 -2
View File
@@ -8,6 +8,20 @@ export type PartyMember = {
maxHealth: number maxHealth: number
shield: number shield: number
hotTicks: number hotTicks: number
hotEffects?: Array<{
id: string
spellId: string
label: string
ticks: number
power: number
}>
bounceHeals?: Array<{
id: string
label: string
charges: number
power: number
}>
damageReductionTicks?: number
debuff?: string debuff?: string
debuffTicks?: number debuffTicks?: number
poisonStacks?: number poisonStacks?: number
@@ -24,7 +38,8 @@ export type Spell = {
cooldown: number cooldown: number
power: number power: number
glyph: string glyph: string
kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' kind: 'direct' | 'hot' | 'group' | 'shield' | 'cleanse' | 'damage_reduction' | 'bounce_heal'
effectType?: string
} }
export type Encounter = { export type Encounter = {
@@ -44,6 +59,9 @@ export type CombatLogEntry = {
tone: 'system' | 'heal' | 'danger' | 'loot' tone: 'system' | 'heal' | 'danger' | 'loot'
} }
export const TANKLESS_DAMAGE_MULTIPLIER = 1.35
export const DEFAULT_GROUP_HEAL_TARGETS = 4
export const INITIAL_PARTY: PartyMember[] = [ export const INITIAL_PARTY: PartyMember[] = [
{ id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 }, { id: 'brann', name: 'Brann', role: 'Tank', health: 150, maxHealth: 150, shield: 0, hotTicks: 0 },
{ id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 }, { id: 'mira', name: 'Mira', role: 'Healer', health: 100, maxHealth: 100, shield: 0, hotTicks: 0 },
@@ -101,7 +119,7 @@ export const SPELLS: Spell[] = [
id: 'radiance', id: 'radiance',
key: '3', key: '3',
name: 'Radiance', name: 'Radiance',
description: 'Restores health to every living party member.', description: 'Restores health to up to 4 injured party members.',
cost: 12, cost: 12,
cooldown: 8, cooldown: 8,
power: 18, power: 18,
@@ -164,3 +182,28 @@ export const ENCOUNTERS: Encounter[] = [
isBoss: true, isBoss: true,
}, },
] ]
export function partyDamageOutput(party: PartyMember[], baseDamage: number) {
const livingCount = party.filter((member) => member.health > 0).length
return Math.round(baseDamage * (livingCount / Math.max(1, party.length)))
}
export function tankPressureTargets(party: PartyMember[]) {
const living = party.filter((member) => member.health > 0)
const tanks = living.filter((member) => member.role === 'Tank')
if (tanks.length > 0) return { targets: tanks, multiplier: 1 }
const damageDealer = living
.filter((member) => member.role === 'Damage')
.sort((left, right) => right.health - left.health)[0]
return {
targets: damageDealer ? [damageDealer] : [],
multiplier: TANKLESS_DAMAGE_MULTIPLIER,
}
}
export function groupHealTargets(party: PartyMember[], targetCount = DEFAULT_GROUP_HEAL_TARGETS) {
return party
.filter((member) => member.health > 0)
.sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))
.slice(0, targetCount)
}
+125 -26
View File
@@ -26,6 +26,7 @@ export interface GameRepository {
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> ): Promise<DungeonReward>
completeRoguelike( completeRoguelike(
dungeonId: number, dungeonId: number,
@@ -102,6 +103,7 @@ const offlineSaveKey = 'chronicle.offlineSave.v1'
const onlineCacheKey = 'chronicle.onlineCache.v1' const onlineCacheKey = 'chronicle.onlineCache.v1'
const authTokenKey = 'chronicle.authToken.v1' const authTokenKey = 'chronicle.authToken.v1'
const offlineAccount = { id: -1, username: 'Offline' } const offlineAccount = { id: -1, username: 'Offline' }
const ABILITY_SLOT_COUNT = 6
function clone<T>(value: T): T { function clone<T>(value: T): T {
return structuredClone(value) return structuredClone(value)
@@ -146,7 +148,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
level: cid === p.character.classId ? p.character.level : 1, level: cid === p.character.classId ? p.character.level : 1,
experience: cid === p.character.classId ? p.character.experience : 0, experience: cid === p.character.classId ? p.character.experience : 0,
talentPoints: cid === p.character.classId ? p.character.talentPoints : 1, 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, talentRanks,
inventory: cid === p.character.classId ? clone(p.inventory) : [], inventory: cid === p.character.classId ? clone(p.inventory) : [],
} }
@@ -163,11 +165,32 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record<string
} }
function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave { function upgradeV2Save(v2: Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }): OfflineSave {
return { return normalizeSaveAbilitySlots({
...v2, ...v2,
version: 3, version: 3,
completedRaidPhases: 0, 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 { function normalizeOfflineSave(raw: unknown): OfflineSave | null {
@@ -177,12 +200,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null {
profile?: CharacterProfile profile?: CharacterProfile
lootRolls?: Record<string, LootRoll> 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) { if (candidate.version === 2) {
return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 }) return upgradeV2Save(candidate as Omit<OfflineSave, 'version' | 'completedRaidPhases'> & { version: 2 })
} }
if (candidate.version === 1 && candidate.profile) { 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 return null
} }
@@ -359,11 +382,33 @@ function experienceForLevel(level: number) {
return (level - 1) * (level - 1) * 100 return (level - 1) * (level - 1) * 100
} }
function catchUpExperienceReward(
baseReward: number,
currentExperience: number,
currentLevel: number,
targetLevel: number,
) {
if (targetLevel <= currentLevel) return baseReward
const targetExperience = experienceForLevel(targetLevel)
const gap = Math.max(0, targetExperience - currentExperience)
if (gap <= 0) return baseReward
const doubledBase = Math.min(baseReward, Math.ceil(gap / 2))
return doubledBase * 2 + (baseReward - doubledBase)
}
function highestOtherClassLevel(save: OfflineSave) {
const activeClass = save.activeClassId
return Object.entries(save.characters)
.filter(([classId]) => Number(classId) !== activeClass)
.reduce((highest, [, character]) => Math.max(highest, character.level), 0)
}
function scaledPvpBossExperience( function scaledPvpBossExperience(
startingExperience: number, startingExperience: number,
startingLevel: number, startingLevel: number,
bossesCleared: number, bossesCleared: number,
maxLevel: number, maxLevel: number,
targetLevel = startingLevel,
) { ) {
let experience = startingExperience let experience = startingExperience
let level = startingLevel let level = startingLevel
@@ -374,7 +419,8 @@ function scaledPvpBossExperience(
? maxExperience ? maxExperience
: experienceForLevel(level + 1) : experienceForLevel(level + 1)
const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor) const levelBand = Math.max(1, nextLevelExperience - currentLevelFloor)
experience = Math.min(maxExperience, experience + Math.round(levelBand * 0.25)) const rewardRate = targetLevel > level ? 0.5 : 0.25
experience = Math.min(maxExperience, experience + Math.round(levelBand * rewardRate))
while (level < maxLevel && experienceForLevel(level + 1) <= experience) { while (level < maxLevel && experienceForLevel(level + 1) <= experience) {
level += 1 level += 1
} }
@@ -382,6 +428,17 @@ function scaledPvpBossExperience(
return { experience, level } 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 } 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.' },
@@ -389,6 +446,7 @@ const COMPONENT_ITEMS: Record<number, ComponentTemplate> = {
20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' }, 20: { id: 604, slug: 'superior-component', name: 'Superior Component', itemLevel: 20, glyph: '◎', description: 'A superior crafting component.' },
25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' }, 25: { id: 605, slug: 'primal-component', name: 'Primal Component', itemLevel: 25, glyph: '✦', description: 'A primal crafting component.' },
} }
const DIRECT_CRAFT_ITEM_LEVELS = new Set([1, 10, 20, 25])
type WindowWithApiBase = Window & { type WindowWithApiBase = Window & {
CAPACITOR_API_BASE_URL?: string CAPACITOR_API_BASE_URL?: string
@@ -423,7 +481,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS
level: profile.character.level, level: profile.character.level,
experience: profile.character.experience, experience: profile.character.experience,
talentPoints: profile.character.talentPoints, talentPoints: profile.character.talentPoints,
abilitySlots: [...profile.abilitySlots], abilitySlots: normalizeAbilitySlots(profile.abilitySlots),
talentRanks, talentRanks,
inventory: clone(profile.inventory), inventory: clone(profile.inventory),
} }
@@ -716,7 +774,7 @@ const serverRepository: GameRepository = {
), ),
saveProfile: (classId, abilitySlots) => saveProfile: (classId, abilitySlots) =>
cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -725,6 +783,7 @@ const serverRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
@@ -761,9 +820,9 @@ function emptyCharacterData(classId: number): CharacterData {
const inventory: Item[] = [] const inventory: Item[] = []
const startingAbilitySlots: Array<number | null> = gc.spells const startingAbilitySlots: Array<number | null> = gc.spells
.filter((s) => s.unlockLevel === 1) .filter((s) => s.unlockLevel === 1)
.slice(0, 5) .slice(0, ABILITY_SLOT_COUNT)
.map((s) => s.id) .map((s) => s.id)
while (startingAbilitySlots.length < 6) startingAbilitySlots.push(null) while (startingAbilitySlots.length < ABILITY_SLOT_COUNT) startingAbilitySlots.push(null)
return { return {
level: 1, level: 1,
experience: 0, experience: 0,
@@ -807,8 +866,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const gameClass = static_.classes.find((candidate) => candidate.id === classId) const gameClass = static_.classes.find((candidate) => candidate.id === classId)
if (!gameClass) throw new Error('Selected class does not exist.') if (!gameClass) throw new Error('Selected class does not exist.')
const slots = abilitySlots.slice(0, 6) const slots = normalizeAbilitySlots(abilitySlots)
while (slots.length < 6) slots.push(null)
const selectedIds = slots.filter((id): id is number => id !== null) const selectedIds = slots.filter((id): id is number => id !== null)
if (new Set(selectedIds).size !== selectedIds.length) { if (new Set(selectedIds).size !== selectedIds.length) {
throw new Error('The same ability cannot be equipped twice.') throw new Error('The same ability cannot be equipped twice.')
@@ -831,7 +889,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
store.writeSave(save) store.writeSave(save)
return buildProfile(save) return buildProfile(save)
}, },
async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) { async completeDungeon(dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) {
void startPart void startPart
void partDurationSeconds void partDurationSeconds
if (!Number.isInteger(resourceSpent) || resourceSpent < 0) { if (!Number.isInteger(resourceSpent) || resourceSpent < 0) {
@@ -857,8 +915,15 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const previousLevel = cd.level const previousLevel = cd.level
const previousExperience = cd.experience const previousExperience = cd.experience
const partCount = completedPart ?? 1 const partCount = completedPart ?? 1
const experienceReward = Math.round( const rewardMultiplier = hardMode ? 2 : 1
dungeon.experienceReward * difficulty.experienceMultiplier * partCount, const baseExperienceReward = Math.round(
dungeon.experienceReward * difficulty.experienceMultiplier * partCount * rewardMultiplier,
)
const experienceReward = catchUpExperienceReward(
baseExperienceReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
) )
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const newExperience = Math.min(previousExperience + experienceReward, maxExperience) const newExperience = Math.min(previousExperience + experienceReward, maxExperience)
@@ -906,19 +971,20 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)] const selected = rewardPool[Math.floor(Math.random() * rewardPool.length)]
const existing = profile.inventory.find((item) => item.id === selected.id) const existing = profile.inventory.find((item) => item.id === selected.id)
const duplicate = Boolean(existing) const duplicate = Boolean(existing)
let quantityAfter = 1 const rewardQuantity = rewardMultiplier
let quantityAfter = rewardQuantity
if (existing) { if (existing) {
existing.quantity += 1 existing.quantity += rewardQuantity
quantityAfter = existing.quantity quantityAfter = existing.quantity
} else { } else {
profile.inventory.push({ profile.inventory.push({
...selected, ...selected,
quantity: 1, quantity: rewardQuantity,
equipped: false, equipped: false,
}) })
} }
cd.inventory = profile.inventory cd.inventory = profile.inventory
bonusItem = { ...selected, quantity: 1, duplicate, quantityAfter } bonusItem = { ...selected, quantity: rewardQuantity, duplicate, quantityAfter }
} }
} }
@@ -971,13 +1037,19 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const maxExperience = experienceForLevel(profile.maxLevel) const maxExperience = experienceForLevel(profile.maxLevel)
const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3)) const bossesCleared = Math.max(0, Math.floor(options?.bossesCleared ?? encountersCleared / 3))
const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level' const scaledReward = options?.experienceMode === 'pvp-boss-quarter-level'
? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel) ? scaledPvpBossExperience(previousExperience, previousLevel, bossesCleared, profile.maxLevel, highestOtherClassLevel(save))
: null : null
const baseRoguelikeReward = Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3))
const newExperience = scaledReward const newExperience = scaledReward
? scaledReward.experience ? scaledReward.experience
: Math.min( : Math.min(
previousExperience previousExperience
+ Math.round(dungeon.experienceReward * difficulty.experienceMultiplier * (encountersCleared / 3)), + catchUpExperienceReward(
baseRoguelikeReward,
previousExperience,
previousLevel,
highestOtherClassLevel(save),
),
maxExperience, maxExperience,
) )
let newLevel = scaledReward?.level ?? previousLevel let newLevel = scaledReward?.level ?? previousLevel
@@ -1041,6 +1113,34 @@ 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 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) { if (cd.talentPoints <= 0) {
throw new Error('No talent points are available.') throw new Error('No talent points are available.')
} }
@@ -1083,10 +1183,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)
}, },
@@ -1161,11 +1263,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository {
const profile = buildProfile(save) const profile = buildProfile(save)
const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId) const recipe = profile.craftingRecipes.find((candidate) => candidate.id === recipeId)
if (!recipe) throw new Error('That crafting recipe does not exist.') if (!recipe) throw new Error('That crafting recipe does not exist.')
const requiresUpgrade = profile.craftingRecipes.some((candidate) => const requiresUpgrade = !DIRECT_CRAFT_ITEM_LEVELS.has(recipe.item.itemLevel)
candidate.sourceEncounterId === recipe.sourceEncounterId
&& candidate.item.slot === recipe.item.slot
&& candidate.item.itemLevel < recipe.item.itemLevel,
)
if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.') if (requiresUpgrade) throw new Error('Upgrade the previous item tier instead.')
const missing = recipe.components.find((component) => component.owned < component.quantity) const missing = recipe.components.find((component) => component.owned < component.quantity)
if (missing) { if (missing) {
@@ -1361,7 +1459,7 @@ const cachedOnlineRepository: GameRepository = {
}, },
loadProfile: () => cachedOnlineLocalRepository.loadProfile(), loadProfile: () => cachedOnlineLocalRepository.loadProfile(),
saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots), saveProfile: (classId, abilitySlots) => cachedOnlineLocalRepository.saveProfile(classId, abilitySlots),
completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds) => completeDungeon: (dungeonId, difficultyId, resourceSpent, durationSeconds, completedPart, startPart, partDurationSeconds, hardMode) =>
cachedOnlineLocalRepository.completeDungeon( cachedOnlineLocalRepository.completeDungeon(
dungeonId, dungeonId,
difficultyId, difficultyId,
@@ -1370,6 +1468,7 @@ const cachedOnlineRepository: GameRepository = {
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
), ),
completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) => completeRoguelike: (dungeonId, difficultyId, encountersCleared, resourceSpent, durationSeconds, options) =>
cachedOnlineLocalRepository.completeRoguelike( cachedOnlineLocalRepository.completeRoguelike(
+18 -17
View File
@@ -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',
}, },
} }
@@ -121,7 +125,6 @@ const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1' const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action' const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller' const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const COMBAT_TARGET_NAVIGATION_THROTTLE_MS = 220
type CaptureState = { type CaptureState = {
device: InputDevice device: InputDevice
@@ -146,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',
@@ -167,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,
@@ -277,14 +290,6 @@ function hasUiOverlay() {
).some(isVisible) ).some(isVisible)
} }
function isCombatTargetAction(action: InputAction) {
return action.startsWith('navigate')
|| action.startsWith('targetParty')
|| action === 'previousTarget'
|| action === 'nextTarget'
|| action === 'toggleTargetGroup'
}
const BUTTON_LABELS: Record<number, string> = { const BUTTON_LABELS: Record<number, string> = {
0: 'A / Cross', 0: 'A / Cross',
1: 'B / Circle', 1: 'B / Circle',
@@ -398,7 +403,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const keyboardInputRef = useRef(keyboardInput) const keyboardInputRef = useRef(keyboardInput)
const previousTokensRef = useRef(new Set<string>()) const previousTokensRef = useRef(new Set<string>())
const repeatRef = useRef<Record<string, number>>({}) const repeatRef = useRef<Record<string, number>>({})
const lastCombatNavigationRef = useRef(0)
useEffect(() => { useEffect(() => {
bindingsRef.current = bindings bindingsRef.current = bindings
@@ -445,11 +449,6 @@ export function InputProvider({ children }: { children: ReactNode }) {
const dispatchAction = useCallback((action: InputAction, device: InputDevice) => { const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
const uiOverlay = hasUiOverlay() const uiOverlay = hasUiOverlay()
const combatActive = Boolean(document.querySelector('[data-combat-active="true"]')) const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
if (combatActive && !uiOverlay && isCombatTargetAction(action)) {
const now = performance.now()
if (now - lastCombatNavigationRef.current < COMBAT_TARGET_NAVIGATION_THROTTLE_MS) return
lastCombatNavigationRef.current = now
}
setLastDevice(device) setLastDevice(device)
document.documentElement.dataset.inputDevice = device document.documentElement.dataset.inputDevice = device
@@ -519,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',
File diff suppressed because it is too large Load Diff
+2
View File
@@ -319,6 +319,7 @@ export async function completeDungeon(
completedPart?: number, completedPart?: number,
startPart?: number, startPart?: number,
partDurationSeconds?: [number, number, number], partDurationSeconds?: [number, number, number],
hardMode?: boolean,
): Promise<DungeonReward> { ): Promise<DungeonReward> {
return activeGameRepository().completeDungeon( return activeGameRepository().completeDungeon(
dungeonId, dungeonId,
@@ -328,6 +329,7 @@ export async function completeDungeon(
completedPart, completedPart,
startPart, startPart,
partDurationSeconds, partDurationSeconds,
hardMode,
) )
} }