449 lines
17 KiB
CoffeeScript
449 lines
17 KiB
CoffeeScript
{Attachment, Status} = require './attachment'
|
|
{Weather} = require '../../shared/weather'
|
|
{Protocol} = require '../../shared/protocol'
|
|
Query = require('./queries')
|
|
util = require './util'
|
|
|
|
# A single Move in the Pokemon engine. Move objects are constructed in
|
|
# data/VERSION/moves.coffee, with only one instance per move (for example,
|
|
# there is only one Flamethrower). These instances are retrieved by the battle
|
|
# engine.
|
|
class @Move
|
|
criticalMultiplier: 2
|
|
|
|
constructor: (@name, attributes = {}) ->
|
|
@accuracy = attributes.accuracy || 0
|
|
@priority = attributes.priority || 0
|
|
@power = attributes.power
|
|
@target = attributes.target
|
|
@description = attributes.description || "No description set"
|
|
@type = attributes.type || '???'
|
|
@spectra = attributes.damage || '???'
|
|
@chLevel = attributes.criticalHitLevel || 1
|
|
@flags = attributes.flags || []
|
|
@flinchChance = (attributes.flinchChance || 0)
|
|
@ailmentChance = (attributes.ailmentChance || 0)
|
|
@ailmentId = attributes.ailmentId
|
|
@primaryBoostStats = attributes.primaryBoostStats
|
|
@primaryBoostTarget = attributes.primaryBoostTarget
|
|
@secondaryBoostChance = attributes.secondaryBoostChance || 0
|
|
@secondaryBoostStats = attributes.secondaryBoostStats
|
|
@secondaryBoostTarget = attributes.secondaryBoostTarget
|
|
@pp = attributes.pp
|
|
@recoil = attributes.recoil
|
|
{@minHits, @maxHits} = attributes
|
|
|
|
isPhysical: ->
|
|
@spectra == 'physical'
|
|
|
|
isSpecial: ->
|
|
@spectra == 'special'
|
|
|
|
isNonDamaging: ->
|
|
@spectra == 'non-damaging'
|
|
|
|
hasFlag: (flagName) ->
|
|
flagName in @flags
|
|
|
|
hasPrimaryEffect: ->
|
|
@primaryBoostStats? || (@ailmentId != "none" && @ailmentChance == 0)
|
|
|
|
# A secondary effect also includes flinching.
|
|
hasSecondaryEffect: ->
|
|
(@ailmentChance > 0 && @ailmentId != "none") ||
|
|
@flinchChance > 0 || @secondaryBoostChance > 0
|
|
|
|
# Executes this move on several targets.
|
|
# Only override this method if the move does not need to be
|
|
# recorded on the enemy pokemon.
|
|
execute: (battle, user, targets) ->
|
|
# If there are no targets, then the move should automatically fail.
|
|
# For example, Helping Hand may not have a target.
|
|
return @fail(battle, user) if targets.length == 0
|
|
# If there were targets, but they are all no longer alive, then the engine
|
|
# outputs a stock message and quits move execution.
|
|
targets = targets.filter((p) -> p.isAlive())
|
|
if targets.length == 0
|
|
battle.cannedText('NO_TARGET')
|
|
return
|
|
|
|
# The move is executing. Run a hook.
|
|
@executing(battle, user, targets)
|
|
|
|
targetsHit = []
|
|
totalDamage = 0
|
|
|
|
for target in targets
|
|
continue if @use(battle, user, target, hitNumber) == false
|
|
if target.shouldBlockExecution(this, user) == true
|
|
@afterFail(battle, user, target)
|
|
continue
|
|
targetsHit.push(target)
|
|
|
|
if targetsHit.length > 0
|
|
targetSlots = targetsHit.map (target) ->
|
|
return [ battle.playerIds.indexOf(target.playerId),
|
|
target.team.indexOf(target) ]
|
|
user.tell(Protocol.MOVE_SUCCESS, targetSlots, @name)
|
|
|
|
for target in targetsHit
|
|
numHits = @calculateNumberOfHits(battle, user, targets)
|
|
wasSlept = user.has(Status.Sleep)
|
|
for hitNumber in [1..numHits]
|
|
isDirect = @isDirectHit(battle, user, target)
|
|
damage = @hit(battle, user, target, hitNumber, isDirect) || 0
|
|
@afterHit(battle, user, target, damage, isDirect)
|
|
totalDamage += damage
|
|
break if target.isFainted() || user.isFainted() ||
|
|
(!wasSlept && user.has(Status.Sleep))
|
|
if numHits > 1
|
|
battle.message @numHitsMessage Math.min(hitNumber, numHits)
|
|
|
|
# Target faints if it has 0 HP.
|
|
for target in targets when target.isFainted()
|
|
target.faint()
|
|
|
|
# Recoil moves
|
|
if totalDamage > 0 && @recoil < 0 && !user.hasAbility("Rock Head")
|
|
recoil = Math.round(totalDamage * -@recoil / 100)
|
|
if user.damage(recoil)
|
|
battle.cannedText('RECOIL', user)
|
|
|
|
# If the move hit 1+ times, query the user's afterAllHits event.
|
|
# If the user is affected by Sheer Force, these are all ignored.
|
|
if targetsHit.length > 0 &&
|
|
(!user.hasAbility("Sheer Force") || !@hasSecondaryEffect())
|
|
user.afterAllHits(this)
|
|
@afterAllHits(battle, user)
|
|
for target in targetsHit
|
|
target.afterAllHitsTarget(this, user)
|
|
|
|
# A hook with a default implementation of returning false on a type immunity.
|
|
# If `use` returns false, the `hit` hook is never called.
|
|
use: (battle, user, target, hitNumber) ->
|
|
if target.isImmune(@getType(battle, user, target), user: user, move: this)
|
|
battle.cannedText('IMMUNITY', target)
|
|
@afterFail(battle, user, target)
|
|
return false
|
|
|
|
if @willMiss(battle, user, target)
|
|
battle.cannedText('MOVE_MISS', target)
|
|
@afterMiss(battle, user, target)
|
|
return false
|
|
|
|
if battle.hasWeather(Weather.HARSHSUN) and @getType(battle, user, target) == "Water"
|
|
battle.cannedText('HARSHSUN_MOVEFAIL')
|
|
return false
|
|
if battle.hasWeather(Weather.HEAVYRAIN) and @getType(battle, user, target) == "Fire"
|
|
battle.cannedText('HEAVYRAIN_MOVEFAIL')
|
|
return false
|
|
|
|
# Calculates damage, deals damage, and returns the amount of damage dealt
|
|
hit: (battle, user, target, hitNumber, isDirect) ->
|
|
damage = @calculateDamage(battle, user, target, hitNumber, isDirect)
|
|
if damage > 0
|
|
previousHP = target.get(Attachment.Substitute)?.hp ? target.currentHP
|
|
damage = target.damage(damage, direct: isDirect, source: "move")
|
|
if damage != 0
|
|
percent = Math.floor(100 * damage / target.stat('hp'))
|
|
battle.cannedText('GOT_HIT', target, percent)
|
|
else
|
|
currentHP = target.get(Attachment.Substitute)?.hp ? target.currentHP
|
|
damage = previousHP - Math.max(0, currentHP)
|
|
return damage
|
|
|
|
# `hit` may be overridden, but we still want to run these callbacks.
|
|
afterHit: (battle, user, target, damage, isDirect) ->
|
|
# Drain moves
|
|
if damage > 0 && @recoil > 0
|
|
amount = Math.round(damage * @recoil / 100)
|
|
user.drain(amount, target)
|
|
battle.cannedText('DRAIN', target)
|
|
battle.cannedText('ABSORB', user)
|
|
|
|
if @shouldTriggerSecondary(battle, user, target, damage, isDirect)
|
|
@triggerSecondaryEffect(battle, user, target)
|
|
user.afterSuccessfulHit(this, user, target, damage, isDirect)
|
|
target.afterBeingHit(this, user, target, damage, isDirect)
|
|
@afterSuccessfulHit(battle, user, target, damage, isDirect)
|
|
user.update()
|
|
target.update()
|
|
|
|
# Miscellaneous
|
|
target.recordHit(user, damage, this, battle.turn, isDirect)
|
|
|
|
# A hook that runs when the move is finally executing.
|
|
executing: (battle, user, targets) ->
|
|
|
|
# A hook that executes after a pokemon has been successfully damaged by
|
|
# a standard move. If execute is overriden, this will not execute.
|
|
afterSuccessfulHit: (battle, user, target, damage) ->
|
|
|
|
# A hook that executes after a pokemon misses an attack. If execute is
|
|
# overriden, this will not execute.
|
|
afterMiss: (battle, user, target) ->
|
|
|
|
# A hook that executes after a pokemon fails while using a move (NOT a miss.)
|
|
# Examples: Target is immune, target successfully uses Protect/Detect
|
|
afterFail: (battle, user, target) ->
|
|
|
|
# A hook that executes after all hits have completed.
|
|
afterAllHits: (battle, user) ->
|
|
|
|
# A hook that executes when asking if this move should do direct damage
|
|
isDirectHit: (battle, user, target) ->
|
|
return true if @hasFlag('authentic')
|
|
return !target.has(Attachment.Substitute)
|
|
|
|
# A hook that executes once a move fails.
|
|
fail: (battle, user) ->
|
|
battle.cannedText('MOVE_FAIL')
|
|
|
|
numHitsMessage: (hitNumber) ->
|
|
return "Hit #{hitNumber} time(s)!"
|
|
|
|
# A hook that is only used by special "specific-move" targets.
|
|
getTargets: (battle, user) ->
|
|
throw new Error("Move #{@name} has not implemented getTargets.")
|
|
|
|
calculateDamage: (battle, user, target, hitNumber=1, isDirect) ->
|
|
return 0 if @basePower(battle, user, target, hitNumber) == 0
|
|
|
|
user.crit = @isCriticalHit(battle, user, target)
|
|
damage = @baseDamage(battle, user, target, hitNumber)
|
|
# TODO: Multi-target modifier.
|
|
damage = @modify(damage, @weatherModifier(battle, user, target))
|
|
damage = damage * @criticalMultiplier if user.crit
|
|
damage = Math.floor(((100 - battle.rng.randInt(0, 15, "damage roll")) * damage) / 100)
|
|
damage = @modify(damage, @stabModifier(battle, user, target))
|
|
effectiveness = @typeEffectiveness(battle, user, target)
|
|
damage = Math.floor(effectiveness * damage)
|
|
damage = Math.floor(@burnCalculation(user) * damage)
|
|
damage = Math.max(damage, 1)
|
|
damage = @modify(damage, @modifyDamage(battle, user, target, hitNumber))
|
|
|
|
if effectiveness < 1
|
|
battle.cannedText('NOT_VERY_EFFECTIVE')
|
|
else if effectiveness > 1
|
|
battle.cannedText('SUPER_EFFECTIVE')
|
|
|
|
if user.crit
|
|
battle.cannedText('CRITICAL_HIT')
|
|
target.informCriticalHit()
|
|
damage
|
|
|
|
canMiss: (battle, user, target) ->
|
|
return true
|
|
|
|
# An accuracy of 0 means this move always hits. A negative accuracy means the
|
|
# target is invulnerable, even for 0-accuracy moves. A move always hits, regardless
|
|
# of accuracy and invulnerability, under any of the following conditions:
|
|
# * The user or target has No Guard
|
|
# * The user is locked onto the target (Lock-On, Mind Reader)
|
|
# * The move has a "never-miss" effect built in
|
|
# (Helping Hand, XY Toxic used by Poison types)
|
|
willMiss: (battle, user, target) ->
|
|
return false if user.hasAbility("No Guard") || target.hasAbility("No Guard")
|
|
return false if user.get(Attachment.LockOn)?.target == target
|
|
return false if !@canMiss(battle, user, target)
|
|
accuracy = @chanceToHit(battle, user, target)
|
|
accuracy = 100 if accuracy == 0
|
|
console.log(accuracy)
|
|
battle.rng.randInt(1, 100, "miss") > accuracy
|
|
|
|
chanceToHit: (battle, user, target) ->
|
|
return 0 if user == target
|
|
userBoosts = user.editBoosts(ignoreAccuracy: target.hasAbility("Unaware"))
|
|
targetBoosts = target.editBoosts(ignoreEvasion: user.hasAbility("Unaware"))
|
|
accuracy = @getAccuracy(battle, user, target)
|
|
# TODO: Find out how accuracy/evasion is chained and do the rest properly
|
|
accuracy = 50 if @isNonDamaging() && accuracy > 50 && target.hasAbility("Wonder Skin")
|
|
if userBoosts.accuracy > 0
|
|
accuracy = Math.floor(accuracy * (3 + userBoosts.accuracy) / 3)
|
|
else if userBoosts.accuracy < 0
|
|
accuracy = Math.floor(accuracy / (3 - userBoosts.accuracy) * 3)
|
|
if targetBoosts.evasion > 0
|
|
accuracy = Math.floor(accuracy / (3 + targetBoosts.evasion) * 3)
|
|
else if targetBoosts.evasion < 0
|
|
accuracy = Math.floor(accuracy * (3 - targetBoosts.evasion) / 3)
|
|
accuracy = user.editAccuracy(accuracy, this, target)
|
|
accuracy = target.editEvasion(accuracy, this, user)
|
|
accuracy
|
|
|
|
getAccuracy: (battle, user, target) ->
|
|
@accuracy
|
|
|
|
weatherModifier: (battle, user, target) ->
|
|
# TODO: This is wrong.
|
|
type = @getType(battle, user, target)
|
|
if type == 'Fire' && battle.hasWeather(Weather.SUN)
|
|
0x1800
|
|
else if type == 'Fire' && battle.hasWeather(Weather.RAIN)
|
|
0x800
|
|
else if type == 'Water' && battle.hasWeather(Weather.RAIN)
|
|
0x1800
|
|
else if type == 'Water' && battle.hasWeather(Weather.SUN)
|
|
0x800
|
|
else if type == 'Dark' && battle.hasWeather(Weather.MOON)
|
|
0x159A
|
|
else if type == 'Fairy' && battle.hasWeather(Weather.MOON)
|
|
0xC00
|
|
else
|
|
0x1000
|
|
|
|
stabModifier: (battle, user, target) ->
|
|
type = @getType(battle, user, target)
|
|
if user.hasType(type)
|
|
return 0x2000 if user.hasAbility("Adaptability")
|
|
return 0x1800
|
|
return 0x1000
|
|
|
|
ignoresImmunities: ->
|
|
@isNonDamaging()
|
|
|
|
typeEffectiveness: (battle, user, target) ->
|
|
type = @getType(battle, user, target)
|
|
effect = target.effectivenessOf(type, user: user, move: this)
|
|
if battle.hasWeather(Weather.DELTASTREAM) and target.hasType("Flying")
|
|
neweffect = 1
|
|
for targettype in target.types
|
|
typeeffect = util.typeEffectiveness(type, targettype)
|
|
if targettype == "Flying" and typeeffect > 1
|
|
typeeffect = 1
|
|
battle.cannedText('DELTASTREAM_MOVEFAIL')
|
|
neweffect * typeeffect
|
|
effect = neweffect
|
|
if target.hasAbility("Ethereal Shroud")
|
|
ghosteffect = util.typeEffectiveness(type, ["Ghost"])
|
|
if ghosteffect < 1
|
|
effect *= ghosteffect
|
|
effect
|
|
|
|
burnCalculation: (user) ->
|
|
if @isPhysical() && !user.hasAbility("Guts") && user.has(Status.Burn)
|
|
.5
|
|
else
|
|
1
|
|
|
|
basePower: (battle, user, target, hitNumber) ->
|
|
@power
|
|
|
|
shouldTriggerSecondary: (battle, user, target, damage, isDirect) ->
|
|
return false if !@hasSecondaryEffect()
|
|
return false if !isDirect && @secondaryBoostTarget != 'self'
|
|
return false if user.hasAbility("Sheer Force")
|
|
return false if target.hasAbility("Shield Dust") && @secondaryBoostTarget != 'self'
|
|
return true
|
|
|
|
triggerSecondaryEffect: (battle, user, target) ->
|
|
# Multiply chances by 2 if the user has Serene Grace.
|
|
chanceMultiplier = (if user.hasAbility("Serene Grace") then 2 else 1)
|
|
|
|
# Secondary effects
|
|
if @ailmentChance > 0 && battle.rng.randInt(0, 99, "secondary effect") < @ailmentChance * chanceMultiplier
|
|
target.attach(battle.getAilmentEffect(this), source: user)
|
|
|
|
# Secondary boosts
|
|
if @secondaryBoostChance > 0 && battle.rng.randInt(0, 99, "secondary boost") < @secondaryBoostChance * chanceMultiplier
|
|
pokemon = (if @secondaryBoostTarget == 'self' then user else target)
|
|
pokemon.boost(@secondaryBoostStats, user)
|
|
|
|
# Flinching. In the game, flinching is treated subtly different than
|
|
# secondary effects. One result is that the Fang moves can both inflict
|
|
# a secondary effect as well as flinch.
|
|
if @flinchChance > 0 && battle.rng.randInt(0, 99, "flinch") < @flinchChance * chanceMultiplier
|
|
target.attach(Attachment.Flinch)
|
|
|
|
isCriticalHit: (battle, attacker, defender) ->
|
|
return false if defender.team?.has(Attachment.LuckyChant)
|
|
return false if defender.ability?.preventsCriticalHits
|
|
|
|
chLevel = @criticalHitLevel(battle, attacker, defender)
|
|
rand = battle.rng.next("ch")
|
|
@determineCriticalHitFromLevel(chLevel, rand)
|
|
|
|
determineCriticalHitFromLevel: (level, rand) ->
|
|
switch level
|
|
when -1
|
|
true
|
|
when 1
|
|
rand < 0.0625
|
|
when 2
|
|
rand < 0.125
|
|
when 3
|
|
rand < 0.25
|
|
when 4
|
|
rand < 1/3
|
|
else
|
|
rand < .5
|
|
|
|
criticalHitLevel: (battle, attacker, defender) ->
|
|
# -1 means always crits
|
|
return @chLevel if @chLevel == -1
|
|
|
|
stage = @chLevel
|
|
stage += 1 if attacker.hasAbility('Super Luck')
|
|
stage += 2 if attacker.has(Attachment.FocusEnergy)
|
|
stage += attacker.criticalModifier()
|
|
stage
|
|
|
|
modify: (number, modifier) ->
|
|
Math.ceil((number * modifier) / 0x1000 - 0.5)
|
|
|
|
baseDamage: (battle, user, target, hitNumber=1) ->
|
|
floor = Math.floor
|
|
uStat = @pickAttackStat(user, target)
|
|
tStat = @pickDefenseStat(user, target)
|
|
if battle.hasWeather(Weather.SAND) && target.hasType("Rock") && @isSpecial()
|
|
tStat = @modify(tStat, 0x1800)
|
|
damage = floor((2 * user.level) / 5 + 2)
|
|
damage *= @basePower(battle, user, target, hitNumber)
|
|
damage = @modify(damage, @modifyBasePower(battle, user, target))
|
|
damage *= @modify(uStat, @modifyAttack(battle, user, target))
|
|
damage = floor(damage / tStat)
|
|
damage = floor(damage / 50)
|
|
damage += 2
|
|
damage
|
|
|
|
calculateNumberOfHits: (battle, user, targets) ->
|
|
numHits = user.calculateNumberOfHits(this, targets)
|
|
if numHits
|
|
numHits
|
|
else if @minHits == @maxHits
|
|
@maxHits
|
|
else if @minHits == 2 && @maxHits == 5
|
|
# hard coding moves like fury swipes to have 2-3 hits have a 1/3 chance, and 4-5 have 1/6th
|
|
battle.rng.choice([2, 2, 3, 3, 4, 5], "num hits")
|
|
else
|
|
battle.rng.randInt(@minHits, @maxHits, "num hits")
|
|
|
|
modifyBasePower: (battle, user, target) ->
|
|
modify = Query.modifiers("modifyBasePower", user.attachments.all(), this, target)
|
|
modify = @modify(modify, Query.modifiers("modifyBasePowerTarget", target.attachments.all(), this, user))
|
|
|
|
modifyDamage: (battle, user, target, hitNumber) ->
|
|
modify = Query.modifiers('modifyDamageTarget', target.team.attachments.all(), this, user, hitNumber)
|
|
modify = @modify(modify, Query.modifiers('modifyDamage', user.attachments.all(), this, target, hitNumber))
|
|
modify = @modify(modify, Query.modifiers('modifyDamageTarget', target.attachments.all(), this, user, hitNumber))
|
|
|
|
modifyAttack: (battle, user, target) ->
|
|
modify = Query.modifiers('modifyAttack', user.attachments.all(), this, target)
|
|
modify = @modify(modify, Query.modifiers('modifyAttackTarget', target.attachments.all(), this, user))
|
|
|
|
getType: (battle, user, target) ->
|
|
type = user.editMoveType(@type, target)
|
|
type
|
|
|
|
pickAttackStat: (user, target) ->
|
|
stat = (if @isPhysical() then 'attack' else 'specialAttack')
|
|
stat = 'specialAttack' if user.hasAbility("Spectral Jaws") and @hasFlag("bite")
|
|
user.stat(stat, ignoreNegativeBoosts: user.crit, ignoreOffense: target.hasAbility("Unaware"))
|
|
|
|
pickDefenseStat: (user, target) ->
|
|
stat = (if @isPhysical() then 'defense' else 'specialDefense')
|
|
stat = 'specialDefense' if user.hasAbility("Spectral Jaws") and @hasFlag("bite")
|
|
target.stat(stat, ignorePositiveBoosts: user.crit, ignoreDefense: user.hasAbility("Unaware"))
|
|
|
|
toString: ->
|
|
"[Move name:#{@name}]"
|