UNPKG

@slippi/slippi-js

Version:
348 lines (345 loc) 16.5 kB
import get from 'lodash/get.js'; import isEqual from 'lodash/isEqual.js'; import keyBy from 'lodash/keyBy.js'; import last from 'lodash/last.js'; import set from 'lodash/set.js'; import size from 'lodash/size.js'; import { getAerialFrameData } from '../melee/framedataUtils.esm.js'; import { exists } from '../utils/exists.esm.js'; import { getSinglesPlayerPermutationsFromSettings, State } from './common.esm.js'; // Frame pattern that indicates a dash dance turn was executed const dashDanceAnimations = [State.DASH, State.TURN, State.DASH]; class ActionsComputer { constructor() { this.playerPermutations = []; this.state = new Map(); } setup(settings) { this.state = new Map(); this.playerPermutations = getSinglesPlayerPermutationsFromSettings(settings); this.playerPermutations.forEach((indices) => { const playerCounts = { playerIndex: indices.playerIndex, wavedashCount: 0, wavelandCount: 0, airDodgeCount: 0, dashDanceCount: 0, spotDodgeCount: 0, ledgegrabCount: 0, rollCount: 0, tauntCount: 0, edgeCancelCount: { success: 0, slow: 0, }, lCancelCount: { success: 0, fail: 0, }, attackCount: { jab1: 0, jab2: 0, jab3: 0, jabm: 0, dash: 0, ftilt: 0, utilt: 0, dtilt: 0, fsmash: 0, usmash: 0, dsmash: 0, nair: 0, fair: 0, bair: 0, uair: 0, dair: 0, }, grabCount: { success: 0, fail: 0, }, throwCount: { up: 0, forward: 0, back: 0, down: 0, }, groundTechCount: { // tech away/in are in reference to the opponents position and not the stage away: 0, in: 0, neutral: 0, fail: 0, }, wallTechCount: { success: 0, fail: 0, }, }; const playerState = { playerCounts: playerCounts, animations: [], actionFrameCounters: [], positionsY: [], lastLCancelStatus: 0, }; this.state.set(indices, playerState); }); } processFrame(frame) { this.playerPermutations.forEach((indices) => { const state = this.state.get(indices); if (state) { handleActionCompute(state, indices, frame); } }); } fetch() { return Array.from(this.state.values()).map((val) => val.playerCounts); } } function isMissGroundTech(animation) { return animation === State.TECH_MISS_DOWN || animation === State.TECH_MISS_UP; } function isRolling(animation) { return animation === State.ROLL_BACKWARD || animation === State.ROLL_FORWARD; } function isGrabAction(animation) { // Includes Grab pull, wait, pummel, and throws return animation > State.GRAB && animation <= State.THROW_DOWN && animation !== State.DASH_GRAB; } function isGrabbing(animation) { return animation === State.GRAB || animation === State.DASH_GRAB; } function isAerialLanding(animation) { return animation >= State.AERIAL_LANDING_START && animation <= State.AERIAL_LANDING_END; } function isForwardTilt(animation) { return animation >= State.ATTACK_FTILT_START && animation <= State.ATTACK_FTILT_END; } function isForwardSmash(animation) { return animation >= State.ATTACK_FSMASH_START && animation <= State.ATTACK_FSMASH_END; } function isTaunting(animation) { return animation === State.TAUNT_LEFT || animation === State.TAUNT_RIGHT; } function getLandLagFromLandAnimation(animation, internalCharacterId) { let aerialName; if (animation === State.AERIAL_BAIR_LANDING) { aerialName = "bair"; } else if (animation === State.AERIAL_FAIR_LANDING) { aerialName = "fair"; } else if (animation === State.AERIAL_DAIR_LANDING) { aerialName = "dair"; } else if (animation === State.AERIAL_NAIR_LANDING) { aerialName = "nair"; } else if (animation === State.AERIAL_UAIR_LANDING) { aerialName = "upair"; } else { return undefined; } const aerialFrameData = getAerialFrameData(internalCharacterId, aerialName); if (aerialFrameData) { return [aerialFrameData.landingLag, aerialFrameData.lcancelledLandingLag]; } return undefined; } //count the number of frames the previous animation took function getPrevAnimationLength(state) { // last element of animations is the first frame of the current action, so // start with the element before that const startIndex = state.animations.length - 2; let i = startIndex; let frameCount = 0; // count frames backwards until we find a different animation while (state.animations[i] === state.animations[startIndex]) { frameCount += 1; i--; } return frameCount; } function handleActionCompute(state, indices, frame) { var _a; const playerFrame = frame.players[indices.playerIndex].post; const opponentFrame = frame.players[indices.opponentIndex].post; const incrementCount = (field, condition) => { if (!condition) { return; } const current = get(state.playerCounts, field, 0); set(state.playerCounts, field, current + 1); }; // Manage animation state const currentAnimation = playerFrame.actionStateId; state.animations.push(currentAnimation); const currentFrameCounter = playerFrame.actionStateCounter; state.actionFrameCounters.push(currentFrameCounter); state.positionsY.push((_a = playerFrame.positionY) !== null && _a !== void 0 ? _a : 0); // Grab last 3 frames const last3Frames = state.animations.slice(-3); const prevAnimation = last3Frames[last3Frames.length - 2]; const prevFrameCounter = state.actionFrameCounters[state.actionFrameCounters.length - 2]; // New action if new animation or frame counter goes back down (repeated action) const isNewAction = currentAnimation !== prevAnimation || prevFrameCounter > currentFrameCounter; if (!isNewAction) { return; } // Increment counts based on conditions const didDashDance = isEqual(last3Frames, dashDanceAnimations); incrementCount("dashDanceCount", didDashDance); incrementCount("rollCount", isRolling(currentAnimation)); incrementCount("spotDodgeCount", currentAnimation === State.SPOT_DODGE); incrementCount("airDodgeCount", currentAnimation === State.AIR_DODGE); incrementCount("ledgegrabCount", currentAnimation === State.CLIFF_CATCH); // Taunts incrementCount("tauntCount", isTaunting(currentAnimation)); // Grabs incrementCount("grabCount.success", isGrabbing(prevAnimation) && isGrabAction(currentAnimation)); incrementCount("grabCount.fail", isGrabbing(prevAnimation) && !isGrabAction(currentAnimation)); if (currentAnimation === State.DASH_GRAB && prevAnimation === State.ATTACK_DASH) { state.playerCounts.attackCount.dash -= 1; // subtract from dash attack if boost grab } // Basic attacks incrementCount("attackCount.jab1", currentAnimation === State.ATTACK_JAB1); incrementCount("attackCount.jab2", currentAnimation === State.ATTACK_JAB2); incrementCount("attackCount.jab3", currentAnimation === State.ATTACK_JAB3); incrementCount("attackCount.jabm", currentAnimation === State.ATTACK_JABM); incrementCount("attackCount.dash", currentAnimation === State.ATTACK_DASH); incrementCount("attackCount.ftilt", isForwardTilt(currentAnimation)); incrementCount("attackCount.utilt", currentAnimation === State.ATTACK_UTILT); incrementCount("attackCount.dtilt", currentAnimation === State.ATTACK_DTILT); incrementCount("attackCount.fsmash", isForwardSmash(currentAnimation)); incrementCount("attackCount.usmash", currentAnimation === State.ATTACK_USMASH); incrementCount("attackCount.dsmash", currentAnimation === State.ATTACK_DSMASH); incrementCount("attackCount.nair", currentAnimation === State.AERIAL_NAIR); incrementCount("attackCount.fair", currentAnimation === State.AERIAL_FAIR); incrementCount("attackCount.bair", currentAnimation === State.AERIAL_BAIR); incrementCount("attackCount.uair", currentAnimation === State.AERIAL_UAIR); incrementCount("attackCount.dair", currentAnimation === State.AERIAL_DAIR); // GnW is weird and has unique IDs for some moves if (playerFrame.internalCharacterId === 0x18) { incrementCount("attackCount.jab1", currentAnimation === State.GNW_JAB1); incrementCount("attackCount.jabm", currentAnimation === State.GNW_JABM); incrementCount("attackCount.dtilt", currentAnimation === State.GNW_DTILT); incrementCount("attackCount.fsmash", currentAnimation === State.GNW_FSMASH); incrementCount("attackCount.nair", currentAnimation === State.GNW_NAIR); incrementCount("attackCount.bair", currentAnimation === State.GNW_BAIR); incrementCount("attackCount.uair", currentAnimation === State.GNW_UAIR); } // Peach is also weird and has a unique ID for her fsmash // FSMASH1 = Golf Club, FSMASH2 = Frying Pan, FSMASH3 = Tennis Racket if (playerFrame.internalCharacterId === 0x09) { incrementCount("attackCount.fsmash", currentAnimation === State.PEACH_FSMASH1); incrementCount("attackCount.fsmash", currentAnimation === State.PEACH_FSMASH2); incrementCount("attackCount.fsmash", currentAnimation === State.PEACH_FSMASH3); } // Throws incrementCount("throwCount.up", currentAnimation === State.THROW_UP); incrementCount("throwCount.forward", currentAnimation === State.THROW_FORWARD); incrementCount("throwCount.down", currentAnimation === State.THROW_DOWN); incrementCount("throwCount.back", currentAnimation === State.THROW_BACK); // Techs const opponentDir = playerFrame.positionX > opponentFrame.positionX ? -1 : 1; const facingOpponent = playerFrame.facingDirection === opponentDir; incrementCount("groundTechCount.fail", isMissGroundTech(currentAnimation)); incrementCount("groundTechCount.in", currentAnimation === State.FORWARD_TECH && facingOpponent); incrementCount("groundTechCount.in", currentAnimation === State.BACKWARD_TECH && !facingOpponent); incrementCount("groundTechCount.neutral", currentAnimation === State.NEUTRAL_TECH); incrementCount("groundTechCount.away", currentAnimation === State.BACKWARD_TECH && facingOpponent); incrementCount("groundTechCount.away", currentAnimation === State.FORWARD_TECH && !facingOpponent); incrementCount("wallTechCount.success", currentAnimation === State.WALL_TECH); incrementCount("wallTechCount.fail", currentAnimation === State.MISSED_WALL_TECH); if (isAerialLanding(currentAnimation)) { incrementCount("lCancelCount.success", playerFrame.lCancelStatus === 1); incrementCount("lCancelCount.fail", playerFrame.lCancelStatus === 2); } // check for edge canceled aerial if (isAerialLanding(prevAnimation) && (currentAnimation === State.FALL || currentAnimation === State.TEETER) && exists(playerFrame.internalCharacterId)) { const landingFrames = getPrevAnimationLength(state); const [landingLag, lCanceledLag] = getLandLagFromLandAnimation(prevAnimation, playerFrame.internalCharacterId); // only counts as a successful edge cancel if an L cancel wouldn't have been faster incrementCount("edgeCancelCount.success", landingFrames < lCanceledLag); incrementCount("edgeCancelCount.slow", landingFrames >= lCanceledLag && landingFrames < landingLag); // make edge cancels not count as failed L cancels if (landingFrames <= lCanceledLag && state.lastLCancelStatus === 2) { state.playerCounts.lCancelCount.fail -= 1; } } // Handles wavedash detection (and waveland) handleActionWavedash(state.playerCounts, state.animations, state.positionsY); if (exists(playerFrame.lCancelStatus) && playerFrame.lCancelStatus > 0) { state.lastLCancelStatus = playerFrame.lCancelStatus; } } function handleActionWavedash(counts, animations, positionsY) { const currentAnimation = last(animations); const prevAnimation = animations[animations.length - 2]; const isSpecialLanding = currentAnimation === State.LANDING_FALL_SPECIAL; const isAcceptablePrevious = isWavedashInitiationAnimation(prevAnimation); const isPossibleWavedash = isSpecialLanding && isAcceptablePrevious; if (!isPossibleWavedash) { return; } const recentFrames = animations.slice(-15); const recentAnimations = keyBy(recentFrames, (animation) => animation); if (size(recentAnimations) === 2 && recentAnimations[State.AIR_DODGE]) { // If the only other animation is air dodge, this might be really late to the point // where it was actually an air dodge. Air dodge animation is really long return; } if (recentAnimations[State.AIR_DODGE]) { // If one of the recent animations was an air dodge, let's remove that from the // air dodge counter, we don't want to count air dodges used to wavedash/land counts.airDodgeCount -= 1; } if (recentAnimations[State.ACTION_KNEE_BEND]) { // If a jump was started recently, we will consider this a wavedash unless the player // spent too many frames in the air and their Y positions changed // (indicating they were traveling through the air, not wavedashing). // We use CONTROLLED_JUMP_START + 1 because CONTROLLED_JUMP_START equals ACTION_KNEE_BEND, // and we only want to count actual airborne jump frames, not the knee bend frames. const airborneJumpStart = State.CONTROLLED_JUMP_START + 1; const jumpFrames = recentFrames.filter((animation) => animation >= airborneJumpStart && animation <= State.CONTROLLED_JUMP_END).length; // Calculate Y position difference between knee bend start and landing const recentPositionsY = positionsY.slice(-15); const kneeBendIndex = recentFrames.findLastIndex((anim) => anim === State.ACTION_KNEE_BEND); const yDifference = recentPositionsY[recentPositionsY.length - 1] - recentPositionsY[kneeBendIndex]; const epsilon = 0.1; const changedY = Math.abs(yDifference) > epsilon; // If the player was airborne for at least 5 frames and their Y positions changed, // they were traveling through the air (e.g. jumping to a platform) so they wavelanded, not wavedashed // 5 was chosen because it is the minimum number of jump frames it takes a character to get from ground to // a non-moving platform, Falco on the lowest FoD platform. // Edge cases: // - Any wavedash that is done with >15 frames from knee bend to landing // e.g. Fox/Falco JC shine where the first and second jump combine to 4+ frames in air. // - Any wavedash that has 5 or more frames in the air and changes Y position, // e.g. Late wavedash to/from Yoshi's center to slope, Late wavedash on FoD moving platforms (4+ frames in air). if (jumpFrames >= 5 && changedY) { counts.wavelandCount += 1; } else { counts.wavedashCount += 1; } } else { // If there was no jump recently, this is a waveland counts.wavelandCount += 1; } } function isWavedashInitiationAnimation(animation) { if (animation === State.AIR_DODGE) { return true; } const isAboveMin = animation >= State.CONTROLLED_JUMP_START; const isBelowMax = animation <= State.CONTROLLED_JUMP_END; return isAboveMin && isBelowMax; } export { ActionsComputer };