updating buff and debuff icons. separated COA classes from WoW classes on the character creation screen
This commit is contained in:
@@ -124,6 +124,23 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates custom-class profiles into the CoA category", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
const character = createCharacter({
|
||||
ownerId: offline.ownerId,
|
||||
name: "Conqueror",
|
||||
categoryId: "wow",
|
||||
raceId: "human",
|
||||
classId: "barbarian",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
|
||||
expect(character.categoryId).toBe("coa");
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.categoryId).toBe("coa");
|
||||
});
|
||||
|
||||
it("persists controller ability bindings per character without changing progression", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import {
|
||||
contentCategoryForRace,
|
||||
contentCategoryForClass,
|
||||
isRomClassId,
|
||||
type RomClassId,
|
||||
} from "./characterCatalog";
|
||||
@@ -112,7 +112,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
equipment?: unknown;
|
||||
manastormProgress?: unknown;
|
||||
settings?: unknown;
|
||||
categoryId?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
actionLoadouts?: unknown;
|
||||
};
|
||||
@@ -132,9 +131,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
|
||||
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
||||
: {};
|
||||
const categoryId = legacy.categoryId === "rom" || legacy.categoryId === "wow"
|
||||
? legacy.categoryId
|
||||
: contentCategoryForRace(value.raceId);
|
||||
const categoryId = contentCategoryForClass(value.classId);
|
||||
const secondaryClassId = categoryId === "rom"
|
||||
&& isRomClassId(value.classId)
|
||||
&& typeof legacy.secondaryClassId === "string"
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
classIconUrl,
|
||||
classesForRace,
|
||||
clampAppearance,
|
||||
contentCategoryForClass,
|
||||
normalizeCharacterName,
|
||||
randomAppearance,
|
||||
supportedGenders,
|
||||
totalRaceClassCombinations,
|
||||
validateCharacterName,
|
||||
} from "./characterCatalog";
|
||||
import { CONTENT_CATEGORIES } from "./contentCategories";
|
||||
|
||||
describe("wow335a character catalog", () => {
|
||||
it("limits normal play to the live-verified race roster", () => {
|
||||
@@ -34,9 +36,13 @@ describe("wow335a character catalog", () => {
|
||||
|
||||
it("matches the extracted Ascension playable catalog", () => {
|
||||
const wowRaces = RACES.filter((race) => race.categoryId !== "rom");
|
||||
const wowClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
|
||||
const azerothClasses = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom");
|
||||
const wowClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "wow");
|
||||
const coaClasses = CLASSES.filter((characterClass) => contentCategoryForClass(characterClass.id) === "coa");
|
||||
expect(wowRaces).toHaveLength(27);
|
||||
expect(wowClasses).toHaveLength(31);
|
||||
expect(azerothClasses).toHaveLength(31);
|
||||
expect(wowClasses).toHaveLength(10);
|
||||
expect(coaClasses).toHaveLength(21);
|
||||
expect(COA_CLASS_IDS).toHaveLength(21);
|
||||
expect(wowRaces.reduce((total, race) => total + classesForRace(race.id).length, 0)).toBe(740);
|
||||
expect(totalRaceClassCombinations()).toBe(758);
|
||||
@@ -48,7 +54,7 @@ describe("wow335a character catalog", () => {
|
||||
"Forest Troll", "Taunka", "Northrend Skeleton", "Ice Troll", "Earthen",
|
||||
"Human Cultist",
|
||||
]);
|
||||
expect(COA_CLASS_IDS.map((id) => wowClasses.find((entry) => entry.id === id)?.name)).toEqual([
|
||||
expect(COA_CLASS_IDS.map((id) => coaClasses.find((entry) => entry.id === id)?.name)).toEqual([
|
||||
"Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer",
|
||||
"Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger",
|
||||
"Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller",
|
||||
@@ -56,16 +62,25 @@ describe("wow335a character catalog", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("separates default WoW, custom CoA, and RoM character creation categories", () => {
|
||||
expect(CONTENT_CATEGORIES.map((category) => category.id)).toEqual(["wow", "rom", "coa"]);
|
||||
expect(classesForRace("human", "wow").map((entry) => entry.id)).toContain("priest");
|
||||
expect(classesForRace("human", "wow").map((entry) => entry.id)).not.toContain("barbarian");
|
||||
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
|
||||
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("priest");
|
||||
expect(classesForRace("rom-human", "rom").map((entry) => entry.id)).toContain("rom-priest");
|
||||
});
|
||||
|
||||
it("applies the race eligibility rows from CharBaseInfo.dbc", () => {
|
||||
const wowClassCount = CLASSES.filter((characterClass) => characterClass.categoryId !== "rom").length;
|
||||
expect(classesForRace("human").map((entry) => entry.id)).toContain("barbarian");
|
||||
expect(classesForRace("human").map((entry) => entry.id)).not.toContain("felsworn");
|
||||
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("felsworn");
|
||||
expect(classesForRace("draenei").map((entry) => entry.id)).toContain("knight-of-xoroth");
|
||||
expect(classesForRace("troll").map((entry) => entry.id)).toContain("venomancer");
|
||||
expect(classesForRace("troll").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
|
||||
expect(classesForRace("goblin")).toHaveLength(wowClassCount);
|
||||
expect(classesForRace("human-cultist")).toHaveLength(wowClassCount);
|
||||
expect(classesForRace("human", "coa").map((entry) => entry.id)).toContain("barbarian");
|
||||
expect(classesForRace("human", "coa").map((entry) => entry.id)).not.toContain("felsworn");
|
||||
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("felsworn");
|
||||
expect(classesForRace("draenei", "coa").map((entry) => entry.id)).toContain("knight-of-xoroth");
|
||||
expect(classesForRace("troll", "coa").map((entry) => entry.id)).toContain("venomancer");
|
||||
expect(classesForRace("troll", "coa").map((entry) => entry.id)).not.toContain("knight-of-xoroth");
|
||||
expect(classesForRace("goblin", "wow")).toHaveLength(10);
|
||||
expect(classesForRace("goblin", "coa")).toHaveLength(21);
|
||||
expect(classesForRace("human-cultist")).toHaveLength(31);
|
||||
expect(supportedGenders("vrykul")).toEqual(["male"]);
|
||||
expect(supportedGenders("earthen")).toEqual(["male", "female"]);
|
||||
});
|
||||
|
||||
+41
-25
@@ -408,27 +408,27 @@ export const CLASSES: readonly ClassDefinition[] = [
|
||||
{ id: "warlock", dbcId: 9, name: "Warlock", role: "Damage", sigil: "WL", color: "#9482c9", description: "Fel caster commanding curses and summoned demons.", mode: "classic", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Affliction", "Demonology", "Destruction"], archetype: "warlock" },
|
||||
{ id: "druid", dbcId: 11, name: "Druid", role: "Tank / Healer / Damage", sigil: "DU", color: "#ff7d0a", description: "Shapeshifter drawing power from the wilds.", mode: "classic", armor: "leather", primaryStat: "agility", resourceSummary: "Mana", specializations: ["Balance", "Feral", "Restoration"], archetype: "druid" },
|
||||
|
||||
{ id: "barbarian", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
|
||||
{ id: "witch-doctor", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
|
||||
{ id: "felsworn", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
|
||||
{ id: "witch-hunter", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
|
||||
{ id: "stormbringer", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
|
||||
{ id: "knight-of-xoroth", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
|
||||
{ id: "guardian", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
|
||||
{ id: "templar", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
|
||||
{ id: "bloodmage", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
|
||||
{ id: "ranger", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
|
||||
{ id: "chronomancer", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
|
||||
{ id: "necromancer", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
|
||||
{ id: "pyromancer", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
|
||||
{ id: "cultist", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
|
||||
{ id: "starcaller", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
|
||||
{ id: "sun-cleric", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
|
||||
{ id: "tinker", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
|
||||
{ id: "venomancer", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
|
||||
{ id: "reaper", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
|
||||
{ id: "primalist", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
|
||||
{ id: "runemaster", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
|
||||
{ id: "barbarian", categoryId: "coa", dbcId: 12, name: "Barbarian", role: "Damage / Support", sigil: "BA", color: "#8a3303", description: "A brutal melee combatant who cleaves foes and rallies ancestral power.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy", specializations: ["Brutality", "Headhunting", "Ancestry"], archetype: "rogue" },
|
||||
{ id: "witch-doctor", categoryId: "coa", dbcId: 13, name: "Witch Doctor", role: "Healer / Damage", sigil: "WD", color: "#6eff00", description: "A shadowhunter who mixes hexes, brews, wards, and restorative voodoo.", mode: "conquest", armor: "leather", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Shadowhunting", "Voodoo", "Brewing"], archetype: "shaman" },
|
||||
{ id: "felsworn", categoryId: "coa", dbcId: 14, name: "Felsworn", role: "Damage / Tank", sigil: "FE", color: "#a330c9", description: "A demon-touched fighter who turns fel fury into relentless offense and defense.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Felfury", specializations: ["Slayer", "Infernal", "Tyrant"], archetype: "rogue" },
|
||||
{ id: "witch-hunter", categoryId: "coa", dbcId: 15, name: "Witch Hunter", role: "Damage / Tank", sigil: "WH", color: "#abd473", description: "An agile inquisitor wielding blades, firearms, traps, and forbidden shadow.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Boltslinger", "Houndmaster", "Inquisition", "Black Knight"], archetype: "hunter" },
|
||||
{ id: "stormbringer", categoryId: "coa", dbcId: 16, name: "Stormbringer", role: "Damage / Support", sigil: "ST", color: "#0070de", description: "An elemental conduit who commands lightning, wind, and violent storms.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Static", specializations: ["Maelstrom", "Lightning", "Wind"], archetype: "shaman" },
|
||||
{ id: "knight-of-xoroth", categoryId: "coa", dbcId: 17, name: "Knight of Xoroth", role: "Tank / Damage", sigil: "KX", color: "#d64747", description: "A demonic plate fighter fueled by rage, deathfire, and hellish summons.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Rage / Deathfire", specializations: ["Hellfire", "Defiance", "War"], archetype: "death-knight" },
|
||||
{ id: "guardian", categoryId: "coa", dbcId: 18, name: "Guardian", role: "Tank / Damage / Support", sigil: "GU", color: "#c79c6e", description: "A sword-and-board leader who blocks attacks and inspires allies with banners.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Energy", specializations: ["Gladiator", "Inspiration", "Vanguard"], archetype: "warrior" },
|
||||
{ id: "templar", categoryId: "coa", dbcId: 19, name: "Templar", role: "Damage / Tank", sigil: "TE", color: "#00ff99", description: "A flowing holy martial artist who chains strikes into powerful combos.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Energy / Holy Runes", specializations: ["Zealot", "Oathkeeper", "Crusader"], archetype: "rogue" },
|
||||
{ id: "bloodmage", categoryId: "coa", dbcId: 20, name: "Bloodmage", role: "Damage / Healer / Tank", sigil: "BM", color: "#cc9900", description: "A sanguine spellblade who spends vitality and embraces the worgen curse.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Rage / Health", specializations: ["Sanguine", "Accursed", "Eternal", "Fleshweaver"], archetype: "druid" },
|
||||
{ id: "ranger", categoryId: "coa", dbcId: 21, name: "Ranger", role: "Damage / Support", sigil: "RA", color: "#fff569", description: "A mobile skirmisher equally dangerous with bows or paired melee weapons.", mode: "conquest", armor: "leather", primaryStat: "agility", resourceSummary: "Focus / Archery Points", specializations: ["Archery", "Brigand", "Farstrider"], archetype: "hunter" },
|
||||
{ id: "chronomancer", categoryId: "coa", dbcId: 22, name: "Chronomancer", role: "Healer / Damage", sigil: "CH", color: "#f2e699", description: "A rule-bending caster who reverses wounds and warps order, chaos, and time.", mode: "conquest", armor: "cloth", primaryStat: "spirit", resourceSummary: "Mana", specializations: ["Infinite", "Time", "Artificer"], archetype: "priest" },
|
||||
{ id: "necromancer", categoryId: "coa", dbcId: 23, name: "Necromancer", role: "Damage", sigil: "NE", color: "#8787ed", description: "A master of frost, plague, and permanent undead minions.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runic Power", specializations: ["Death", "Rime", "Animation"], archetype: "warlock" },
|
||||
{ id: "pyromancer", categoryId: "coa", dbcId: 24, name: "Pyromancer", role: "Damage / Support", sigil: "PY", color: "#ff300f", description: "A fire specialist who incinerates foes, cauterizes allies, and channels dragons.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Incineration", "Flameweaving", "Draconic"], archetype: "mage" },
|
||||
{ id: "cultist", categoryId: "coa", dbcId: 25, name: "Cultist", role: "Healer / Damage / Tank", sigil: "CU", color: "#e0c7ff", description: "A versatile servant of the Old Gods wielding eldritch spells and void blades.", mode: "conquest", armor: "plate", primaryStat: "strength", resourceSummary: "Mana / Insanity", specializations: ["Godblade", "Corruption", "Heretic", "Dreadnought"], archetype: "paladin" },
|
||||
{ id: "starcaller", categoryId: "coa", dbcId: 26, name: "Starcaller", role: "Tank / Damage / Healer", sigil: "SC", color: "#b5ffff", description: "An astral warrior who invokes Elune through blades, bows, and lunar magic.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana", specializations: ["Moon Guard", "Sentinel", "Moon Priest", "Warden"], archetype: "paladin" },
|
||||
{ id: "sun-cleric", categoryId: "coa", dbcId: 27, name: "Sun Cleric", role: "Damage / Healer / Tank", sigil: "SU", color: "#f58cba", description: "A solar champion who heals with warmth and burns enemies with holy fire.", mode: "conquest", armor: "plate", primaryStat: "intellect", resourceSummary: "Mana / Rage / Solar Power", specializations: ["Piety", "Blessings", "Valkyrie", "Seraphim"], archetype: "paladin" },
|
||||
{ id: "tinker", categoryId: "coa", dbcId: 28, name: "Tinker", role: "Damage / Healer", sigil: "TI", color: "#a3a3a3", description: "An inventor armed with guns, ammunition, turrets, gadgets, and mechanical allies.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Energy / Ammunition", specializations: ["Demolition", "Invention", "Mechanics"], archetype: "hunter" },
|
||||
{ id: "venomancer", categoryId: "coa", dbcId: 29, name: "Venomancer", role: "Tank / Damage / Healer", sigil: "VE", color: "#ff7d0a", description: "A shapeshifting toxin master who spreads venom and hardens into insect forms.", mode: "conquest", armor: "mail", primaryStat: "intellect", resourceSummary: "Mana / Rage", specializations: ["Venom", "Stalking", "Fortitude", "Vizier"], archetype: "shaman" },
|
||||
{ id: "reaper", categoryId: "coa", dbcId: 30, name: "Reaper", role: "Damage / Tank", sigil: "RE", color: "#c41f3b", description: "A soul-harvesting warrior who moves like a spectre and dominates the dead.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Mana / Souls", specializations: ["Harvest", "Soul", "Domination"], archetype: "death-knight" },
|
||||
{ id: "primalist", categoryId: "coa", dbcId: 31, name: "Primalist", role: "Damage / Tank / Healer", sigil: "PR", color: "#0d2ed6", description: "A primal shapeshifter who calls beasts, stone, magma, and the Earthmother.", mode: "conquest", armor: "mail", primaryStat: "agility", resourceSummary: "Rage / Mana", specializations: ["Primal", "Geomancy", "Life", "Mountain King"], archetype: "druid" },
|
||||
{ id: "runemaster", categoryId: "coa", dbcId: 32, name: "Runemaster", role: "Damage / Tank", sigil: "RU", color: "#40c7eb", description: "A rune-inscribing battlemage who bends elements, portals, and spellblades.", mode: "conquest", armor: "cloth", primaryStat: "intellect", resourceSummary: "Mana / Runes", specializations: ["Runic", "Arcane", "Riftblade"], archetype: "mage" },
|
||||
|
||||
{ id: "rom-warrior", categoryId: "rom", dbcId: 1, name: "Warrior", role: "Tank / Damage", sigil: "RW", color: "#b86b3f", description: "A hardened weapon fighter who converts incoming pressure into rage.", mode: "rom", armor: "plate", primaryStat: "strength", resourceSummary: "Rage", specializations: [], archetype: "warrior" },
|
||||
{ id: "rom-scout", categoryId: "rom", dbcId: 2, name: "Scout", role: "Damage", sigil: "SC", color: "#8bae58", description: "A mobile ranged hunter who relies on focus, precision, and battlefield control.", mode: "rom", armor: "leather", primaryStat: "agility", resourceSummary: "Focus", specializations: [], archetype: "hunter" },
|
||||
@@ -595,21 +595,37 @@ export function isRomClassId(classId: ClassId): classId is RomClassId {
|
||||
}
|
||||
|
||||
export function racesForCategory(categoryId: ContentCategoryId): readonly RaceDefinition[] {
|
||||
return PLAYABLE_RACES.filter((race) => (race.categoryId ?? "wow") === categoryId);
|
||||
return PLAYABLE_RACES.filter((race) => {
|
||||
const raceCategory = race.categoryId ?? "wow";
|
||||
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
|
||||
});
|
||||
}
|
||||
|
||||
export function classesForRace(raceId: RaceId): readonly ClassDefinition[] {
|
||||
export function raceSupportsCategory(raceId: RaceId, categoryId: ContentCategoryId): boolean {
|
||||
const raceCategory = contentCategoryForRace(raceId);
|
||||
return raceCategory === categoryId || (categoryId === "coa" && raceCategory === "wow");
|
||||
}
|
||||
|
||||
export function classesForRace(
|
||||
raceId: RaceId,
|
||||
categoryId?: ContentCategoryId,
|
||||
): readonly ClassDefinition[] {
|
||||
const race = raceById(raceId);
|
||||
if (isRomRaceId(raceId)) {
|
||||
if (categoryId && categoryId !== "rom") return [];
|
||||
const allowed = new Set(ROM_CLASSES_BY_RACE[raceId]);
|
||||
return CLASSES.filter((characterClass) => allowed.has(characterClass.id as RomClassId));
|
||||
}
|
||||
if (categoryId === "rom") return [];
|
||||
const classMatchesCategory = (characterClass: ClassDefinition) => (
|
||||
categoryId ? contentCategoryForClass(characterClass.id) === categoryId : contentCategoryForClass(characterClass.id) !== "rom"
|
||||
);
|
||||
if (race.unrestrictedClasses) return CLASSES.filter((characterClass) => (
|
||||
(characterClass.categoryId ?? "wow") === "wow"
|
||||
classMatchesCategory(characterClass)
|
||||
));
|
||||
const raceDbcId = race.dbcId;
|
||||
return CLASSES.filter((characterClass) => (
|
||||
(characterClass.categoryId ?? "wow") === "wow"
|
||||
classMatchesCategory(characterClass)
|
||||
&& RACE_DBC_IDS_BY_CLASS[characterClass.id].includes(raceDbcId)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ContentCategoryId = "wow" | "rom";
|
||||
export type ContentCategoryId = "wow" | "rom" | "coa";
|
||||
|
||||
export interface ContentCategoryDefinition {
|
||||
readonly id: ContentCategoryId;
|
||||
@@ -6,9 +6,13 @@ export interface ContentCategoryDefinition {
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/** Shared by character creation and dungeon selection. */
|
||||
/** Character-creation categories; other catalogs may expose a supported subset. */
|
||||
export const CONTENT_CATEGORIES: readonly ContentCategoryDefinition[] = Object.freeze([
|
||||
{ id: "wow", label: "WoW", name: "World of Warcraft" },
|
||||
{ id: "rom", label: "RoM", name: "Runes of Magic" },
|
||||
{ id: "coa", label: "CoA", name: "Conquest of Azeroth" },
|
||||
]);
|
||||
|
||||
export function contentCategoryById(id: ContentCategoryId | null | undefined): ContentCategoryDefinition {
|
||||
return CONTENT_CATEGORIES.find((category) => category.id === id) ?? CONTENT_CATEGORIES[0];
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("shell store", () => {
|
||||
expect(useShellStore.getState().draft.gender).toBe("male");
|
||||
});
|
||||
|
||||
it("resets the creator to a valid draft when switching between WoW and RoM", () => {
|
||||
it("resets the creator to a valid draft when switching among WoW, RoM, and CoA", () => {
|
||||
useShellStore.getState().openCreator();
|
||||
useShellStore.getState().updateDraft({ name: "Category Keeper", categoryId: "rom" });
|
||||
expect(useShellStore.getState().draft).toMatchObject({
|
||||
@@ -179,6 +179,16 @@ describe("shell store", () => {
|
||||
classId: "rom-champion",
|
||||
gender: "male",
|
||||
});
|
||||
useShellStore.getState().updateDraft({ categoryId: "coa" });
|
||||
expect(useShellStore.getState().draft).toMatchObject({
|
||||
name: "Categorykeep",
|
||||
categoryId: "coa",
|
||||
raceId: "human",
|
||||
classId: "barbarian",
|
||||
gender: "female",
|
||||
});
|
||||
useShellStore.getState().updateDraft({ classId: "priest" });
|
||||
expect(useShellStore.getState().draft.classId).toBe("barbarian");
|
||||
useShellStore.getState().updateDraft({ categoryId: "wow" });
|
||||
expect(useShellStore.getState().draft).toMatchObject({
|
||||
name: "Categorykeep",
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
clampAppearance,
|
||||
classById,
|
||||
classesForRace,
|
||||
contentCategoryForRace,
|
||||
createDefaultAppearance,
|
||||
normalizeCharacterName,
|
||||
randomAppearance,
|
||||
raceSupportsCategory,
|
||||
racesForCategory,
|
||||
supportedGenders,
|
||||
validateCharacterName,
|
||||
@@ -100,7 +100,7 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
name: "",
|
||||
categoryId,
|
||||
raceId,
|
||||
classId: categoryId === "rom" ? "rom-priest" : "priest",
|
||||
classId: categoryId === "rom" ? "rom-priest" : categoryId === "coa" ? "barbarian" : "priest",
|
||||
gender: "female",
|
||||
appearance: createDefaultAppearance(),
|
||||
rotation: -12,
|
||||
@@ -165,16 +165,16 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
? { ...defaultDraft(patch.categoryId), name: state.draft.name, rotation: state.draft.rotation }
|
||||
: { ...state.draft, ...patch };
|
||||
if (patch.name !== undefined) next.name = normalizeCharacterName(patch.name);
|
||||
if (contentCategoryForRace(next.raceId) !== next.categoryId) {
|
||||
if (!raceSupportsCategory(next.raceId, next.categoryId)) {
|
||||
const targetRace = racesForCategory(next.categoryId)[0];
|
||||
next.raceId = targetRace.id;
|
||||
next.classId = classesForRace(targetRace.id)[0].id;
|
||||
next.classId = classesForRace(targetRace.id, next.categoryId)[0].id;
|
||||
next.gender = supportedGenders(targetRace.id)[0] ?? "male";
|
||||
next.appearance = createDefaultAppearance();
|
||||
}
|
||||
const availableGenders = supportedGenders(next.raceId);
|
||||
if (!availableGenders.includes(next.gender)) next.gender = availableGenders[0] ?? "male";
|
||||
const availableClasses = classesForRace(next.raceId);
|
||||
const availableClasses = classesForRace(next.raceId, next.categoryId);
|
||||
if (!availableClasses.some((definition) => definition.id === next.classId)) {
|
||||
next.classId = availableClasses[0].id;
|
||||
}
|
||||
@@ -201,7 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({ notice: nameError });
|
||||
return false;
|
||||
}
|
||||
if (!classesForRace(draft.raceId).some((definition) => definition.id === draft.classId)) {
|
||||
if (!classesForRace(draft.raceId, draft.categoryId).some((definition) => definition.id === draft.classId)) {
|
||||
set({ notice: "That class is not available to the selected race." });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -39,9 +39,11 @@ import { resolveContentUrl } from "../content/contentManager";
|
||||
import {
|
||||
selectCharacterBaseClip,
|
||||
selectCharacterEventClip,
|
||||
selectCharacterVerticalClip,
|
||||
type CharacterCombatAnimationEvent,
|
||||
type CharacterBaseMotion,
|
||||
} from "../game/combatAnimation";
|
||||
import type { CharacterVerticalMotion } from "../game/playerJump";
|
||||
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
|
||||
import {
|
||||
ATTACK_PRESENTATION_DURATION_SECONDS,
|
||||
@@ -77,6 +79,7 @@ export interface CharacterModelProps {
|
||||
identity: AvatarIdentity | null | undefined;
|
||||
active?: boolean;
|
||||
movingRef?: MutableRefObject<boolean>;
|
||||
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
|
||||
attackPulseRef?: MutableRefObject<number>;
|
||||
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||
onReady?: () => void;
|
||||
@@ -88,6 +91,8 @@ export interface CharacterModelProps {
|
||||
|
||||
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
|
||||
|
||||
type CharacterLocomotionMotion = CharacterBaseMotion | Exclude<CharacterVerticalMotion, "grounded">;
|
||||
|
||||
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
|
||||
|
||||
function configureMaterial(material: Material): void {
|
||||
@@ -164,6 +169,7 @@ function LoadedCharacterModel({
|
||||
identity,
|
||||
manifest,
|
||||
movingRef,
|
||||
verticalMotionRef,
|
||||
attackPulseRef,
|
||||
animationEventRef,
|
||||
onReady,
|
||||
@@ -176,6 +182,7 @@ function LoadedCharacterModel({
|
||||
identity: AvatarIdentity;
|
||||
manifest: AvatarManifest;
|
||||
movingRef?: MutableRefObject<boolean>;
|
||||
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
|
||||
attackPulseRef?: MutableRefObject<number>;
|
||||
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||
onReady?: () => void;
|
||||
@@ -190,7 +197,7 @@ function LoadedCharacterModel({
|
||||
const overrideAction = useRef<AnimationAction | null>(null);
|
||||
const overrideTerminal = useRef(false);
|
||||
const overrideLooping = useRef(false);
|
||||
const currentMotion = useRef<CharacterBaseMotion | null>(null);
|
||||
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
|
||||
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
|
||||
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
|
||||
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
|
||||
@@ -338,19 +345,27 @@ function LoadedCharacterModel({
|
||||
?? gltf.animations[0]
|
||||
);
|
||||
|
||||
const switchMotion = (motion: CharacterBaseMotion) => {
|
||||
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
|
||||
if (overrideAction.current) return;
|
||||
if (currentMotion.current === motion) return;
|
||||
const clip = selectClip(motion);
|
||||
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
|
||||
? selectCharacterVerticalClip(gltf.animations, motion, moving)
|
||||
: null;
|
||||
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
|
||||
const clip = verticalSelection?.clip ?? selectClip(
|
||||
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion,
|
||||
);
|
||||
if (!clip) return;
|
||||
const loop = verticalSelection?.loop ?? true;
|
||||
const next = mixer.clipAction(clip, scene);
|
||||
if (currentAction.current !== next) {
|
||||
next.enabled = true;
|
||||
next.clampWhenFinished = false;
|
||||
next.setLoop(LoopRepeat, Number.POSITIVE_INFINITY).reset().fadeIn(0.16).play();
|
||||
currentAction.current?.fadeOut(0.16);
|
||||
currentAction.current = next;
|
||||
}
|
||||
next.enabled = true;
|
||||
next.clampWhenFinished = !loop;
|
||||
next.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
|
||||
.reset()
|
||||
.fadeIn(0.16)
|
||||
.play();
|
||||
if (currentAction.current !== next) currentAction.current?.fadeOut(0.16);
|
||||
currentAction.current = next;
|
||||
currentMotion.current = motion;
|
||||
};
|
||||
|
||||
@@ -415,7 +430,7 @@ function LoadedCharacterModel({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
switchMotion("idle");
|
||||
switchMotion("idle", false);
|
||||
return () => {
|
||||
mixer.stopAllAction();
|
||||
mixer.uncacheRoot(scene);
|
||||
@@ -439,6 +454,10 @@ function LoadedCharacterModel({
|
||||
useFrame((_, delta) => {
|
||||
if (!active) return;
|
||||
const moving = !preview && Boolean(movingRef?.current);
|
||||
const verticalMotion = preview ? "grounded" : verticalMotionRef?.current ?? "grounded";
|
||||
const locomotionMotion: CharacterLocomotionMotion = verticalMotion === "grounded"
|
||||
? moving ? "run" : "idle"
|
||||
: verticalMotion;
|
||||
const nextAnimationEvent = animationEventRef?.current;
|
||||
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
|
||||
observedAnimationRevision.current = nextAnimationEvent.revision;
|
||||
@@ -455,7 +474,7 @@ function LoadedCharacterModel({
|
||||
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
|
||||
}
|
||||
}
|
||||
switchMotion(moving ? "run" : "idle");
|
||||
switchMotion(locomotionMotion, moving);
|
||||
|
||||
const safeDelta = Math.min(delta, 0.05);
|
||||
mixer.update(safeDelta);
|
||||
@@ -468,7 +487,7 @@ function LoadedCharacterModel({
|
||||
&& !overrideLooping.current
|
||||
&& !overrideAction.current.isRunning()
|
||||
) clearOverride();
|
||||
switchMotion(moving ? "run" : "idle");
|
||||
switchMotion(locomotionMotion, moving);
|
||||
const swing = attackSwingAt(attackElapsed.current);
|
||||
attackElapsed.current = Math.min(
|
||||
ATTACK_PRESENTATION_DURATION_SECONDS,
|
||||
@@ -514,6 +533,7 @@ export function CharacterModel({
|
||||
identity,
|
||||
active = true,
|
||||
movingRef,
|
||||
verticalMotionRef,
|
||||
attackPulseRef,
|
||||
animationEventRef,
|
||||
onReady,
|
||||
@@ -546,6 +566,7 @@ export function CharacterModel({
|
||||
identity={identity}
|
||||
manifest={manifest}
|
||||
movingRef={movingRef}
|
||||
verticalMotionRef={verticalMotionRef}
|
||||
attackPulseRef={attackPulseRef}
|
||||
animationEventRef={animationEventRef}
|
||||
onReady={onReady}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseWoWAnimationName,
|
||||
selectCharacterBaseClip,
|
||||
selectCharacterEventClip,
|
||||
selectCharacterVerticalClip,
|
||||
selectWoWAnimationVariation,
|
||||
} from "./combatAnimation";
|
||||
|
||||
@@ -20,6 +21,10 @@ const clips = [
|
||||
{ name: "Death (ID 1 variation 0)" },
|
||||
{ name: "AttackUnarmed (ID 16 variation 0)" },
|
||||
{ name: "AttackUnarmed (ID 16 variation 1)" },
|
||||
{ name: "JumpStart (ID 37 variation 0)" },
|
||||
{ name: "Fall (ID 40 variation 0)" },
|
||||
{ name: "JumpEnd (ID 39 variation 0)" },
|
||||
{ name: "JumpLandRun (ID 133 variation 0)" },
|
||||
];
|
||||
|
||||
describe("WoW character animation selection", () => {
|
||||
@@ -45,6 +50,19 @@ describe("WoW character animation selection", () => {
|
||||
expect(clipsForAnimationIds(clips, [57, 17])).toEqual([clips[4], clips[3]]);
|
||||
});
|
||||
|
||||
it("selects native rising, falling, and landing locomotion", () => {
|
||||
expect(selectCharacterVerticalClip(clips, "rising", false)).toMatchObject({
|
||||
clip: clips[11],
|
||||
loop: false,
|
||||
});
|
||||
expect(selectCharacterVerticalClip(clips, "falling", false)).toMatchObject({
|
||||
clip: clips[12],
|
||||
loop: true,
|
||||
});
|
||||
expect(selectCharacterVerticalClip(clips, "landing", false)?.clip).toBe(clips[13]);
|
||||
expect(selectCharacterVerticalClip(clips, "landing", true)?.clip).toBe(clips[14]);
|
||||
});
|
||||
|
||||
it("maps hard-cast lifecycle events to ready and release clips", () => {
|
||||
expect(selectCharacterEventClip(clips, "priest", {
|
||||
revision: 1,
|
||||
@@ -79,6 +97,9 @@ describe("WoW character animation selection", () => {
|
||||
{ name: "Action - buff01" },
|
||||
{ name: "Wound - hurt" },
|
||||
{ name: "Death - death" },
|
||||
{ name: "Action - jump_up" },
|
||||
{ name: "Action - jump_down" },
|
||||
{ name: "Action - jump_end_run" },
|
||||
];
|
||||
|
||||
expect(selectCharacterEventClip(runewakerClips, "rom-mage", {
|
||||
@@ -114,5 +135,11 @@ describe("WoW character animation selection", () => {
|
||||
revision: 6,
|
||||
kind: "death",
|
||||
})).toMatchObject({ clip: runewakerClips[9], terminal: true, native: true });
|
||||
expect(selectCharacterVerticalClip(runewakerClips, "rising", false)?.clip)
|
||||
.toBe(runewakerClips[10]);
|
||||
expect(selectCharacterVerticalClip(runewakerClips, "falling", false)?.clip)
|
||||
.toBe(runewakerClips[11]);
|
||||
expect(selectCharacterVerticalClip(runewakerClips, "landing", true)?.clip)
|
||||
.toBe(runewakerClips[12]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
|
||||
import type { AbilityDefinition } from "./abilityCatalog";
|
||||
import { abilityAnimationById } from "./abilityAnimationLookup";
|
||||
import type { CharacterVerticalMotion } from "./playerJump";
|
||||
|
||||
export type CharacterCombatAnimationKind =
|
||||
| "ability-start"
|
||||
@@ -112,6 +113,35 @@ export function selectCharacterBaseClip<TClip extends NamedAnimationClip>(
|
||||
return selectWoWAnimationVariation(clips, ids);
|
||||
}
|
||||
|
||||
export function selectCharacterVerticalClip<TClip extends NamedAnimationClip>(
|
||||
clips: readonly TClip[],
|
||||
motion: Exclude<CharacterVerticalMotion, "grounded">,
|
||||
moving: boolean,
|
||||
): CharacterClipSelection<TClip> | null {
|
||||
const ids = motion === "rising"
|
||||
? [37, 38, 40]
|
||||
: motion === "falling"
|
||||
? [40, 38, 37]
|
||||
: moving
|
||||
? [133, 39, 38]
|
||||
: [39, 133, 38];
|
||||
const runewakerNames = motion === "rising"
|
||||
? ["Action - jump_up", "Action - jump_loop"]
|
||||
: motion === "falling"
|
||||
? ["Action - jump_down", "Action - jump_loop"]
|
||||
: moving
|
||||
? ["Action - jump_end_run", "Action - jump_end_back"]
|
||||
: ["Action - jump_end_back", "Action - jump_end_run"];
|
||||
const clip = selectWoWAnimationVariation(clips, ids)
|
||||
?? selectRunewakerClipVariation(clips, runewakerNames);
|
||||
return clip ? {
|
||||
clip,
|
||||
loop: motion === "falling",
|
||||
terminal: false,
|
||||
native: true,
|
||||
} : null;
|
||||
}
|
||||
|
||||
function isMeleeAbility(ability: AbilityDefinition | null, classId: ClassId): boolean {
|
||||
if (!ability) return MELEE_CLASSES.has(baseClassFor(classId));
|
||||
if (ability.range.max <= 4) return true;
|
||||
|
||||
@@ -38,7 +38,13 @@ describe("named aura combat runtime", () => {
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
expiresAt: now + 1_800_000,
|
||||
definition: { disposition: "buff", dispelCategory: "magic" },
|
||||
definition: {
|
||||
disposition: "buff",
|
||||
dispelCategory: "magic",
|
||||
icon: useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-fortitude"
|
||||
))?.icon,
|
||||
},
|
||||
});
|
||||
|
||||
useCombatStore.getState().tick(0, now + 1_800_000);
|
||||
@@ -57,6 +63,11 @@ describe("named aura combat runtime", () => {
|
||||
.flatMap((aura) => aura.absorbs)
|
||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||
useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-shield"
|
||||
))?.icon,
|
||||
);
|
||||
|
||||
const healthDamage = useCombatStore.getState().damagePlayer(20, "shadow", 80);
|
||||
const afterAbsorb = useCombatStore.getState().auras
|
||||
|
||||
@@ -133,6 +133,10 @@ export interface AuraDefinition {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly disposition: AuraDisposition;
|
||||
/** Spell artwork used by every generic aura HUD surface. */
|
||||
readonly icon?: string;
|
||||
/** Keep a mechanically active aura out of the generic HUD strip when another widget owns its display. */
|
||||
readonly hideFromAuraStrip?: boolean;
|
||||
/** Null means the aura persists until explicitly removed. */
|
||||
readonly durationMs: number | null;
|
||||
readonly maxStacks?: number;
|
||||
|
||||
+19
-8
@@ -83,6 +83,7 @@ import {
|
||||
resolveResourceValue as resolveAuraResourceValue,
|
||||
resolveStatValue as resolveAuraStatValue,
|
||||
type ActiveAura,
|
||||
type AuraDefinition,
|
||||
type ProcEvent,
|
||||
type TriggeredAuraProc,
|
||||
} from "./combatAuras";
|
||||
@@ -1464,6 +1465,18 @@ function aurasForEntity(auras: readonly ActiveAura[], entityId: string): ActiveA
|
||||
return auras.filter((aura) => aura.targetId === entityId);
|
||||
}
|
||||
|
||||
function auraDefinitionForAbility(
|
||||
definition: AuraDefinition,
|
||||
ability: AbilityDefinition,
|
||||
durationMs = definition.durationMs,
|
||||
): AuraDefinition {
|
||||
return {
|
||||
...definition,
|
||||
icon: definition.icon ?? ability.icon,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAuraStats(base: CharacterStats, auras: readonly ActiveAura[]): CharacterStats {
|
||||
const stat = (value: number, ...names: string[]) => names.reduce(
|
||||
(current, name) => resolveAuraStatValue(current, auras, name),
|
||||
@@ -1534,7 +1547,7 @@ function syncPassiveAuras(
|
||||
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
|
||||
return ability.effects
|
||||
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
|
||||
.map((effect) => effect.aura);
|
||||
.map((effect) => auraDefinitionForAbility(effect.aura, ability));
|
||||
});
|
||||
const passiveIds = new Set(definitions.map((definition) => definition.id));
|
||||
let next = current.filter((aura) => (
|
||||
@@ -2125,7 +2138,7 @@ function executeTriggeredSpellInWork(
|
||||
? sourceId
|
||||
: targetId ?? sourceId;
|
||||
work.auras = [...applyCombatAura(work.auras, {
|
||||
definition: effect.aura,
|
||||
definition: auraDefinitionForAbility(effect.aura, ability),
|
||||
sourceId,
|
||||
targetId: recipient,
|
||||
now,
|
||||
@@ -3156,12 +3169,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}, ability.id, now);
|
||||
}
|
||||
} else if (effect.kind === "apply-aura") {
|
||||
const auraDefinition = effect.aura.durationMs === null
|
||||
? effect.aura
|
||||
: {
|
||||
...effect.aura,
|
||||
durationMs: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers),
|
||||
};
|
||||
const durationMs = effect.aura.durationMs === null
|
||||
? null
|
||||
: talentAdjustedDuration(effect.aura.durationMs, ability, modifiers);
|
||||
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
|
||||
const recipients = effect.recipient === "caster"
|
||||
? [PLAYER_AURA_ENTITY_ID]
|
||||
: ability.target === "hostile"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonRegistry";
|
||||
import { CONTENT_CATEGORIES, type ContentCategoryId } from "../app/contentCategories";
|
||||
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
export type DungeonCategoryId = ContentCategoryId;
|
||||
const DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
|
||||
export type DungeonCategoryId = (typeof DUNGEON_CATEGORY_IDS)[number];
|
||||
|
||||
export interface DungeonCategory {
|
||||
readonly id: DungeonCategoryId;
|
||||
@@ -10,11 +11,10 @@ export interface DungeonCategory {
|
||||
}
|
||||
|
||||
export const DUNGEON_CATEGORIES: readonly DungeonCategory[] = Object.freeze([
|
||||
...CONTENT_CATEGORIES.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
fullName: category.name,
|
||||
})),
|
||||
...DUNGEON_CATEGORY_IDS.map((id) => {
|
||||
const category = contentCategoryById(id);
|
||||
return { id, label: category.label, fullName: category.name };
|
||||
}),
|
||||
]);
|
||||
|
||||
export interface DungeonCatalogEntry {
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
MAX_MOUSE_DELTA_PER_FRAME,
|
||||
POINTER_LOCK_MOUSE_GRACE_MS,
|
||||
beginMouseLookDrag,
|
||||
clearJumpRequest,
|
||||
consumeJumpRequest,
|
||||
consumeMouseLook,
|
||||
controllerCommandForToken,
|
||||
createMouseLookBuffer,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
isDesktopCameraLookPointer,
|
||||
keyboardAxesForKeys,
|
||||
keyboardCommandForCode,
|
||||
queueJumpRequest,
|
||||
sanitizeMouseDelta,
|
||||
type InputActions,
|
||||
} from "./inputManager";
|
||||
@@ -54,6 +57,7 @@ function inputActionSpies(blocked = false): InputActions {
|
||||
|
||||
afterEach(() => {
|
||||
endMouseLookDrag();
|
||||
clearJumpRequest();
|
||||
resetControllerState();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -89,7 +93,7 @@ describe("gameplay input bindings", () => {
|
||||
it("maps Thor controls without stealing Select from display routing", () => {
|
||||
expect(controllerCommandForToken("Button9")).toBe("pause");
|
||||
expect(controllerCommandForToken("Button11")).toBe("target-next");
|
||||
expect(controllerCommandForToken("Button10")).toBe("target-clear");
|
||||
expect(controllerCommandForToken("Button10")).toBe("jump");
|
||||
expect(controllerCommandForToken("Button12")).toBe("party-previous");
|
||||
expect(controllerCommandForToken("Button13")).toBe("party-next");
|
||||
expect(controllerCommandForToken("Button4")).toBeNull();
|
||||
@@ -145,6 +149,31 @@ describe("gameplay input bindings", () => {
|
||||
expect(keyboardCommandForCode("Digit8")).toBeNull();
|
||||
});
|
||||
|
||||
it("queues Space and L3 jump presses once and blocks them behind menus", () => {
|
||||
expect(keyboardCommandForCode("Space")).toBe("jump");
|
||||
queueJumpRequest(25);
|
||||
expect(consumeJumpRequest()).toBe(25);
|
||||
expect(consumeJumpRequest()).toBeNull();
|
||||
|
||||
stubInputEventTargets();
|
||||
const nextActions = inputActionSpies();
|
||||
const blocked = nextActions.gameplayActionsBlocked as ReturnType<typeof vi.fn>;
|
||||
const dispose = installInput(nextActions);
|
||||
|
||||
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
|
||||
expect(consumeJumpRequest()).not.toBeNull();
|
||||
|
||||
emitControllerToken({ token: "Button10", repeat: true, pressed: true });
|
||||
expect(consumeJumpRequest()).toBeNull();
|
||||
|
||||
blocked.mockReturnValue(true);
|
||||
emitControllerToken({ token: "Button10", repeat: false, pressed: false });
|
||||
emitControllerToken({ token: "Button10", repeat: false, pressed: true });
|
||||
expect(consumeJumpRequest()).toBeNull();
|
||||
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("uses WASD for movement and arrow keys for camera look", () => {
|
||||
expect(keyboardAxesForKeys(new Set(["KeyW", "KeyD"]))).toEqual({
|
||||
moveX: 1,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface InputActions {
|
||||
|
||||
const keys = new Set<string>();
|
||||
let actions: InputActions | null = null;
|
||||
let jumpRequestAt: number | null = null;
|
||||
|
||||
// Pointer-lock implementations can emit a short burst of stale, very large
|
||||
// movement events while the cursor is being recentered. Some Android WebViews have also been
|
||||
@@ -69,6 +70,20 @@ function monotonicNow(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function queueJumpRequest(nowMs = monotonicNow()): void {
|
||||
if (Number.isFinite(nowMs)) jumpRequestAt = nowMs;
|
||||
}
|
||||
|
||||
export function consumeJumpRequest(): number | null {
|
||||
const requestedAt = jumpRequestAt;
|
||||
jumpRequestAt = null;
|
||||
return requestedAt;
|
||||
}
|
||||
|
||||
export function clearJumpRequest(): void {
|
||||
jumpRequestAt = null;
|
||||
}
|
||||
|
||||
export function createMouseLookBuffer(now: () => number = monotonicNow) {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@@ -161,12 +176,13 @@ export type GameplayInputCommand =
|
||||
| "party-defend"
|
||||
| "party-stop"
|
||||
| "party-recall"
|
||||
| "jump"
|
||||
| "target-clear";
|
||||
|
||||
export function controllerCommandForToken(token: string): GameplayInputCommand | null {
|
||||
if (token === "Button9" || token === CONTROLLER_SYSTEM_BACK_TOKEN) return "pause";
|
||||
if (token === "Button11") return "target-next";
|
||||
if (token === "Button10") return "target-clear";
|
||||
if (token === "Button10") return "jump";
|
||||
if (token === "Button12") return "party-previous";
|
||||
if (token === "Button13") return "party-next";
|
||||
return null;
|
||||
@@ -183,6 +199,7 @@ export function keyboardCommandForCode(code: string, shiftKey = false): Gameplay
|
||||
if (code === "F2") return "party-defend";
|
||||
if (code === "F3") return "party-stop";
|
||||
if (code === "F4") return "party-recall";
|
||||
if (code === "Space") return "jump";
|
||||
if (code === "Tab") return shiftKey ? "target-previous" : "target-next";
|
||||
return null;
|
||||
}
|
||||
@@ -202,6 +219,7 @@ function runCommand(command: GameplayInputCommand, nextActions: InputActions): v
|
||||
else if (command === "party-defend") nextActions.setPartyCommand("defend");
|
||||
else if (command === "party-stop") nextActions.setPartyCommand("stop");
|
||||
else if (command === "party-recall") nextActions.recallParty();
|
||||
else if (command === "jump") queueJumpRequest();
|
||||
else if (command === "target-clear") nextActions.clearTarget();
|
||||
}
|
||||
|
||||
@@ -303,6 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
return;
|
||||
}
|
||||
if (nextActions.gameplayActionsBlocked()) return;
|
||||
if (command === "jump") event.preventDefault();
|
||||
if (event.code === "Tab") event.preventDefault();
|
||||
runCommand(command, nextActions);
|
||||
};
|
||||
@@ -339,6 +358,7 @@ export function installInput(nextActions: InputActions): () => void {
|
||||
keyboardModifiers.clear();
|
||||
controllerL1 = false;
|
||||
controllerL2 = false;
|
||||
clearJumpRequest();
|
||||
mouseLookDragActive = false;
|
||||
previousMousePosition = null;
|
||||
nextActions.setActionLayer("primary");
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
partyCombatMovementGoal,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partySeparationGoal,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
stepPartyPosition,
|
||||
synchronizePartyBreadcrumbCursor,
|
||||
} from "./partyMovement";
|
||||
|
||||
describe("party world movement", () => {
|
||||
@@ -38,6 +45,89 @@ describe("party world movement", () => {
|
||||
expect(out).toEqual([12, 2, 17]);
|
||||
});
|
||||
|
||||
it("builds stable and distinct bounded movement personalities", () => {
|
||||
const first = partyMovementProfile("member-a", 0);
|
||||
const repeat = partyMovementProfile("member-a", 0);
|
||||
const second = partyMovementProfile("member-b", 1);
|
||||
expect(first).toEqual(repeat);
|
||||
expect(second).not.toEqual(first);
|
||||
for (const profile of [first, second]) {
|
||||
expect(Math.abs(profile.followDistanceJitter)).toBeLessThanOrEqual(0.35);
|
||||
expect(Math.abs(profile.lateralOffset)).toBeLessThanOrEqual(0.65);
|
||||
expect(profile.comfortSlack).toBeGreaterThanOrEqual(0.35);
|
||||
expect(profile.comfortSlack).toBeLessThanOrEqual(0.85);
|
||||
expect(profile.speedMultiplier).toBeGreaterThanOrEqual(0.94);
|
||||
expect(profile.speedMultiplier).toBeLessThanOrEqual(1.06);
|
||||
expect(Math.abs(profile.combatAngleOffset)).toBeLessThanOrEqual(50 * Math.PI / 180);
|
||||
}
|
||||
});
|
||||
|
||||
it("places natural followers inside personal spacing and movement bands", () => {
|
||||
const profile = partyMovementProfile("member-c", 2);
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
partyNaturalFollowPosition([10, 2, 20], 0, 1, 3.75, profile, 5_000, out);
|
||||
expect(out[1]).toBe(2);
|
||||
expect(Math.abs(out[0] - 10)).toBeLessThanOrEqual(0.83);
|
||||
expect(20 - out[2]).toBeGreaterThanOrEqual(3.4);
|
||||
expect(20 - out[2]).toBeLessThanOrEqual(4.1);
|
||||
expect(partyShouldMoveToComfortTarget(out, out, false, profile.comfortSlack)).toBe(false);
|
||||
expect(partyShouldMoveToComfortTarget([0, 0, 0], out, false, profile.comfortSlack)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps personalized combat bands inside role-safe bounds", () => {
|
||||
const profiles = [
|
||||
partyMovementProfile("ranged-a", 1),
|
||||
partyMovementProfile("ranged-b", 2),
|
||||
];
|
||||
for (const profile of profiles) {
|
||||
const ranged = personalizedPartyCombatRangeBand({
|
||||
minimum: 10,
|
||||
preferred: 14.5,
|
||||
maximum: 18.1,
|
||||
}, profile);
|
||||
expect(ranged.minimum).toBeGreaterThanOrEqual(9);
|
||||
expect(ranged.maximum).toBeLessThanOrEqual(18.1);
|
||||
expect(ranged.preferred).toBeGreaterThan(ranged.minimum);
|
||||
expect(ranged.preferred).toBeLessThan(ranged.maximum);
|
||||
|
||||
const melee = personalizedPartyCombatRangeBand({
|
||||
minimum: 0,
|
||||
preferred: 3.3,
|
||||
maximum: 3.55,
|
||||
}, profile);
|
||||
expect(melee.minimum).toBe(0);
|
||||
expect(melee.maximum).toBeLessThanOrEqual(3.55);
|
||||
expect(melee.preferred).toBeLessThan(melee.maximum);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a stable combat bearing and separates overlapping allies", () => {
|
||||
const out: [number, number, number] = [0, 0, 0];
|
||||
expect(partyPersonalizedCombatMovementGoal(
|
||||
[20, 0, 0],
|
||||
[0, 0, 0],
|
||||
true,
|
||||
{ minimum: 10, preferred: 14, maximum: 18 },
|
||||
Math.PI / 2,
|
||||
false,
|
||||
out,
|
||||
)).toBe("approach");
|
||||
expect(out[0]).toBeCloseTo(14);
|
||||
expect(out[1]).toBe(0);
|
||||
expect(out[2]).toBeCloseTo(0);
|
||||
|
||||
const separated: [number, number, number] = [0, 0, 0];
|
||||
expect(partySeparationGoal(
|
||||
[1, 0, 1],
|
||||
[2, 0, 2],
|
||||
[[1, 0, 1]],
|
||||
Math.PI / 2,
|
||||
separated,
|
||||
)).toBe(true);
|
||||
expect(separated[0]).toBeGreaterThan(2);
|
||||
expect(separated[2]).toBeCloseTo(2);
|
||||
});
|
||||
|
||||
it("recalls only distant allies that should be moving and stopped making progress", () => {
|
||||
expect(partyMadeStuckProgress([0, 0, 0], [0.45, 4, 0])).toBe(true);
|
||||
expect(partyMadeStuckProgress([0, 0, 0], [0.44, 0, 0])).toBe(false);
|
||||
@@ -168,6 +258,14 @@ describe("party world movement", () => {
|
||||
expect(out).toEqual([20, 3, 4]);
|
||||
});
|
||||
|
||||
it("synchronizes direct followers to the matching safe trail point", () => {
|
||||
const trail = createPartyBreadcrumbTrail();
|
||||
for (let x = 0; x <= 6; x += 1) appendPartyBreadcrumb(trail, [x, 0, 0], 0.1, 20);
|
||||
const cursor = createPartyBreadcrumbCursor();
|
||||
synchronizePartyBreadcrumbCursor(trail, cursor, 2);
|
||||
expect(cursor.breadcrumbId).toBe(trail.points.find((point) => point.position[0] === 4)?.id);
|
||||
});
|
||||
|
||||
it("lets a distant follower rejoin a trail before it is long enough for formation spacing", () => {
|
||||
const trail = createPartyBreadcrumbTrail();
|
||||
const cursor = createPartyBreadcrumbCursor();
|
||||
|
||||
+221
-1
@@ -19,7 +19,72 @@ export interface PartyBreadcrumbCursor {
|
||||
breadcrumbId: number | null;
|
||||
}
|
||||
|
||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat";
|
||||
export type PartyCombatMovementIntent = "hold" | "approach" | "retreat" | "reposition";
|
||||
|
||||
export interface PartyMovementProfile {
|
||||
readonly followDistanceJitter: number;
|
||||
readonly lateralOffset: number;
|
||||
readonly comfortSlack: number;
|
||||
readonly speedMultiplier: number;
|
||||
readonly swayAmplitude: number;
|
||||
readonly swayPeriodMs: number;
|
||||
readonly swayPhase: number;
|
||||
readonly combatAngleOffset: number;
|
||||
readonly combatRangeBias: number;
|
||||
}
|
||||
|
||||
export interface PartyCombatRangeLike {
|
||||
readonly minimum: number;
|
||||
readonly preferred: number;
|
||||
readonly maximum: number;
|
||||
}
|
||||
|
||||
function stableHash(value: string): number {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function hashUnit(value: string): number {
|
||||
return stableHash(value) / 0xffff_ffff;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
/** Stable per-character preferences keep motion reproducible without looking cloned. */
|
||||
export function partyMovementProfile(memberId: string, livingRank: number): PartyMovementProfile {
|
||||
const rank = Math.max(0, Math.floor(Number.isFinite(livingRank) ? livingRank : 0));
|
||||
const prefix = `${memberId}|${rank}`;
|
||||
const lateralMagnitude = rank === 0
|
||||
? hashUnit(`${prefix}|leader-lateral`) * 0.3
|
||||
: 0.35 + hashUnit(`${prefix}|lateral`) * 0.3;
|
||||
const lateralSign = rank === 0
|
||||
? hashUnit(`${prefix}|leader-side`) < 0.5 ? -1 : 1
|
||||
: rank % 2 === 1 ? -1 : 1;
|
||||
const angleSlots = [0, -35, 35, -50, 50];
|
||||
const angleDegrees = clamp(
|
||||
(angleSlots[rank % angleSlots.length] ?? 0)
|
||||
+ (hashUnit(`${prefix}|angle-jitter`) - 0.5) * 10,
|
||||
-50,
|
||||
50,
|
||||
);
|
||||
return {
|
||||
followDistanceJitter: (hashUnit(`${prefix}|distance`) - 0.5) * 0.7,
|
||||
lateralOffset: lateralSign * lateralMagnitude,
|
||||
comfortSlack: 0.35 + hashUnit(`${prefix}|comfort`) * 0.5,
|
||||
speedMultiplier: 0.94 + hashUnit(`${prefix}|speed`) * 0.12,
|
||||
swayAmplitude: 0.08 + hashUnit(`${prefix}|sway-amplitude`) * 0.1,
|
||||
swayPeriodMs: 6_000 + hashUnit(`${prefix}|sway-period`) * 4_000,
|
||||
swayPhase: hashUnit(`${prefix}|sway-phase`) * Math.PI * 2,
|
||||
combatAngleOffset: angleDegrees * Math.PI / 180,
|
||||
combatRangeBias: hashUnit(`${prefix}|combat-range`) * 2 - 1,
|
||||
};
|
||||
}
|
||||
|
||||
export const PARTY_STUCK_RECALL_DISTANCE = 12;
|
||||
export const PARTY_STUCK_RECALL_DELAY_MS = 3_500;
|
||||
@@ -66,6 +131,25 @@ export function createPartyBreadcrumbCursor(): PartyBreadcrumbCursor {
|
||||
return { breadcrumbId: null };
|
||||
}
|
||||
|
||||
export function synchronizePartyBreadcrumbCursor(
|
||||
trail: PartyBreadcrumbTrail,
|
||||
cursor: PartyBreadcrumbCursor,
|
||||
trailingDistance: number,
|
||||
): void {
|
||||
const latest = trail.points[trail.points.length - 1];
|
||||
if (!latest) {
|
||||
cursor.breadcrumbId = null;
|
||||
return;
|
||||
}
|
||||
const maximumDistance = latest.distance - Math.max(0, trailingDistance);
|
||||
let target = trail.points[0];
|
||||
for (const point of trail.points) {
|
||||
if (point.distance > maximumDistance + EPSILON) break;
|
||||
target = point;
|
||||
}
|
||||
cursor.breadcrumbId = target.id;
|
||||
}
|
||||
|
||||
export function resetPartyBreadcrumbTrail(
|
||||
trail: PartyBreadcrumbTrail,
|
||||
position?: PartyWorldPosition,
|
||||
@@ -271,6 +355,114 @@ export function partyFormationPosition(
|
||||
out[2] = anchor[2] + rightZ * lateral - normalizedForwardZ * trailing;
|
||||
}
|
||||
|
||||
export function partyNaturalFollowPosition(
|
||||
anchor: PartyWorldPosition,
|
||||
forwardX: number,
|
||||
forwardZ: number,
|
||||
baseTrailingDistance: number,
|
||||
profile: PartyMovementProfile,
|
||||
nowMs: number,
|
||||
out: MutablePartyWorldPosition,
|
||||
): void {
|
||||
const trailing = Math.max(0.9, baseTrailingDistance + profile.followDistanceJitter);
|
||||
const safeNow = Number.isFinite(nowMs) ? nowMs : 0;
|
||||
const sway = Math.sin(
|
||||
safeNow / Math.max(1, profile.swayPeriodMs) * Math.PI * 2 + profile.swayPhase,
|
||||
) * profile.swayAmplitude;
|
||||
partyFormationPosition(
|
||||
anchor,
|
||||
forwardX,
|
||||
forwardZ,
|
||||
profile.lateralOffset + sway,
|
||||
trailing,
|
||||
out,
|
||||
);
|
||||
}
|
||||
|
||||
/** Start outside a personal comfort radius, then finish close enough to avoid jitter. */
|
||||
export function partyShouldMoveToComfortTarget(
|
||||
current: PartyWorldPosition,
|
||||
target: PartyWorldPosition,
|
||||
wasMoving: boolean,
|
||||
comfortSlack: number,
|
||||
): boolean {
|
||||
const distance = Math.hypot(
|
||||
target[0] - current[0],
|
||||
target[1] - current[1],
|
||||
target[2] - current[2],
|
||||
);
|
||||
if (!Number.isFinite(distance)) return false;
|
||||
const startDistance = Math.max(0.18, Number.isFinite(comfortSlack) ? comfortSlack : 0.35);
|
||||
return distance > (wasMoving ? 0.18 : startDistance);
|
||||
}
|
||||
|
||||
export function personalizedPartyCombatRangeBand(
|
||||
base: PartyCombatRangeLike,
|
||||
profile: PartyMovementProfile,
|
||||
): PartyCombatRangeLike {
|
||||
if (base.minimum > 0) {
|
||||
const minimum = clamp(base.minimum + profile.combatRangeBias * 0.75, 9, 11);
|
||||
const maximum = clamp(base.maximum + profile.combatRangeBias * 0.35, 17, 18.1);
|
||||
return {
|
||||
minimum,
|
||||
preferred: clamp(
|
||||
base.preferred + profile.combatRangeBias * 1.25,
|
||||
minimum + 0.75,
|
||||
maximum - 0.5,
|
||||
),
|
||||
maximum,
|
||||
};
|
||||
}
|
||||
const maximum = Math.max(2.4, base.maximum - Math.abs(profile.combatRangeBias) * 0.2);
|
||||
return {
|
||||
minimum: 0,
|
||||
preferred: clamp(
|
||||
base.preferred + profile.combatRangeBias * 0.3,
|
||||
2.35,
|
||||
maximum - 0.12,
|
||||
),
|
||||
maximum,
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds a bounded local repulsion so allies do not occupy the same point. */
|
||||
export function partySeparationGoal(
|
||||
current: PartyWorldPosition,
|
||||
desired: PartyWorldPosition,
|
||||
peers: readonly PartyWorldPosition[],
|
||||
fallbackAngle: number,
|
||||
out: MutablePartyWorldPosition,
|
||||
separationDistance = 0.85,
|
||||
maximumCorrection = 0.35,
|
||||
): boolean {
|
||||
let offsetX = 0;
|
||||
let offsetZ = 0;
|
||||
const safeSeparation = Math.max(EPSILON, separationDistance);
|
||||
for (const peer of peers) {
|
||||
const dx = current[0] - peer[0];
|
||||
const dz = current[2] - peer[2];
|
||||
const distance = Math.hypot(dx, dz);
|
||||
if (!Number.isFinite(distance) || distance >= safeSeparation) continue;
|
||||
const weight = (safeSeparation - distance) / safeSeparation;
|
||||
if (distance <= EPSILON) {
|
||||
offsetX += Math.sin(fallbackAngle) * weight;
|
||||
offsetZ += Math.cos(fallbackAngle) * weight;
|
||||
} else {
|
||||
offsetX += dx / distance * weight;
|
||||
offsetZ += dz / distance * weight;
|
||||
}
|
||||
}
|
||||
const length = Math.hypot(offsetX, offsetZ);
|
||||
out[0] = desired[0];
|
||||
out[1] = desired[1];
|
||||
out[2] = desired[2];
|
||||
if (length <= EPSILON) return false;
|
||||
const correction = Math.min(Math.max(0, maximumCorrection), length);
|
||||
out[0] += offsetX / length * correction;
|
||||
out[2] += offsetZ / length * correction;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses an individual combat destination. Melee only closes distance;
|
||||
* ranged attackers also back out when a target collapses their preferred band.
|
||||
@@ -323,3 +515,31 @@ export function partyCombatMovementGoal(
|
||||
out[2] = target[2] + awayZ / planarLength * preferred;
|
||||
return "retreat";
|
||||
}
|
||||
|
||||
export function partyPersonalizedCombatMovementGoal(
|
||||
current: PartyWorldPosition,
|
||||
target: PartyWorldPosition,
|
||||
targetVisible: boolean,
|
||||
rangeBand: PartyCombatRangeLike,
|
||||
preferredBearing: number,
|
||||
forceReposition: boolean,
|
||||
out: MutablePartyWorldPosition,
|
||||
): PartyCombatMovementIntent {
|
||||
const intent = partyCombatMovementGoal(
|
||||
current,
|
||||
target,
|
||||
targetVisible,
|
||||
rangeBand.minimum,
|
||||
rangeBand.preferred,
|
||||
rangeBand.maximum,
|
||||
out,
|
||||
Math.sin(preferredBearing),
|
||||
Math.cos(preferredBearing),
|
||||
);
|
||||
if (!targetVisible || (intent === "hold" && !forceReposition)) return intent;
|
||||
|
||||
out[0] = target[0] + Math.sin(preferredBearing) * rangeBand.preferred;
|
||||
out[1] = target[1];
|
||||
out[2] = target[2] + Math.cos(preferredBearing) * rangeBand.preferred;
|
||||
return intent === "hold" ? "reposition" : intent;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
PLAYER_JUMP_BUFFER_MS,
|
||||
PLAYER_JUMP_COYOTE_MS,
|
||||
bufferPlayerJump,
|
||||
cancelBufferedPlayerJump,
|
||||
characterVerticalMotion,
|
||||
createPlayerJumpTiming,
|
||||
isWalkableGroundHit,
|
||||
updatePlayerJumpTiming,
|
||||
} from "./playerJump";
|
||||
|
||||
describe("player jump timing", () => {
|
||||
it("accepts only a nearby upward-facing ground hit", () => {
|
||||
expect(isWalkableGroundHit({
|
||||
timeOfImpact: 0.9,
|
||||
normal: { y: PLAYER_GROUND_MIN_NORMAL_Y },
|
||||
}, 0.95)).toBe(true);
|
||||
expect(isWalkableGroundHit({ timeOfImpact: 0.96, normal: { y: 1 } }, 0.95)).toBe(false);
|
||||
expect(isWalkableGroundHit({ timeOfImpact: 0.4, normal: { y: 0.2 } }, 0.95)).toBe(false);
|
||||
});
|
||||
|
||||
it("buffers a press shortly before landing", () => {
|
||||
const timing = createPlayerJumpTiming();
|
||||
bufferPlayerJump(timing, 1_000);
|
||||
expect(updatePlayerJumpTiming(timing, 1_000 + PLAYER_JUMP_BUFFER_MS - 1, true, -2)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows coyote time after leaving a ledge", () => {
|
||||
const timing = createPlayerJumpTiming(2_000);
|
||||
bufferPlayerJump(timing, 2_000 + PLAYER_JUMP_COYOTE_MS - 1);
|
||||
expect(updatePlayerJumpTiming(
|
||||
timing,
|
||||
2_000 + PLAYER_JUMP_COYOTE_MS - 1,
|
||||
false,
|
||||
-0.2,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("cancels buffered input while gameplay is blocked", () => {
|
||||
const timing = createPlayerJumpTiming(2_500);
|
||||
bufferPlayerJump(timing, 2_500);
|
||||
cancelBufferedPlayerJump(timing);
|
||||
expect(updatePlayerJumpTiming(timing, 2_500, true, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not double jump until a real landing", () => {
|
||||
const timing = createPlayerJumpTiming(3_000);
|
||||
bufferPlayerJump(timing, 3_000);
|
||||
expect(updatePlayerJumpTiming(timing, 3_000, true, 0)).toBe(true);
|
||||
|
||||
bufferPlayerJump(timing, 3_040);
|
||||
expect(updatePlayerJumpTiming(timing, 3_040, true, 6.2)).toBe(false);
|
||||
expect(updatePlayerJumpTiming(timing, 3_300, false, -2)).toBe(false);
|
||||
bufferPlayerJump(timing, 3_550);
|
||||
expect(updatePlayerJumpTiming(timing, 3_600, true, -1)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports rising, falling, landing, and grounded presentation phases", () => {
|
||||
expect(characterVerticalMotion(false, 2, 100, 0)).toBe("rising");
|
||||
expect(characterVerticalMotion(false, -0.1, 100, 0)).toBe("falling");
|
||||
expect(characterVerticalMotion(true, 0, 100, 150)).toBe("landing");
|
||||
expect(characterVerticalMotion(true, 0, 150, 150)).toBe("grounded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
export const PLAYER_JUMP_VELOCITY = 7.2;
|
||||
export const PLAYER_JUMP_COYOTE_MS = 100;
|
||||
export const PLAYER_JUMP_BUFFER_MS = 120;
|
||||
export const PLAYER_JUMP_LANDING_UNLOCK_MS = 80;
|
||||
export const PLAYER_LANDING_PRESENTATION_MS = 180;
|
||||
export const PLAYER_GROUND_PROBE_DISTANCE = 0.14;
|
||||
export const PLAYER_GROUND_MIN_NORMAL_Y = 0.55;
|
||||
|
||||
export type CharacterVerticalMotion = "grounded" | "rising" | "falling" | "landing";
|
||||
|
||||
export interface PlayerJumpTiming {
|
||||
lastGroundedAtMs: number;
|
||||
bufferedUntilMs: number;
|
||||
jumpedAtMs: number;
|
||||
lockedUntilLanding: boolean;
|
||||
}
|
||||
|
||||
export interface GroundProbeHit {
|
||||
readonly timeOfImpact: number;
|
||||
readonly normal: { readonly y: number };
|
||||
}
|
||||
|
||||
export function createPlayerJumpTiming(nowMs = Number.NEGATIVE_INFINITY): PlayerJumpTiming {
|
||||
return {
|
||||
lastGroundedAtMs: nowMs,
|
||||
bufferedUntilMs: Number.NEGATIVE_INFINITY,
|
||||
jumpedAtMs: Number.NEGATIVE_INFINITY,
|
||||
lockedUntilLanding: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function bufferPlayerJump(timing: PlayerJumpTiming, requestedAtMs: number): void {
|
||||
if (!Number.isFinite(requestedAtMs)) return;
|
||||
timing.bufferedUntilMs = requestedAtMs + PLAYER_JUMP_BUFFER_MS;
|
||||
}
|
||||
|
||||
export function cancelBufferedPlayerJump(timing: PlayerJumpTiming): void {
|
||||
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function isWalkableGroundHit(
|
||||
hit: GroundProbeHit | null | undefined,
|
||||
maximumTimeOfImpact: number,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
hit
|
||||
&& Number.isFinite(hit.timeOfImpact)
|
||||
&& hit.timeOfImpact >= 0
|
||||
&& hit.timeOfImpact <= maximumTimeOfImpact
|
||||
&& Number.isFinite(hit.normal.y)
|
||||
&& hit.normal.y >= PLAYER_GROUND_MIN_NORMAL_Y,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the timing latch and consumes a buffered jump exactly once. The
|
||||
* upward-velocity guard prevents the ground probe from unlocking the latch on
|
||||
* the first few frames after launch while the capsule is still near the floor.
|
||||
*/
|
||||
export function updatePlayerJumpTiming(
|
||||
timing: PlayerJumpTiming,
|
||||
nowMs: number,
|
||||
grounded: boolean,
|
||||
verticalVelocity: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(nowMs)) return false;
|
||||
|
||||
if (grounded && verticalVelocity <= 0.5) {
|
||||
timing.lastGroundedAtMs = nowMs;
|
||||
if (
|
||||
timing.lockedUntilLanding
|
||||
&& nowMs - timing.jumpedAtMs >= PLAYER_JUMP_LANDING_UNLOCK_MS
|
||||
) timing.lockedUntilLanding = false;
|
||||
}
|
||||
|
||||
const buffered = timing.bufferedUntilMs >= nowMs;
|
||||
const insideCoyoteWindow = nowMs - timing.lastGroundedAtMs <= PLAYER_JUMP_COYOTE_MS;
|
||||
if (timing.lockedUntilLanding || !buffered || !insideCoyoteWindow) return false;
|
||||
|
||||
timing.lockedUntilLanding = true;
|
||||
timing.jumpedAtMs = nowMs;
|
||||
timing.bufferedUntilMs = Number.NEGATIVE_INFINITY;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function characterVerticalMotion(
|
||||
grounded: boolean,
|
||||
verticalVelocity: number,
|
||||
nowMs: number,
|
||||
landingUntilMs: number,
|
||||
): CharacterVerticalMotion {
|
||||
if (grounded) return nowMs < landingUntilMs ? "landing" : "grounded";
|
||||
return verticalVelocity > 0.15 ? "rising" : "falling";
|
||||
}
|
||||
@@ -147,7 +147,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 30_000);
|
||||
}, 60_000);
|
||||
|
||||
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
|
||||
const recipes = await Promise.all(
|
||||
@@ -305,7 +305,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
||||
poseOnly: 1,
|
||||
proxy: 0,
|
||||
});
|
||||
}, 30_000);
|
||||
}, 60_000);
|
||||
|
||||
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
|
||||
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
|
||||
|
||||
@@ -57,12 +57,43 @@ describe("game store", () => {
|
||||
expect(state.areaName).toContain("Entrance");
|
||||
});
|
||||
|
||||
it("keeps a grounded navigation anchor while the physical player is airborne", () => {
|
||||
useGameStore.getState().updatePlayerSnapshot({
|
||||
position: [164, -73.66, 132],
|
||||
yaw: -2,
|
||||
grounded: true,
|
||||
});
|
||||
useGameStore.getState().updatePlayerSnapshot({
|
||||
position: [166, -72.2, 133],
|
||||
yaw: -2,
|
||||
grounded: false,
|
||||
});
|
||||
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
playerPosition: [166, -72.2, 133],
|
||||
playerNavigationPosition: [164, -73.66, 132],
|
||||
playerGrounded: false,
|
||||
});
|
||||
|
||||
useGameStore.getState().updatePlayerSnapshot({
|
||||
position: [167, -73.66, 134],
|
||||
yaw: -2,
|
||||
grounded: true,
|
||||
});
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
playerNavigationPosition: [167, -73.66, 134],
|
||||
playerGrounded: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reset restores the authoritative transformed entrance", () => {
|
||||
useGameStore.getState().setActionLayer("tertiary");
|
||||
useGameStore.getState().updatePlayerSnapshot({ position: [0, 0, 0], yaw: 0, distanceDelta: 8 });
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
const state = useGameStore.getState();
|
||||
expect(state.playerPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
|
||||
expect(state.playerNavigationPosition).toEqual(DUNGEON_MANIFEST.entrance.footPosition);
|
||||
expect(state.playerGrounded).toBe(true);
|
||||
expect(state.cameraYaw).toBe(DUNGEON_MANIFEST.entrance.yaw);
|
||||
expect(state.distanceTravelled).toBe(0);
|
||||
expect(state.actionLayer).toBe("primary");
|
||||
|
||||
+14
-1
@@ -13,6 +13,7 @@ interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
yaw: number;
|
||||
distanceDelta?: number;
|
||||
grounded?: boolean;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
@@ -31,6 +32,8 @@ export interface GameState {
|
||||
assetStatus: AssetStatus;
|
||||
assetMessage: string | null;
|
||||
playerPosition: Vector3Tuple;
|
||||
playerNavigationPosition: Vector3Tuple;
|
||||
playerGrounded: boolean;
|
||||
cameraYaw: number;
|
||||
areaName: string;
|
||||
distanceTravelled: number;
|
||||
@@ -77,6 +80,8 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
assetStatus: "checking",
|
||||
assetMessage: null,
|
||||
playerPosition: entrance.footPosition,
|
||||
playerNavigationPosition: entrance.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: entrance.yaw,
|
||||
areaName: resolveAreaNameForDungeon(initialDungeon, entrance.footPosition),
|
||||
distanceTravelled: 0,
|
||||
@@ -106,6 +111,8 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
assetStatus: "checking",
|
||||
assetMessage: null,
|
||||
playerPosition: activeSpawn.footPosition,
|
||||
playerNavigationPosition: activeSpawn.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: activeSpawn.yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, activeSpawn.footPosition),
|
||||
distanceTravelled: 0,
|
||||
@@ -175,10 +182,12 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
if (isStaleLoadingWrite) return state;
|
||||
return { assetStatus, assetMessage };
|
||||
}),
|
||||
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0 }) => set((state) => {
|
||||
updatePlayerSnapshot: ({ position, yaw, distanceDelta = 0, grounded = true }) => set((state) => {
|
||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||
return {
|
||||
playerPosition: position,
|
||||
playerNavigationPosition: grounded ? position : state.playerNavigationPosition,
|
||||
playerGrounded: grounded,
|
||||
cameraYaw: yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, position),
|
||||
distanceTravelled: state.distanceTravelled + Math.max(0, distanceDelta),
|
||||
@@ -193,6 +202,8 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
mapOpen: false,
|
||||
cameraLookActive: false,
|
||||
playerPosition: state.activeSpawn.footPosition,
|
||||
playerNavigationPosition: state.activeSpawn.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: state.activeSpawn.yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
|
||||
distanceTravelled: 0,
|
||||
@@ -213,6 +224,8 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
mapOpen: false,
|
||||
cameraLookActive: false,
|
||||
playerPosition: activeEntrance.footPosition,
|
||||
playerNavigationPosition: activeEntrance.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: activeEntrance.yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, activeEntrance.footPosition),
|
||||
distanceTravelled: 0,
|
||||
|
||||
@@ -77,6 +77,26 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect(shield.effects.some((effect) => effect.kind === "shield")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps periodic damage and healing auras out of the generic HUD strip", () => {
|
||||
const pain = byId("wow335-priest-shadow-word-pain");
|
||||
const painAura = effectsOfKind(pain, "apply-aura")
|
||||
.find((effect) => effect.sourceAuraType === 3);
|
||||
const renew = byId("wow335-priest-renew");
|
||||
const renewAura = effectsOfKind(renew, "apply-aura")
|
||||
.find((effect) => effect.sourceAuraType === 8);
|
||||
|
||||
expect(painAura?.aura).toMatchObject({
|
||||
disposition: "debuff",
|
||||
hideFromAuraStrip: true,
|
||||
});
|
||||
expect(renewAura?.aura).toMatchObject({
|
||||
disposition: "buff",
|
||||
hideFromAuraStrip: true,
|
||||
});
|
||||
expect(effectsOfKind(pain, "dot")).toHaveLength(1);
|
||||
expect(effectsOfKind(renew, "hot")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("maps cleanse, purge, and dual-purpose dispel categories", () => {
|
||||
expect(effectsOfKind(byId("wow335-paladin-cleanse"), "dispel")).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ mode: "cleanse", relationship: "friendly", categories: ["magic"] }),
|
||||
|
||||
@@ -367,6 +367,9 @@ function namedAuraForEffect(
|
||||
id: `wow335:${rank.spellId}:effect:${effectIndex}:aura:${effect.auraType}`,
|
||||
name: `${abilityName} (DBC ${rank.spellId}, Aura ${effect.auraType})`,
|
||||
disposition: effectTargetsHostile(effect) ? "debuff" : "buff",
|
||||
...([3, 8].includes(effect.auraType) && effect.periodMs > 0
|
||||
? { hideFromAuraStrip: true }
|
||||
: {}),
|
||||
durationMs,
|
||||
maxStacks: 1,
|
||||
stackBehavior: "refresh",
|
||||
|
||||
+268
-77
@@ -31,14 +31,21 @@ import {
|
||||
createPartyBreadcrumbCursor,
|
||||
createPartyBreadcrumbTrail,
|
||||
nextPartyBreadcrumbFollowWaypoint,
|
||||
partyCombatMovementGoal,
|
||||
partyFormationPosition,
|
||||
partyMadeStuckProgress,
|
||||
partyMovementProfile,
|
||||
partyNaturalFollowPosition,
|
||||
partyPersonalizedCombatMovementGoal,
|
||||
partyPresentationY,
|
||||
partySeparationGoal,
|
||||
partyShouldRecallForStuck,
|
||||
partyShouldMoveToComfortTarget,
|
||||
personalizedPartyCombatRangeBand,
|
||||
resetPartyBreadcrumbTrail,
|
||||
stepPartyPosition,
|
||||
synchronizePartyBreadcrumbCursor,
|
||||
type PartyBreadcrumbCursor,
|
||||
type PartyMovementProfile,
|
||||
} from "../game/partyMovement";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
@@ -82,13 +89,7 @@ const PARTY_MAX_FLOOR_DELTA = 0.55;
|
||||
const PARTY_MAX_GROUND_CORRECTION = 0.85;
|
||||
const EMPTY_NAVIGATION_LINKS = [] as const;
|
||||
|
||||
/** Lateral and trailing offsets behind the current squad anchor. */
|
||||
const PARTY_FORMATION: readonly (readonly [number, number])[] = [
|
||||
[0, 1.45],
|
||||
[0, 2.6],
|
||||
[0, 3.75],
|
||||
[0, 4.9],
|
||||
];
|
||||
const PARTY_PLAYER_TRAILING_DISTANCE: readonly number[] = [1.45, 2.6, 3.75, 4.9];
|
||||
const PARTY_ENTRANCE_FORWARD_DISTANCE: readonly number[] = [2.4, 1.8, 1.2, 0.6];
|
||||
|
||||
const PARTY_TRAIL_DISTANCE: readonly number[] = [0, 1.15, 2.3, 3.45];
|
||||
@@ -106,6 +107,11 @@ interface PartyStuckProgress {
|
||||
lastProgressAt: number;
|
||||
}
|
||||
|
||||
interface PartyCombatAnchor {
|
||||
readonly targetId: string;
|
||||
readonly bearing: number;
|
||||
}
|
||||
|
||||
type PartyAvatarMetricKey = `${RaceId}:${GenderId}`;
|
||||
|
||||
/** Bind-pose bounds measured from the shipped wow335a rigs, in local meters. */
|
||||
@@ -418,6 +424,9 @@ function PartyActors({
|
||||
const lastCommandRef = useRef(usePartyStore.getState().command);
|
||||
const lastRecallRevisionRef = useRef(usePartyStore.getState().recallRevision);
|
||||
const stuckProgressRef = useRef(new Map<string, PartyStuckProgress>());
|
||||
const movementProfilesRef = useRef(new Map<string, PartyMovementProfile>());
|
||||
const directFollowMovingRef = useRef(new Map<string, boolean>());
|
||||
const combatAnchorsRef = useRef(new Map<string, PartyCombatAnchor>());
|
||||
const memberIdKey = members.map((member) => member.id).join("|");
|
||||
const teleportPartyToPlayer = useCallback((player: PartyWorldPosition, now: number): void => {
|
||||
const party = usePartyStore.getState();
|
||||
@@ -448,6 +457,8 @@ function PartyActors({
|
||||
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
combatRoutesRef.current.clear();
|
||||
directFollowMovingRef.current.clear();
|
||||
combatAnchorsRef.current.clear();
|
||||
followerCursorsRef.current.clear();
|
||||
lastActiveTargetIdRef.current = party.activeMobId;
|
||||
lastLeaderWaypointRef.current = null;
|
||||
@@ -473,6 +484,17 @@ function PartyActors({
|
||||
for (const id of stuckProgressRef.current.keys()) {
|
||||
if (!activeIds.has(id)) stuckProgressRef.current.delete(id);
|
||||
}
|
||||
for (const id of directFollowMovingRef.current.keys()) {
|
||||
if (!activeIds.has(id)) directFollowMovingRef.current.delete(id);
|
||||
}
|
||||
for (const id of combatAnchorsRef.current.keys()) {
|
||||
if (!activeIds.has(id)) combatAnchorsRef.current.delete(id);
|
||||
}
|
||||
for (const key of movementProfilesRef.current.keys()) {
|
||||
if (![...activeIds].some((id) => key.startsWith(`${id}|`))) {
|
||||
movementProfilesRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
const cleanups = members.map((member, index) => {
|
||||
const position = positionsRef.current.get(member.id) ?? initialMemberPosition(index);
|
||||
positionsRef.current.set(member.id, position);
|
||||
@@ -534,6 +556,8 @@ function PartyActors({
|
||||
trailLeaderIdRef.current = firstLiving?.id ?? null;
|
||||
followerCursorsRef.current.clear();
|
||||
combatRoutesRef.current.clear();
|
||||
directFollowMovingRef.current.clear();
|
||||
combatAnchorsRef.current.clear();
|
||||
lastActiveTargetIdRef.current = null;
|
||||
lastLeaderWaypointRef.current = null;
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
@@ -561,6 +585,8 @@ function PartyActors({
|
||||
if (leaderPosition) resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition);
|
||||
followerCursorsRef.current.clear();
|
||||
stuckProgressRef.current.clear();
|
||||
directFollowMovingRef.current.clear();
|
||||
combatAnchorsRef.current.clear();
|
||||
}, [navigationGraph]);
|
||||
|
||||
// Time spent in a pause/menu must not count toward the stuck watchdog.
|
||||
@@ -575,24 +601,39 @@ function PartyActors({
|
||||
const party = usePartyStore.getState();
|
||||
if (!party.members.length) return;
|
||||
const game = useGameStore.getState();
|
||||
const player = game.playerPosition;
|
||||
appendPartyBreadcrumb(playerTrailRef.current, player);
|
||||
const playerNavigation = game.playerNavigationPosition;
|
||||
appendPartyBreadcrumb(playerTrailRef.current, playerNavigation);
|
||||
const routeNow = clock.elapsedTime * 1_000;
|
||||
const playerTrail = playerTrailRef.current.points;
|
||||
const latestPlayerTrailPoint = playerTrail[playerTrail.length - 1];
|
||||
const previousPlayerTrailPoint = playerTrail[playerTrail.length - 2];
|
||||
let playerForwardX = Math.sin(game.cameraYaw);
|
||||
let playerForwardZ = Math.cos(game.cameraYaw);
|
||||
if (latestPlayerTrailPoint && previousPlayerTrailPoint) {
|
||||
const trailX = latestPlayerTrailPoint.position[0] - previousPlayerTrailPoint.position[0];
|
||||
const trailZ = latestPlayerTrailPoint.position[2] - previousPlayerTrailPoint.position[2];
|
||||
if (Math.hypot(trailX, trailZ) > 0.05) {
|
||||
playerForwardX = trailX;
|
||||
playerForwardZ = trailZ;
|
||||
}
|
||||
}
|
||||
if (lastRecallRevisionRef.current !== party.recallRevision) {
|
||||
lastRecallRevisionRef.current = party.recallRevision;
|
||||
teleportPartyToPlayer(player, routeNow);
|
||||
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||
return;
|
||||
}
|
||||
if (lastCommandRef.current !== party.command) {
|
||||
lastCommandRef.current = party.command;
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
combatRoutesRef.current.clear();
|
||||
directFollowMovingRef.current.clear();
|
||||
combatAnchorsRef.current.clear();
|
||||
followerCursorsRef.current.clear();
|
||||
const livingLeader = party.members.find((member) => member.health > 0);
|
||||
const livingLeaderPosition = livingLeader ? positionsRef.current.get(livingLeader.id) : null;
|
||||
resetPartyBreadcrumbTrail(
|
||||
leaderTrailRef.current,
|
||||
livingLeaderPosition ?? player,
|
||||
livingLeaderPosition ?? playerNavigation,
|
||||
);
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
@@ -602,9 +643,29 @@ function PartyActors({
|
||||
const desired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const nextPosition: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const leaderDestination: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const naturalFollowTarget: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const separatedDesired: MutablePartyWorldPosition = [0, 0, 0];
|
||||
const livingIndices = party.members
|
||||
.map((member, index) => member.health > 0 ? index : -1)
|
||||
.filter((index) => index >= 0);
|
||||
const livingIds = new Set(livingIndices.map((index) => party.members[index].id));
|
||||
const movementProfileFor = (memberId: string, memberIndex: number): PartyMovementProfile => {
|
||||
const livingRank = Math.max(0, livingIndices.indexOf(memberIndex));
|
||||
const key = `${memberId}|${livingRank}`;
|
||||
let profile = movementProfilesRef.current.get(key);
|
||||
if (!profile) {
|
||||
profile = partyMovementProfile(memberId, livingRank);
|
||||
movementProfilesRef.current.set(key, profile);
|
||||
}
|
||||
return profile;
|
||||
};
|
||||
const peerPositionsFor = (memberId: string): PartyWorldPosition[] => {
|
||||
const peers: PartyWorldPosition[] = [];
|
||||
for (const [id, position] of positionsRef.current) {
|
||||
if (id !== memberId && livingIds.has(id)) peers.push(position);
|
||||
}
|
||||
return peers;
|
||||
};
|
||||
let leaderHasWaypoint = false;
|
||||
let leaderMovementExpected = false;
|
||||
let leaderFollowingPlayer = false;
|
||||
@@ -623,7 +684,7 @@ function PartyActors({
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
combatRoutesRef.current.clear();
|
||||
followerCursorsRef.current.clear();
|
||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? player);
|
||||
resetPartyBreadcrumbTrail(leaderTrailRef.current, leaderPosition ?? playerNavigation);
|
||||
lastLeaderWaypointRef.current = null;
|
||||
}
|
||||
if (leaderMember && leaderPosition && trailLeaderIdRef.current !== leaderMember.id) {
|
||||
@@ -631,6 +692,7 @@ function PartyActors({
|
||||
trailLeaderIdRef.current = leaderMember.id;
|
||||
followerCursorsRef.current.clear();
|
||||
combatRoutesRef.current.clear();
|
||||
combatAnchorsRef.current.clear();
|
||||
lastLeaderWaypointRef.current = null;
|
||||
routeRef.current = emptyPartyRouteState();
|
||||
}
|
||||
@@ -640,18 +702,32 @@ function PartyActors({
|
||||
let goalKey: string;
|
||||
let hasDestination = true;
|
||||
if (targetPosition && leaderTargetId) {
|
||||
const rangeBand = partyCombatRangeBand(leader);
|
||||
const actor = actorRefs.current[leaderIndex];
|
||||
const movementIntent = partyCombatMovementGoal(
|
||||
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||
const rangeBand = personalizedPartyCombatRangeBand(
|
||||
partyCombatRangeBand(leader),
|
||||
profile,
|
||||
);
|
||||
let combatAnchor = combatAnchorsRef.current.get(leader.id);
|
||||
if (!combatAnchor || combatAnchor.targetId !== leaderTargetId) {
|
||||
combatAnchor = {
|
||||
targetId: leaderTargetId,
|
||||
bearing: Math.atan2(
|
||||
leaderPosition[0] - targetPosition[0],
|
||||
leaderPosition[2] - targetPosition[2],
|
||||
) + profile.combatAngleOffset,
|
||||
};
|
||||
combatAnchorsRef.current.set(leader.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(leader.id);
|
||||
const crowded = peers.some((peer) => planarDistance(leaderPosition, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
leaderPosition,
|
||||
targetPosition,
|
||||
hasStaticLineOfSight(leaderPosition, targetPosition),
|
||||
rangeBand.minimum,
|
||||
rangeBand.preferred,
|
||||
rangeBand.maximum,
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
leaderDestination,
|
||||
actor ? Math.sin(actor.rotation.y) : 0,
|
||||
actor ? Math.cos(actor.rotation.y) : 1,
|
||||
);
|
||||
if (movementIntent === "hold") {
|
||||
hasDestination = false;
|
||||
@@ -677,10 +753,9 @@ function PartyActors({
|
||||
goalKey = `leader:${leader.id}|objective:${objective.id}`;
|
||||
} else {
|
||||
leaderFollowingPlayer = true;
|
||||
leaderDestination[0] = player[0];
|
||||
leaderDestination[1] = player[1];
|
||||
leaderDestination[2] = player[2];
|
||||
if (fullDistance(leaderPosition, player) <= PARTY_FORMATION[0][1]) hasDestination = false;
|
||||
leaderDestination[0] = playerNavigation[0];
|
||||
leaderDestination[1] = playerNavigation[1];
|
||||
leaderDestination[2] = playerNavigation[2];
|
||||
goalKey = `leader:${leader.id}|player`;
|
||||
}
|
||||
leaderMovementExpected = hasDestination
|
||||
@@ -689,28 +764,68 @@ function PartyActors({
|
||||
if (leaderFollowingPlayer) {
|
||||
routeRef.current = { ...emptyPartyRouteState(), goalKey };
|
||||
const cursor = leaderPlayerCursorRef.current;
|
||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
|
||||
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
|
||||
playerTrailRef.current,
|
||||
cursor,
|
||||
leaderPosition,
|
||||
PARTY_FORMATION[0][1],
|
||||
BREADCRUMB_REACHED_DISTANCE,
|
||||
desired,
|
||||
const profile = movementProfileFor(leader.id, leaderIndex);
|
||||
const baseTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[0];
|
||||
const personalizedTrailingDistance = Math.max(
|
||||
0.9,
|
||||
baseTrailingDistance + profile.followDistanceJitter,
|
||||
);
|
||||
if (
|
||||
leaderHasWaypoint
|
||||
&& requiresValidatedRejoin
|
||||
&& !directSegmentAllowed(leaderPosition, desired)
|
||||
) {
|
||||
cursor.breadcrumbId = previousBreadcrumbId;
|
||||
leaderHasWaypoint = false;
|
||||
leaderPlayerTrailBlocked = true;
|
||||
partyNaturalFollowPosition(
|
||||
playerNavigation,
|
||||
playerForwardX,
|
||||
playerForwardZ,
|
||||
baseTrailingDistance,
|
||||
profile,
|
||||
routeNow,
|
||||
leaderDestination,
|
||||
);
|
||||
if (directSegmentAllowed(leaderPosition, leaderDestination)) {
|
||||
const wasMoving = directFollowMovingRef.current.get(leader.id) ?? false;
|
||||
leaderHasWaypoint = partyShouldMoveToComfortTarget(
|
||||
leaderPosition,
|
||||
leaderDestination,
|
||||
wasMoving,
|
||||
profile.comfortSlack,
|
||||
);
|
||||
directFollowMovingRef.current.set(leader.id, leaderHasWaypoint);
|
||||
synchronizePartyBreadcrumbCursor(
|
||||
playerTrailRef.current,
|
||||
cursor,
|
||||
personalizedTrailingDistance,
|
||||
);
|
||||
if (leaderHasWaypoint) {
|
||||
desired[0] = leaderDestination[0];
|
||||
desired[1] = leaderDestination[1];
|
||||
desired[2] = leaderDestination[2];
|
||||
}
|
||||
leaderMovementExpected = leaderHasWaypoint;
|
||||
} else {
|
||||
directFollowMovingRef.current.set(leader.id, false);
|
||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||
|| !playerTrailRef.current.points.some((point) => point.id === cursor.breadcrumbId);
|
||||
leaderHasWaypoint = nextPartyBreadcrumbFollowWaypoint(
|
||||
playerTrailRef.current,
|
||||
cursor,
|
||||
leaderPosition,
|
||||
personalizedTrailingDistance,
|
||||
BREADCRUMB_REACHED_DISTANCE,
|
||||
desired,
|
||||
profile.comfortSlack,
|
||||
);
|
||||
if (
|
||||
leaderHasWaypoint
|
||||
&& requiresValidatedRejoin
|
||||
&& !directSegmentAllowed(leaderPosition, desired)
|
||||
) {
|
||||
cursor.breadcrumbId = previousBreadcrumbId;
|
||||
leaderHasWaypoint = false;
|
||||
leaderPlayerTrailBlocked = true;
|
||||
}
|
||||
leaderMovementExpected = leaderHasWaypoint
|
||||
|| planarDistance(leaderPosition, playerNavigation)
|
||||
> personalizedTrailingDistance + profile.comfortSlack;
|
||||
}
|
||||
leaderMovementExpected = leaderHasWaypoint
|
||||
|| planarDistance(leaderPosition, player) > PARTY_FORMATION[0][1] + 0.75;
|
||||
} else if (!hasDestination || fullDistance(leaderPosition, leaderDestination) <= 0.18) {
|
||||
if (
|
||||
routeRef.current.goalKey !== goalKey
|
||||
@@ -860,17 +975,32 @@ function PartyActors({
|
||||
}
|
||||
} else {
|
||||
if (memberTargetPosition && memberTargetId) {
|
||||
const rangeBand = partyCombatRangeBand(member);
|
||||
const movementIntent = partyCombatMovementGoal(
|
||||
const profile = movementProfileFor(member.id, index);
|
||||
const rangeBand = personalizedPartyCombatRangeBand(
|
||||
partyCombatRangeBand(member),
|
||||
profile,
|
||||
);
|
||||
let combatAnchor = combatAnchorsRef.current.get(member.id);
|
||||
if (!combatAnchor || combatAnchor.targetId !== memberTargetId) {
|
||||
combatAnchor = {
|
||||
targetId: memberTargetId,
|
||||
bearing: Math.atan2(
|
||||
current[0] - memberTargetPosition[0],
|
||||
current[2] - memberTargetPosition[2],
|
||||
) + profile.combatAngleOffset,
|
||||
};
|
||||
combatAnchorsRef.current.set(member.id, combatAnchor);
|
||||
}
|
||||
const peers = peerPositionsFor(member.id);
|
||||
const crowded = peers.some((peer) => planarDistance(current, peer) < 0.85);
|
||||
const movementIntent = partyPersonalizedCombatMovementGoal(
|
||||
current,
|
||||
memberTargetPosition,
|
||||
canSeeActiveTarget,
|
||||
rangeBand.minimum,
|
||||
rangeBand.preferred,
|
||||
rangeBand.maximum,
|
||||
rangeBand,
|
||||
combatAnchor.bearing,
|
||||
crowded,
|
||||
desired,
|
||||
Math.sin(actor.rotation.y),
|
||||
Math.cos(actor.rotation.y),
|
||||
);
|
||||
movementExpected = movementIntent !== "hold";
|
||||
if (movementIntent === "hold") {
|
||||
@@ -933,30 +1063,71 @@ function PartyActors({
|
||||
cursor = createPartyBreadcrumbCursor();
|
||||
followerCursorsRef.current.set(member.id, cursor);
|
||||
}
|
||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
||||
const livingRank = Math.max(1, livingIndices.indexOf(index));
|
||||
const trailingDistance = PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15;
|
||||
hasDesired = nextPartyBreadcrumbFollowWaypoint(
|
||||
leaderTrailRef.current,
|
||||
cursor,
|
||||
current,
|
||||
trailingDistance,
|
||||
BREADCRUMB_REACHED_DISTANCE,
|
||||
desired,
|
||||
const profile = movementProfileFor(member.id, index);
|
||||
const playerTrailingDistance = PARTY_PLAYER_TRAILING_DISTANCE[livingRank]
|
||||
?? 1.45 + livingRank * 1.15;
|
||||
const leaderTrailingDistance = Math.max(
|
||||
0.9,
|
||||
(PARTY_TRAIL_DISTANCE[livingRank] ?? livingRank * 1.15)
|
||||
+ profile.followDistanceJitter,
|
||||
);
|
||||
if (
|
||||
hasDesired
|
||||
&& requiresValidatedRejoin
|
||||
&& !directSegmentAllowed(current, desired)
|
||||
) {
|
||||
cursor.breadcrumbId = previousBreadcrumbId;
|
||||
hasDesired = false;
|
||||
frameRouteBlocked = true;
|
||||
partyNaturalFollowPosition(
|
||||
playerNavigation,
|
||||
playerForwardX,
|
||||
playerForwardZ,
|
||||
playerTrailingDistance,
|
||||
profile,
|
||||
routeNow,
|
||||
naturalFollowTarget,
|
||||
);
|
||||
if (directSegmentAllowed(current, naturalFollowTarget)) {
|
||||
const wasMoving = directFollowMovingRef.current.get(member.id) ?? false;
|
||||
hasDesired = partyShouldMoveToComfortTarget(
|
||||
current,
|
||||
naturalFollowTarget,
|
||||
wasMoving,
|
||||
profile.comfortSlack,
|
||||
);
|
||||
directFollowMovingRef.current.set(member.id, hasDesired);
|
||||
synchronizePartyBreadcrumbCursor(
|
||||
leaderTrailRef.current,
|
||||
cursor,
|
||||
leaderTrailingDistance,
|
||||
);
|
||||
if (hasDesired) {
|
||||
desired[0] = naturalFollowTarget[0];
|
||||
desired[1] = naturalFollowTarget[1];
|
||||
desired[2] = naturalFollowTarget[2];
|
||||
}
|
||||
movementExpected = hasDesired;
|
||||
} else {
|
||||
directFollowMovingRef.current.set(member.id, false);
|
||||
const previousBreadcrumbId = cursor.breadcrumbId;
|
||||
const requiresValidatedRejoin = cursor.breadcrumbId === null
|
||||
|| !leaderTrailRef.current.points.some((point) => point.id === cursor!.breadcrumbId);
|
||||
hasDesired = nextPartyBreadcrumbFollowWaypoint(
|
||||
leaderTrailRef.current,
|
||||
cursor,
|
||||
current,
|
||||
leaderTrailingDistance,
|
||||
BREADCRUMB_REACHED_DISTANCE,
|
||||
desired,
|
||||
profile.comfortSlack,
|
||||
);
|
||||
if (
|
||||
hasDesired
|
||||
&& requiresValidatedRejoin
|
||||
&& !directSegmentAllowed(current, desired)
|
||||
) {
|
||||
cursor.breadcrumbId = previousBreadcrumbId;
|
||||
hasDesired = false;
|
||||
frameRouteBlocked = true;
|
||||
}
|
||||
movementExpected = hasDesired
|
||||
|| planarDistance(current, leaderPosition ?? playerNavigation)
|
||||
> leaderTrailingDistance + profile.comfortSlack;
|
||||
}
|
||||
movementExpected = hasDesired
|
||||
|| planarDistance(current, leaderPosition ?? player) > trailingDistance + 0.75;
|
||||
}
|
||||
if (memberTargetPosition) {
|
||||
faceX = memberTargetPosition[0] - current[0];
|
||||
@@ -967,9 +1138,29 @@ function PartyActors({
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlanarDistance = planarDistance(current, player);
|
||||
const movementProfile = movementProfileFor(member.id, index);
|
||||
if (hasDesired) {
|
||||
const peers = peerPositionsFor(member.id);
|
||||
if (
|
||||
partySeparationGoal(
|
||||
current,
|
||||
desired,
|
||||
peers,
|
||||
movementProfile.swayPhase,
|
||||
separatedDesired,
|
||||
)
|
||||
&& directSegmentAllowed(current, separatedDesired)
|
||||
) {
|
||||
desired[0] = separatedDesired[0];
|
||||
desired[1] = separatedDesired[1];
|
||||
desired[2] = separatedDesired[2];
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlanarDistance = planarDistance(current, playerNavigation);
|
||||
if (member.health > 0 && party.command !== "stop" && hasDesired) {
|
||||
const speed = currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED;
|
||||
const speed = (currentPlanarDistance > 8 ? PARTY_CATCH_UP_SPEED : PARTY_MOVE_SPEED)
|
||||
* movementProfile.speedMultiplier;
|
||||
const moved = stepPartyPosition(current, desired, speed * safeDelta, nextPosition);
|
||||
// Route progress, not radial player distance, is authoritative. A
|
||||
// valid path may need to move away briefly around a U-bend.
|
||||
@@ -1015,7 +1206,7 @@ function PartyActors({
|
||||
stuckProgress.lastProgressAt = routeNow;
|
||||
} else if (partyShouldRecallForStuck(
|
||||
current,
|
||||
player,
|
||||
playerNavigation,
|
||||
movementExpected,
|
||||
routeNow,
|
||||
stuckProgress.lastProgressAt,
|
||||
@@ -1042,7 +1233,7 @@ function PartyActors({
|
||||
if (autoRecallRequested) {
|
||||
party.recallParty();
|
||||
lastRecallRevisionRef.current = usePartyStore.getState().recallRevision;
|
||||
teleportPartyToPlayer(player, routeNow);
|
||||
teleportPartyToPlayer(playerNavigation, routeNow);
|
||||
return;
|
||||
}
|
||||
party.setRouteBlocked(frameRouteBlocked);
|
||||
|
||||
+90
-9
@@ -13,7 +13,12 @@ import { useShellStore } from "../app/shellStore";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
import { consumeMouseLook, readInputSnapshot } from "../game/inputManager";
|
||||
import {
|
||||
clearJumpRequest,
|
||||
consumeJumpRequest,
|
||||
consumeMouseLook,
|
||||
readInputSnapshot,
|
||||
} from "../game/inputManager";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import type { CharacterCombatAnimationEvent } from "../game/combatAnimation";
|
||||
import {
|
||||
@@ -33,6 +38,18 @@ import {
|
||||
isBelowDungeonRecoveryPlane,
|
||||
isBelowManastormRecoveryPlane,
|
||||
} from "../game/playerRecovery";
|
||||
import {
|
||||
PLAYER_GROUND_PROBE_DISTANCE,
|
||||
PLAYER_JUMP_VELOCITY,
|
||||
PLAYER_LANDING_PRESENTATION_MS,
|
||||
bufferPlayerJump,
|
||||
cancelBufferedPlayerJump,
|
||||
characterVerticalMotion,
|
||||
createPlayerJumpTiming,
|
||||
isWalkableGroundHit,
|
||||
updatePlayerJumpTiming,
|
||||
type CharacterVerticalMotion,
|
||||
} from "../game/playerJump";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
|
||||
@@ -62,6 +79,7 @@ interface PlayerControllerProps extends PlayerRigRefs {
|
||||
identity: CharacterProfile | null;
|
||||
equipmentItems: readonly InventoryItem[];
|
||||
movingRef: React.MutableRefObject<boolean>;
|
||||
verticalMotionRef: React.MutableRefObject<CharacterVerticalMotion>;
|
||||
animationEventRef: React.MutableRefObject<CharacterCombatAnimationEvent | null>;
|
||||
}
|
||||
|
||||
@@ -85,6 +103,7 @@ function PlayerController({
|
||||
identity,
|
||||
equipmentItems,
|
||||
movingRef,
|
||||
verticalMotionRef,
|
||||
animationEventRef,
|
||||
}: PlayerControllerProps) {
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
@@ -98,11 +117,24 @@ function PlayerController({
|
||||
const spawn = useGameStore((state) => state.activeSpawn);
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const lastReported = useRef<[number, number, number]>([...spawn.footPosition]);
|
||||
const { rapier, world } = useRapier();
|
||||
const groundRay = useMemo(
|
||||
() => new rapier.Ray({ x: 0, y: 0, z: 0 }, { x: 0, y: -1, z: 0 }),
|
||||
[rapier],
|
||||
);
|
||||
const jumpTimingRef = useRef(createPlayerJumpTiming());
|
||||
const wasGroundedRef = useRef(true);
|
||||
const landingUntilRef = useRef(Number.NEGATIVE_INFINITY);
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) placeAtActiveSpawn(bodyRef.current, orbitRef.current);
|
||||
lastReported.current = [...spawn.footPosition];
|
||||
}, [bodyRef, orbitRef, resetRevision, spawn]);
|
||||
clearJumpRequest();
|
||||
jumpTimingRef.current = createPlayerJumpTiming(performance.now());
|
||||
wasGroundedRef.current = true;
|
||||
landingUntilRef.current = Number.NEGATIVE_INFINITY;
|
||||
verticalMotionRef.current = "grounded";
|
||||
}, [bodyRef, orbitRef, resetRevision, spawn, verticalMotionRef]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const body = bodyRef.current;
|
||||
@@ -131,18 +163,68 @@ function PlayerController({
|
||||
}
|
||||
|
||||
const velocity = body.linvel();
|
||||
if (paused || mapOpen || companionOpen || controlledUntil > Date.now()) {
|
||||
const translation = body.translation();
|
||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||
placeAtActiveSpawn(body, orbit);
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = performance.now();
|
||||
groundRay.origin.x = translation.x;
|
||||
groundRay.origin.y = translation.y;
|
||||
groundRay.origin.z = translation.z;
|
||||
const groundProbeLength = PLAYER_CENTER_HEIGHT + PLAYER_GROUND_PROBE_DISTANCE;
|
||||
const groundHit = world.castRayAndGetNormal(
|
||||
groundRay,
|
||||
groundProbeLength,
|
||||
true,
|
||||
rapier.QueryFilterFlags.EXCLUDE_SENSORS | rapier.QueryFilterFlags.EXCLUDE_DYNAMIC,
|
||||
undefined,
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
let grounded = velocity.y <= 0.5 && isWalkableGroundHit(groundHit, groundProbeLength);
|
||||
const requestedAtMs = consumeJumpRequest();
|
||||
const gameplayBlocked = paused || mapOpen || companionOpen || controlledUntil > Date.now();
|
||||
if (gameplayBlocked) {
|
||||
cancelBufferedPlayerJump(jumpTimingRef.current);
|
||||
movingRef.current = false;
|
||||
verticalMotionRef.current = characterVerticalMotion(
|
||||
grounded,
|
||||
velocity.y,
|
||||
nowMs,
|
||||
landingUntilRef.current,
|
||||
);
|
||||
body.setLinvel({ x: 0, y: velocity.y, z: 0 }, true);
|
||||
return;
|
||||
}
|
||||
if (requestedAtMs !== null) bufferPlayerJump(jumpTimingRef.current, requestedAtMs);
|
||||
|
||||
const shouldJump = updatePlayerJumpTiming(
|
||||
jumpTimingRef.current,
|
||||
nowMs,
|
||||
grounded,
|
||||
velocity.y,
|
||||
);
|
||||
const verticalVelocity = shouldJump ? PLAYER_JUMP_VELOCITY : velocity.y;
|
||||
if (shouldJump) grounded = false;
|
||||
if (grounded && !wasGroundedRef.current) {
|
||||
landingUntilRef.current = nowMs + PLAYER_LANDING_PRESENTATION_MS;
|
||||
}
|
||||
wasGroundedRef.current = grounded;
|
||||
verticalMotionRef.current = characterVerticalMotion(
|
||||
grounded,
|
||||
verticalVelocity,
|
||||
nowMs,
|
||||
landingUntilRef.current,
|
||||
);
|
||||
|
||||
const [worldX, worldZ] = cameraRelativeMovement(
|
||||
snapshot.moveX,
|
||||
snapshot.moveForward,
|
||||
orbit.yaw,
|
||||
);
|
||||
body.setLinvel({ x: worldX * MOVE_SPEED, y: velocity.y, z: worldZ * MOVE_SPEED }, true);
|
||||
body.setLinvel({ x: worldX * MOVE_SPEED, y: verticalVelocity, z: worldZ * MOVE_SPEED }, true);
|
||||
|
||||
const moving = Math.hypot(worldX, worldZ) > 0.05;
|
||||
movingRef.current = moving;
|
||||
@@ -152,11 +234,6 @@ function PlayerController({
|
||||
avatarRef.current.rotation.y += difference * (1 - Math.exp(-14 * delta));
|
||||
}
|
||||
|
||||
const translation = body.translation();
|
||||
if (![translation.x, translation.y, translation.z].every(Number.isFinite)) {
|
||||
placeAtActiveSpawn(body, orbit);
|
||||
return;
|
||||
}
|
||||
const activeManastormPackage = gameMode === "manastorm"
|
||||
? activeManastormStageAssetPackage()
|
||||
: null;
|
||||
@@ -185,6 +262,7 @@ function PlayerController({
|
||||
position: footPosition,
|
||||
yaw: orbit.yaw,
|
||||
distanceDelta,
|
||||
grounded,
|
||||
});
|
||||
lastReported.current = footPosition;
|
||||
}
|
||||
@@ -214,6 +292,7 @@ function PlayerController({
|
||||
equipment={equipmentItems}
|
||||
active={!paused && !mapOpen && !companionOpen}
|
||||
movingRef={movingRef}
|
||||
verticalMotionRef={verticalMotionRef}
|
||||
animationEventRef={animationEventRef}
|
||||
fallback={<ProceduralAvatar accent={classById(identity?.classId ?? "priest").color} />}
|
||||
/>
|
||||
@@ -331,6 +410,7 @@ export function PlayerRig() {
|
||||
const bodyRef = useRef<RapierRigidBody>(null);
|
||||
const avatarRef = useRef<Object3D>(null);
|
||||
const movingRef = useRef(false);
|
||||
const verticalMotionRef = useRef<CharacterVerticalMotion>("grounded");
|
||||
const animationEventRef = useRef<CharacterCombatAnimationEvent | null>(animationEvent);
|
||||
animationEventRef.current = animationEvent;
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -349,6 +429,7 @@ export function PlayerRig() {
|
||||
identity={activeCharacter}
|
||||
equipmentItems={equipmentItems}
|
||||
movingRef={movingRef}
|
||||
verticalMotionRef={verticalMotionRef}
|
||||
animationEventRef={animationEventRef}
|
||||
/>
|
||||
<ThirdPersonCamera bodyRef={bodyRef} orbitRef={orbitRef} avatarRef={avatarRef} />
|
||||
|
||||
@@ -707,6 +707,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.aura-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; }
|
||||
.aura { position: relative; display: inline-grid; width: 22px; height: 22px; place-items: center; overflow: hidden; border: 1px solid rgba(255,255,255,.35); border-radius: 3px; color: #fff; background: linear-gradient(145deg, #42669a, #18273e); box-shadow: 0 1px 3px rgba(0,0,0,.7); }
|
||||
.aura--debuff { border-color: rgba(243,118,105,.75); background: linear-gradient(145deg, #8c3d45, #35191f); }
|
||||
.aura__icon { width: 100%; height: 100%; object-fit: cover; }
|
||||
.aura__initial { font: 700 11px/1 var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__timer { position: absolute; right: 1px; bottom: 0; font: 700 7px/8px var(--font-ui); text-shadow: 0 1px 2px #000; }
|
||||
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AuraDefinition } from "../game/combatAuras";
|
||||
import { auraDefinitionDisplaysInStrip, auraDefinitionIcon } from "./AuraStrip";
|
||||
|
||||
function aura(overrides: Partial<AuraDefinition> = {}): AuraDefinition {
|
||||
return {
|
||||
id: "test-aura",
|
||||
name: "Test Aura",
|
||||
disposition: "debuff",
|
||||
durationMs: 10_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("aura strip visibility", () => {
|
||||
it("shows ordinary auras and suppresses periodic shadow auras with dedicated timers", () => {
|
||||
expect(auraDefinitionDisplaysInStrip(aura())).toBe(true);
|
||||
expect(auraDefinitionDisplaysInStrip(aura({ hideFromAuraStrip: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses spell artwork while retaining the initial fallback for iconless effects", () => {
|
||||
expect(auraDefinitionIcon(aura({ icon: " /assets/ui/spells/test.png " }))).toBe(
|
||||
"/assets/ui/spells/test.png",
|
||||
);
|
||||
expect(auraDefinitionIcon(aura())).toBeNull();
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { AuraDisposition } from "../game/combatAuras";
|
||||
import type { AuraDefinition, AuraDisposition } from "../game/combatAuras";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
|
||||
|
||||
@@ -9,6 +9,14 @@ interface AuraStripProps {
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export function auraDefinitionDisplaysInStrip(definition: AuraDefinition): boolean {
|
||||
return definition.hideFromAuraStrip !== true;
|
||||
}
|
||||
|
||||
export function auraDefinitionIcon(definition: AuraDefinition): string | null {
|
||||
return definition.icon?.trim() || null;
|
||||
}
|
||||
|
||||
export function AuraStrip({
|
||||
targetId,
|
||||
dispositions = ["buff", "debuff"],
|
||||
@@ -19,6 +27,7 @@ export function AuraStrip({
|
||||
const auras = allAuras.filter((aura) => (
|
||||
aura.targetId === targetId
|
||||
&& dispositions.includes(aura.definition.disposition)
|
||||
&& auraDefinitionDisplaysInStrip(aura.definition)
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
));
|
||||
|
||||
@@ -36,6 +45,7 @@ export function AuraStrip({
|
||||
const remaining = aura.expiresAt === null ? null : Math.max(0, aura.expiresAt - now);
|
||||
const timer = remaining === null ? "" : formatTimedEffectTimer(remaining);
|
||||
const initial = aura.definition.name.trim().charAt(0).toUpperCase() || "*";
|
||||
const icon = auraDefinitionIcon(aura.definition);
|
||||
return (
|
||||
<span
|
||||
key={aura.instanceId}
|
||||
@@ -43,7 +53,9 @@ export function AuraStrip({
|
||||
aria-label={`${aura.definition.name}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
|
||||
title={`${aura.definition.name}${timer ? ` · ${timer} remaining` : ""}`}
|
||||
>
|
||||
<span className="aura__initial" aria-hidden="true">{initial}</span>
|
||||
{icon
|
||||
? <img className="aura__icon" src={icon} alt="" draggable={false} />
|
||||
: <span className="aura__initial" aria-hidden="true">{initial}</span>}
|
||||
{aura.stacks > 1 ? <b className="aura__stacks">{aura.stacks}</b> : null}
|
||||
{timer ? <b className="aura__timer" aria-hidden="true">{timer}</b> : null}
|
||||
</span>
|
||||
|
||||
@@ -34,12 +34,15 @@ export function CharacterCreateScreen() {
|
||||
const race = raceById(draft.raceId);
|
||||
const characterClass = classById(draft.classId);
|
||||
const availableRaces = useMemo(() => racesForCategory(draft.categoryId), [draft.categoryId]);
|
||||
const availableClasses = useMemo(() => classesForRace(draft.raceId), [draft.raceId]);
|
||||
const availableClasses = useMemo(
|
||||
() => classesForRace(draft.raceId, draft.categoryId),
|
||||
[draft.categoryId, draft.raceId],
|
||||
);
|
||||
const appearanceLabels = race.labels[draft.gender];
|
||||
const appearanceCounts = race.counts[draft.gender];
|
||||
|
||||
const chooseRace = (raceId: RaceId) => {
|
||||
const nextClasses = classesForRace(raceId);
|
||||
const nextClasses = classesForRace(raceId, draft.categoryId);
|
||||
const nextGenders = supportedGenders(raceId);
|
||||
const classId = nextClasses.some((definition) => definition.id === draft.classId)
|
||||
? draft.classId
|
||||
@@ -154,7 +157,7 @@ export function CharacterCreateScreen() {
|
||||
</div>
|
||||
</main>
|
||||
<section className="creator-classes">
|
||||
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : "WoW / Ascension"} classes available to {race.name}</small></div></header>
|
||||
<header><span>02</span><div><strong>Class</strong><small>{availableClasses.length} {draft.categoryId === "rom" ? "RuneWaker" : draft.categoryId === "coa" ? "CoA" : "WoW"} classes available to {race.name}</small></div></header>
|
||||
<div className="class-grid">
|
||||
{availableClasses.map((definition) => (
|
||||
<ControllerButton
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState, type CSSProperties } from "react";
|
||||
import { CLASSES, PLAYABLE_RACES, classById, FACTIONS, raceById } from "../app/characterCatalog";
|
||||
import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS, raceById } from "../app/characterCatalog";
|
||||
import { contentCategoryById } from "../app/contentCategories";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
@@ -61,6 +62,7 @@ export function CharacterSelectScreen() {
|
||||
{characters.map((character) => {
|
||||
const raceDefinition = raceById(character.raceId);
|
||||
const classDefinition = classById(character.classId);
|
||||
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
|
||||
return (
|
||||
<ControllerButton
|
||||
key={character.id}
|
||||
@@ -72,7 +74,7 @@ export function CharacterSelectScreen() {
|
||||
onClick={() => select(character.id)}
|
||||
>
|
||||
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
|
||||
<i><strong>{character.name}</strong><small>{character.categoryId === "rom" ? "RoM" : "WoW"} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
|
||||
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
|
||||
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
|
||||
</ControllerButton>
|
||||
);
|
||||
@@ -110,7 +112,7 @@ export function CharacterSelectScreen() {
|
||||
<h2>{selected.location}</h2>
|
||||
<dl>
|
||||
<div><dt>Level</dt><dd>{selected.level}</dd></div>
|
||||
<div><dt>Content</dt><dd>{selected.categoryId === "rom" ? "Runes of Magic" : "World of Warcraft"}</dd></div>
|
||||
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
|
||||
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
|
||||
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
|
||||
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
|
||||
|
||||
@@ -78,10 +78,11 @@ function PauseDialog() {
|
||||
</div>
|
||||
<dl className="controls-list">
|
||||
<div><dt>Move</dt><dd>WASD / Left stick</dd></div>
|
||||
<div><dt>Jump</dt><dd>Space / L3</dd></div>
|
||||
<div><dt>Look</dt><dd>Hold right mouse / Arrow keys / Right stick</dd></div>
|
||||
<div><dt>Abilities</dt><dd>1–8 / Face, R1, R2, D-pad left/right</dd></div>
|
||||
<div><dt>Ability layers</dt><dd>Shift / Alt / Hold L1 / Hold L2</dd></div>
|
||||
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self · L3 clear</dd></div>
|
||||
<div><dt>Targets</dt><dd>Tab / R3 enemy · D-pad up/down party/self</dd></div>
|
||||
<div><dt>Party orders</dt><dd>F1 Attack / F2 Defend / F3 Stop / F4 Recall</dd></div>
|
||||
<div><dt>Spellbook / {skillMenuLabel}</dt><dd>P / N</dd></div>
|
||||
<div><dt>Inventory / Loot</dt><dd>I / E</dd></div>
|
||||
|
||||
Reference in New Issue
Block a user