import { ActionBattleEnabledContext, ActionBattleOptions } from "./types"; import { normalizeActionBattleAttackProfile } from "./core/attack-profile"; export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = { preset: "adventure", ui: { hotbar: { enabled: false, autoOpen: false, }, targeting: { enabled: true, showGrid: true, colors: { area: 0x2f9ef7, edge: 0x1b6a98, cursor: 0xffd166, }, }, }, skills: { defaultAoeMask: ["#"], }, targeting: { affects: "events", allowEmptyTarget: true, }, attack: { lockMovement: true, lockDurationMs: 350, showPreview: true, previewDurationMs: 180, previewColor: 0xfff3b0, previewAccentColor: 0xffffff, }, combat: { player: { combo: { enabled: true, bufferMs: 140, resetMs: 700, steps: [ { id: "adventure-combo-1", startupMs: 55, activeMs: 90, recoveryMs: 120, damageMultiplier: 0.85, knockbackMultiplier: 0.7, }, { id: "adventure-combo-2", startupMs: 45, activeMs: 95, recoveryMs: 130, damageMultiplier: 1, knockbackMultiplier: 0.85, }, { id: "adventure-combo-3", startupMs: 90, activeMs: 120, recoveryMs: 240, damageMultiplier: 1.35, knockbackMultiplier: 1.4, }, ], }, chargedAttack: { enabled: true, control: "e", minChargeMs: 300, maxChargeMs: 900, minDamageMultiplier: 1.5, maxDamageMultiplier: 2.4, minKnockbackMultiplier: 1.6, maxKnockbackMultiplier: 2.3, profile: { id: "adventure-charged", startupMs: 100, activeMs: 140, recoveryMs: 380, }, }, dodge: { enabled: true, durationMs: 180, cooldownMs: 650, invincibilityMs: 220, additionalSpeed: 8, }, guard: { enabled: true, control: "f", parryWindowMs: 140, guardDamageReduction: 0.65, guardKnockbackReduction: 0.6, guardArcDegrees: 120, staggerMs: 650, counterWindowMs: 700, counterDamageMultiplier: 1.5, counterStaggerMultiplier: 1.5, }, softTargeting: { enabled: true, range: 112, coneDegrees: 110, directionWeight: 0.48, distanceWeight: 0.32, threatWeight: 0.2, indicatorDurationMs: 220, }, }, }, visual: "impact", feedback: { hitStop: true, hitStopMs: 32, heavyHitStopMs: 52, parryHitStopMs: 68, flashes: true, screenShake: true, damageNumbers: true, }, animations: {}, ai: { director: { enabled: true, maxConcurrentAttackers: 1, slotDurationMs: 1200, }, }, }; let currentActionBattleOptions: ActionBattleOptions = DEFAULT_ACTION_BATTLE_OPTIONS; export function normalizeActionBattleOptions( options: ActionBattleOptions = {} ): ActionBattleOptions { const preset = options.preset ?? DEFAULT_ACTION_BATTLE_OPTIONS.preset; const classic = preset === "classic"; const defaultCombat = classic ? { player: { combo: false, chargedAttack: false, dodge: false, guard: false, softTargeting: false, }, } : DEFAULT_ACTION_BATTLE_OPTIONS.combat; const defaultAdventurePlayer = DEFAULT_ACTION_BATTLE_OPTIONS.combat?.player; const requestedPlayer = { ...defaultCombat?.player, ...options.systems?.combat?.player, ...options.combat?.player, }; const normalizePlayerFeature = ( value: any, defaults: any ) => { if (value === false) return false; if (value === true) return { ...defaults, enabled: true }; if (value && typeof value === "object") { return { ...defaults, ...value, enabled: value.enabled ?? true }; } return value; }; const combat = { ...defaultCombat, ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat, ...options.systems?.combat, ...options.combat, hooks: { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat?.hooks, ...options.systems?.combat?.hooks, ...options.combat?.hooks, }, player: { ...requestedPlayer, combo: normalizePlayerFeature( requestedPlayer.combo, defaultAdventurePlayer?.combo ), chargedAttack: normalizePlayerFeature( requestedPlayer.chargedAttack, defaultAdventurePlayer?.chargedAttack ), dodge: normalizePlayerFeature( requestedPlayer.dodge, defaultAdventurePlayer?.dodge ), guard: normalizePlayerFeature( requestedPlayer.guard, defaultAdventurePlayer?.guard ), softTargeting: normalizePlayerFeature( requestedPlayer.softTargeting, defaultAdventurePlayer?.softTargeting ), }, }; const attack = { ...DEFAULT_ACTION_BATTLE_OPTIONS.attack, ...options.attack, ...combat.attack, }; if ( !classic && options.attack?.profile?.control === undefined && options.combat?.attack?.profile?.control === undefined && options.systems?.combat?.attack?.profile?.control === undefined ) { attack.profile = { ...attack.profile, control: { movementLock: "active", directionLock: "active", moveCancelsRecovery: true, dodgeCancelsRecovery: true, inputBufferMs: 160, }, }; } const attackProfile = normalizeActionBattleAttackProfile(attack.profile, { lockMovement: attack.lockMovement, lockDurationMs: attack.lockDurationMs, hitboxes: attack.hitboxes, }); const normalizedAttack = { ...attack, profile: attackProfile, }; const skills = { ...DEFAULT_ACTION_BATTLE_OPTIONS.skills, ...options.skills, }; skills.targeting = skills.targeting ?? skills.getTargeting; skills.getTargeting = skills.getTargeting ?? skills.targeting; const defaultHotbar = DEFAULT_ACTION_BATTLE_OPTIONS.ui?.hotbar as any; const defaultTargeting = DEFAULT_ACTION_BATTLE_OPTIONS.ui?.targeting as any; const optionHotbar = options.ui?.hotbar as any; const optionTargeting = options.ui?.targeting as any; const optionAttackPreview = options.ui?.attackPreview as any; const hotbar = options.ui?.hotbar === false ? { ...defaultHotbar, enabled: false } : { ...defaultHotbar, ...(options.ui?.hotbar === true ? { enabled: true } : optionHotbar), }; const legacyPreviewEnabled = normalizedAttack.showPreview !== false; const attackPreview = options.ui?.attackPreview === false ? { enabled: false } : { enabled: options.ui?.attackPreview === true ? true : legacyPreviewEnabled, ...(options.ui?.attackPreview === true ? {} : optionAttackPreview), }; const targeting = options.ui?.targeting === false ? { ...defaultTargeting, enabled: false } : { ...defaultTargeting, ...(options.ui?.targeting === true ? { enabled: true } : optionTargeting), colors: { ...defaultTargeting?.colors, ...(typeof options.ui?.targeting === "object" ? optionTargeting?.colors : undefined), }, }; const ai = { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai, ...options.systems?.ai, ...options.ai, actions: { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.actions, ...options.systems?.ai?.actions, ...options.ai?.actions, }, behaviors: { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.behaviors, ...options.systems?.ai?.behaviors, ...options.ai?.behaviors, }, presets: { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.presets, ...options.systems?.ai?.presets, ...options.ai?.presets, }, visuals: { ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.visuals, ...options.systems?.ai?.visuals, ...options.ai?.visuals, }, director: options.ai?.director === false ? false : { ...((DEFAULT_ACTION_BATTLE_OPTIONS.ai?.director as object) ?? {}), ...((options.systems?.ai?.director as object) ?? {}), ...((options.ai?.director as object) ?? {}), }, }; return { preset, enabled: options.enabled, ui: { ...options.ui, hotbar, targeting, attackPreview, }, skills, targeting: { ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting, ...options.targeting, }, attack: normalizedAttack, combat: { ...combat, attack: normalizedAttack, }, ai, visual: options.visual ?? (classic ? "classic" : DEFAULT_ACTION_BATTLE_OPTIONS.visual), feedback: { ...DEFAULT_ACTION_BATTLE_OPTIONS.feedback, ...(classic ? { hitStop: false } : {}), ...options.feedback, }, animations: { ...DEFAULT_ACTION_BATTLE_OPTIONS.animations, ...options.animations, }, audio: options.audio, systems: { combat: { ...combat, attack: normalizedAttack, }, ai, }, }; } export function setActionBattleOptions(options: ActionBattleOptions) { currentActionBattleOptions = options; } export function getActionBattleOptions(): ActionBattleOptions { return currentActionBattleOptions; } /** * Tells whether the module is active for a player, an event or a map. * * Evaluated on every call so the option can read state that is only known * later (the project a player plays). */ export function isActionBattleEnabled( context: ActionBattleEnabledContext = {}, options: ActionBattleOptions = currentActionBattleOptions ): boolean { const enabled = options.enabled; if (typeof enabled === "function") return enabled(context) !== false; return enabled !== false; }