diff --git a/internal/characters/neuvillette/asc.go b/internal/characters/neuvillette/asc.go new file mode 100644 index 0000000000..5ac4448957 --- /dev/null +++ b/internal/characters/neuvillette/asc.go @@ -0,0 +1,88 @@ +package neuvillette + +import ( + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/event" + "github.com/genshinsim/gcsim/pkg/core/player/character" + "github.com/genshinsim/gcsim/pkg/gadget" + "github.com/genshinsim/gcsim/pkg/modifier" +) + +type NeuvA1Keys struct { + Evt event.Event + Key string +} + +var a1Multipliers = [4]float64{1, 1.1, 1.25, 1.6} + +func (c *char) a1() { + a1 := []NeuvA1Keys{ + {event.OnBloom, "neuvillette-a1-bloom"}, + {event.OnCrystallizeHydro, "neuvillette-a1-crystallize-hydro"}, + {event.OnElectroCharged, "neuvillette-a1-electro-charged"}, + {event.OnFrozen, "neuvillette-a1-frozen"}, + {event.OnSwirlHydro, "neuvillette-a1-swirl-hydro"}, + {event.OnVaporize, "neuvillette-a1-vaporize"}, + } + + c.a1Statuses = append(c.a1Statuses, + a1..., + ) + + for _, val := range a1 { + // need to make a copy of key for the status key + key := val.Key + c.Core.Events.Subscribe(val.Evt, func(args ...interface{}) bool { + if _, ok := args[0].(*gadget.Gadget); ok { + return false + } + c.AddStatus(key, 30*60, true) + return false + }, key) + } +} + +func (c *char) countA1() int { + if c.Base.Ascension < 1 { + return 0 + } + a1TriggeredReactionsCount := 0 + for _, val := range c.a1Statuses { + if c.StatusIsActive(val.Key) { + a1TriggeredReactionsCount += 1 + } + if a1TriggeredReactionsCount == 3 { + break + } + } + return a1TriggeredReactionsCount +} + +func (c *char) a4() { + c.AddStatMod(character.StatMod{ + Base: modifier.NewBase("neuvillette-a4", -1), + AffectedStat: attributes.HydroP, + Extra: true, + Amount: func() ([]float64, bool) { + return c.a4Buff, true + }, + }) + + c.a4Tick() +} + +func (c *char) a4Tick() { + hpRatio := c.CurrentHPRatio() + hydroDmgBuff := (hpRatio - 0.3) * 0.6 + + if hydroDmgBuff < 0 { + hydroDmgBuff = 0 + } else if hydroDmgBuff > 0.3 { + hydroDmgBuff = 0.3 + } + + c.a4Buff[attributes.HydroP] = hydroDmgBuff + + // Tick every 2s + c.QueueCharTask(c.a4Tick, 120) +} diff --git a/internal/characters/neuvillette/attack.go b/internal/characters/neuvillette/attack.go new file mode 100644 index 0000000000..568e644517 --- /dev/null +++ b/internal/characters/neuvillette/attack.go @@ -0,0 +1,73 @@ +package neuvillette + +import ( + "fmt" + + "github.com/genshinsim/gcsim/internal/frames" + "github.com/genshinsim/gcsim/pkg/core/action" + "github.com/genshinsim/gcsim/pkg/core/attacks" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" +) + +const normalHitNum = 3 + +var ( + attackFrames [][]int + attackHitmarks = []int{19, 16, 32} + attackHitboxes = []float64{1.0, 1.0, 1.5} +) + +func init() { + attackFrames = make([][]int, normalHitNum) + + attackFrames[0] = frames.InitNormalCancelSlice(attackHitmarks[0], 36) + attackFrames[0][action.ActionAttack] = 29 + attackFrames[0][action.ActionCharge] = 20 + + attackFrames[1] = frames.InitNormalCancelSlice(attackHitmarks[1], 33) + attackFrames[1][action.ActionAttack] = 31 + attackFrames[1][action.ActionCharge] = 22 + + attackFrames[2] = frames.InitNormalCancelSlice(attackHitmarks[2], 62) + attackFrames[2][action.ActionWalk] = 61 + attackFrames[2][action.ActionCharge] = 51 +} + +func (c *char) Attack(p map[string]int) (action.Info, error) { + if c.chargeEarlyCancelled { + return action.Info{}, fmt.Errorf("%v: Cannot early cancel Charged Attack: Equitable Judgement with Normal Attack", c.CharWrapper.Base.Key) + } + + ai := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: fmt.Sprintf("Normal %v", c.NormalCounter), + AttackTag: attacks.AttackTagNormal, + ICDTag: attacks.ICDTagNormalAttack, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypeDefault, + Element: attributes.Hydro, + Durability: 25, + Mult: attack[c.NormalCounter][c.TalentLvlAttack()], + } + + c.Core.QueueAttack( + ai, + combat.NewCircleHitOnTarget( + c.Core.Combat.PrimaryTarget(), + nil, + attackHitboxes[c.NormalCounter], + ), + attackHitmarks[c.NormalCounter], + attackHitmarks[c.NormalCounter], + ) + + defer c.AdvanceNormalIndex() + + return action.Info{ + Frames: frames.NewAttackFunc(c.Character, attackFrames), + AnimationLength: attackFrames[c.NormalCounter][action.InvalidAction], + CanQueueAfter: attackFrames[c.NormalCounter][action.ActionSwap], + State: action.NormalAttackState, + }, nil +} diff --git a/internal/characters/neuvillette/burst.go b/internal/characters/neuvillette/burst.go new file mode 100644 index 0000000000..9e72ab01f0 --- /dev/null +++ b/internal/characters/neuvillette/burst.go @@ -0,0 +1,145 @@ +package neuvillette + +import ( + "fmt" + + "github.com/genshinsim/gcsim/internal/common" + "github.com/genshinsim/gcsim/internal/frames" + "github.com/genshinsim/gcsim/pkg/core/action" + "github.com/genshinsim/gcsim/pkg/core/attacks" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" + "github.com/genshinsim/gcsim/pkg/core/geometry" + "github.com/genshinsim/gcsim/pkg/core/glog" +) + +var burstFrames []int +var burstHitmarks = [3]int{95, 95 + 40, 95 + 40 + 19} + +var dropletPosOffsets = [][][]float64{{{0, 7}, {-1, 7.5}, {0.8, 6.5}}, {{-3.5, 7.5}, {-2.5, 6}}, {{3.3, 6}}} +var dropletRandomRanges = [][]float64{{0.5, 2}, {0.5, 1.2}, {0.5, 1.2}} + +var defaultBurstAtkPosOffsets = [][]float64{{-3, 7.5}, {4, 6}} +var burstTickTargetXOffsets = []float64{1.5, -1.5} + +func init() { + burstFrames = frames.InitAbilSlice(135) + burstFrames[action.ActionCharge] = 133 + burstFrames[action.ActionSkill] = 127 + burstFrames[action.ActionDash] = 127 + burstFrames[action.ActionJump] = 128 + burstFrames[action.ActionWalk] = 134 + burstFrames[action.ActionSwap] = 120 +} + +func (c *char) Burst(p map[string]int) (action.Info, error) { + c.chargeEarlyCancelled = false + player := c.Core.Combat.Player() + + aiInitialHit := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: "O Tides, I Have Returned: Skill DMG", + AttackTag: attacks.AttackTagElementalBurst, + ICDTag: attacks.ICDTagElementalBurst, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypeDefault, + Element: attributes.Hydro, + Durability: 25, + FlatDmg: burst[c.TalentLvlBurst()] * c.MaxHP(), + } + aiWaterfall := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: "O Tides, I Have Returned: Waterfall DMG", + AttackTag: attacks.AttackTagElementalBurst, + ICDTag: attacks.ICDTagElementalBurst, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypeDefault, + Element: attributes.Hydro, + Durability: 25, + FlatDmg: burstWaterfall[c.TalentLvlBurst()] * c.MaxHP(), + } + for i := 0; i < 3; i++ { + ix := i // avoid closure issue + + dropletCount := 3 - ix + ai := aiInitialHit + if ix > 0 { + ai = aiWaterfall + } + + c.QueueCharTask(func() { + // spawn droplets for current tick using random point from player pos with offset + for j := 0; j < dropletCount; j++ { + common.NewSourcewaterDroplet( + c.Core, + geometry.CalcRandomPointFromCenter( + geometry.CalcOffsetPoint( + player.Pos(), + geometry.Point{X: dropletPosOffsets[ix][j][0], Y: dropletPosOffsets[ix][j][1]}, + player.Direction(), + ), + dropletRandomRanges[ix][0], + dropletRandomRanges[ix][1], + c.Core.Rand, + ), + combat.GadgetTypSourcewaterDropletNeuv, + ) + } + c.Core.Combat.Log.NewEvent(fmt.Sprint("Burst: Spawned ", dropletCount, " droplets"), glog.LogCharacterEvent, c.Index) + + // determine attack pattern + // initial tick + ap := combat.NewCircleHitOnTarget(player, geometry.Point{Y: 1}, 8) + // 2nd and 3rd tick + if ix > 0 { + // determine attack pattern pos + // default assumption: no target in range -> ticks should spawn at specific offset from player + apPos := geometry.CalcOffsetPoint( + player.Pos(), + geometry.Point{ + X: defaultBurstAtkPosOffsets[ix-1][0], + Y: defaultBurstAtkPosOffsets[ix-1][1], + }, + player.Direction(), + ) + + // check if target is within range + target := c.Core.Combat.PrimaryTarget() + if target.IsWithinArea(combat.NewCircleHitOnTarget(player, nil, 10)) { + // target in range -> adjust pos + // pos is a point in random range from target pos + offset + // TODO: offset is not accurate because currently target is always looking in default direction + apPos = geometry.CalcRandomPointFromCenter( + geometry.CalcOffsetPoint( + target.Pos(), + geometry.Point{X: burstTickTargetXOffsets[ix-1]}, + target.Direction(), + ), + 0, + 1.5, + c.Core.Rand, + ) + } + // create attack pattern for tick after determining pos + ap = combat.NewCircleHitOnTarget(apPos, nil, 5) + } + + c.Core.QueueAttack( + ai, + ap, + 0, + 0, + ) + }, burstHitmarks[ix]) + } + + c.SetCD(action.ActionBurst, 18*60) + c.ConsumeEnergy(4) + + return action.Info{ + Frames: frames.NewAbilFunc(burstFrames), + AnimationLength: burstFrames[action.InvalidAction], + CanQueueAfter: burstFrames[action.ActionSwap], // earliest cancel + State: action.BurstState, + }, nil +} diff --git a/internal/characters/neuvillette/charge.go b/internal/characters/neuvillette/charge.go new file mode 100644 index 0000000000..6c3c6c1776 --- /dev/null +++ b/internal/characters/neuvillette/charge.go @@ -0,0 +1,303 @@ +package neuvillette + +import ( + "fmt" + + "github.com/genshinsim/gcsim/internal/common" + "github.com/genshinsim/gcsim/internal/frames" + "github.com/genshinsim/gcsim/pkg/core/action" + "github.com/genshinsim/gcsim/pkg/core/attacks" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" + "github.com/genshinsim/gcsim/pkg/core/event" + "github.com/genshinsim/gcsim/pkg/core/glog" + "github.com/genshinsim/gcsim/pkg/core/player" +) + +var chargeFrames []int +var endLag []int + +const initialLegalEvalDur = 209 + +var dropletLegalEvalReduction = []int{0, 57, 57 + 54, 57 + 54 + 98} + +const shortChargeHitmark = 27 + +const chargeJudgementName = "Charged Attack: Equitable Judgment" + +func init() { + chargeFrames = frames.InitAbilSlice(87) + chargeFrames[action.ActionCharge] = 69 + chargeFrames[action.ActionSkill] = 26 + chargeFrames[action.ActionBurst] = 27 + chargeFrames[action.ActionDash] = 25 + chargeFrames[action.ActionJump] = 26 + chargeFrames[action.ActionWalk] = 61 + chargeFrames[action.ActionSwap] = 58 + + endLag = frames.InitAbilSlice(51) + endLag[action.ActionWalk] = 36 + endLag[action.ActionCharge] = 30 + endLag[action.ActionSwap] = 27 + endLag[action.ActionBurst] = 0 + endLag[action.ActionSkill] = 0 + endLag[action.ActionDash] = 0 + endLag[action.ActionJump] = 0 +} + +func (c *char) legalEvalFindDroplets() int { + droplets := c.getSourcewaterDroplets() + + // TODO: If droplets time out before the "droplet check" it doesn't count. + indices := c.Core.Combat.Rand.Perm(len(droplets)) + orbs := 0 + for _, ind := range indices { + g := droplets[ind] + c.consumeDroplet(g) + orbs += 1 + if orbs >= 3 { + break + } + } + c.Core.Combat.Log.NewEvent(fmt.Sprint("Picked up ", orbs, " droplets"), glog.LogCharacterEvent, c.Index) + return orbs +} + +func (c *char) ChargeAttack(p map[string]int) (action.Info, error) { + if c.chargeEarlyCancelled { + return action.Info{}, fmt.Errorf("%v: Cannot early cancel Charged Attack: Equitable Judgement with Charged Attack", c.CharWrapper.Base.Key) + } + // there is a windup out of dash/jump/walk/swap. Otherwise it is rolled into the Q/E/CA/NA -> CA frames + windup := 0 + switch c.Core.Player.CurrentState() { + case action.Idle, action.DashState, action.JumpState, action.WalkState, action.SwapState: + windup = 14 + } + + if p["short"] != 0 { + return c.chargeAttackShort(windup) + } + + return c.chargeAttackJudgement(p, windup) +} + +func (c *char) chargeAttackJudgement(p map[string]int, windup int) (action.Info, error) { + c.chargeJudgeDur = 0 + c.tickAnimLength = getChargeJudgementHitmarkDelay(0) + // current framework doesn't really support actions getting shorter, so the legal eval is set to 0, but it may increase later + chargeLegalEvalLeft := 0 + + c.QueueCharTask(func() { + chargeLegalEvalLeft = initialLegalEvalDur + orbs := c.legalEvalFindDroplets() + chargeLegalEvalLeft -= dropletLegalEvalReduction[orbs] + + c.chargeAi = combat.AttackInfo{ + ActorIndex: c.Index, + Abil: chargeJudgementName, + AttackTag: attacks.AttackTagExtra, + ICDTag: attacks.ICDTagExtraAttack, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypePierce, + Element: attributes.Hydro, + Durability: 25, + FlatDmg: chargeJudgement[c.TalentLvlAttack()] * c.MaxHP(), + } + + c.chargeJudgeStartF = c.Core.F + chargeLegalEvalLeft + c.chargeJudgeDur = 173 + + if c.Base.Cons >= 6 { + c.QueueCharTask(c.c6DropletCheck(c.chargeJudgeStartF), chargeLegalEvalLeft) + } + + ticks, ok := p["ticks"] + if !ok { + ticks = -1 + } else if ticks < 0 { + ticks = 0 + } + + // cannot use hitlag affected queue because the logic just does not work then + // -> can't account for possible hitlag delaying the update of the anim length (sim moves on to next action, but ticks continue) + + // start counting at 1 for correct number of ticks when supplying ticks param + c.Core.Tasks.Add(c.chargeJudgementTick(c.chargeJudgeStartF, 1, ticks, false), chargeLegalEvalLeft+getChargeJudgementHitmarkDelay(1)) + + // TODO: drain timing affected by ping? + // He drains 5 times in 3s, on frame 40, 70, 100, 130, 160 + c.QueueCharTask(c.consumeHp(c.chargeJudgeStartF), chargeLegalEvalLeft+40) + }, windup+3) + + return action.Info{ + Frames: func(next action.Action) int { + return windup + 3 + chargeLegalEvalLeft + c.tickAnimLength + endLag[next] + }, + AnimationLength: 1200, // there is no upper limit on the duration of the CA + CanQueueAfter: windup + 3 + endLag[action.ActionDash], + State: action.ChargeAttackState, + }, nil +} + +func (c *char) chargeAttackShort(windup int) (action.Info, error) { + // By releasing too fast it is possible to absorb 3 orbs but not do a big CA + c.QueueCharTask(func() { + c.legalEvalFindDroplets() + // If there is not enough stamina to CA, nothing happens and he floats back down + r := 1 + c.Core.Player.StamPercentMod(action.ActionCharge) + if r < 0 { + r = 0 + } + if c.Core.Player.Stam > 50*r { + // use stam + c.Core.Player.Stam -= 50 * r + c.Core.Player.LastStamUse = c.Core.F + c.Core.Player.Events.Emit(event.OnStamUse, action.ActionCharge) + ai := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: "Charge Attack", + AttackTag: attacks.AttackTagExtra, + ICDTag: attacks.ICDTagNone, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypePierce, + Element: attributes.Hydro, + Durability: 25, + Mult: charge[c.TalentLvlAttack()], + } + ap := combat.NewBoxHitOnTarget(c.Core.Combat.Player(), nil, 3, 8) + // TODO: Not sure of snapshot timing + c.Core.QueueAttack( + ai, + ap, + shortChargeHitmark+windup, + shortChargeHitmark+windup, + ) + } + }, windup+3) + + return action.Info{ + Frames: func(next action.Action) int { return windup + chargeFrames[next] }, + AnimationLength: windup + chargeFrames[action.InvalidAction], + CanQueueAfter: windup + chargeFrames[action.ActionDash], + State: action.ChargeAttackState, + }, nil +} + +func (c *char) judgementWave() { + // calculated every hit since canqueueafter is after the first tick, so configs can change the primary target/entity positions while the CA happens + ap := combat.NewBoxHitOnTarget(c.Core.Combat.Player(), nil, 3.5, 15) + if c.Base.Ascension >= 1 { + c.chargeAi.FlatDmg = chargeJudgement[c.TalentLvlAttack()] * c.MaxHP() * a1Multipliers[c.countA1()] + } + if c.Base.Cons >= 6 { + c.Core.QueueAttack(c.chargeAi, ap, 0, 0, c.c6cb) + } else { + c.Core.QueueAttack(c.chargeAi, ap, 0, 0) + } +} + +func getChargeJudgementHitmarkDelay(tick int) int { + // first tick happens 6f after start, second tick is 22f after first, then other frames are 25f after, then last tick is when the judgement wave ends. + // TODO: check this is the case for c6 + switch tick { + case 1: + return 6 + case 2: + return 22 + default: + return 25 + } +} + +func (c *char) chargeJudgementTick(src, tick, maxTick int, last bool) func() { + return func() { + if c.chargeJudgeStartF != src { + return + } + // no longer in CA anim -> no tick + if c.Core.F > c.chargeJudgeStartF+c.chargeJudgeDur { + return + } + + // last tick -> check for C6 extension + if last { + // C6 did not extend CA -> proc wave and stop queuing ticks + if c.Core.F == c.chargeJudgeStartF+c.chargeJudgeDur { + c.judgementWave() + } else { + // C6 extended the CA between when this tick was queued and when this tick was executed + // -> allow the other non last queued task to execute by extend anim length to include that task + c.tickAnimLength = c.tickAnimLengthC6Extend + } + return + } + + // tick param supplied and hit the limit -> proc wave, enable early cancel flag for next action check and stop queuing ticks + if tick == maxTick { + c.judgementWave() + c.chargeEarlyCancelled = true + return + } + + c.judgementWave() + + // next tick handling + if maxTick == -1 || tick < maxTick { + tickDelay := getChargeJudgementHitmarkDelay(tick + 1) + // calc new animation length to be up until next tick happens + nextTickAnimLength := c.Core.F - c.chargeJudgeStartF + tickDelay + + // always queue up non-final tick that will be executed in case C6 was proc'd + c.Core.Tasks.Add(c.chargeJudgementTick(src, tick+1, maxTick, false), tickDelay) + + // queue up last tick if next tick would happen after CA duration ends + if nextTickAnimLength > c.chargeJudgeDur { + // queue up final tick to happen at end of CA duration + c.Core.Tasks.Add(c.chargeJudgementTick(src, tick+1, maxTick, true), c.chargeJudgeDur-c.tickAnimLength) + // update tickAnimLength to be equal to entire CA duration at the end + c.tickAnimLength = c.chargeJudgeDur + // if C6 is triggered, then tickAnimLength will be wrong so this var holds the actual tickAnimLength if ticks continued normally beyond the original final tick + c.tickAnimLengthC6Extend = nextTickAnimLength + } else { + // next tick happens within CA duration -> update tickAnimLength as usual + c.tickAnimLength = nextTickAnimLength + } + } + } +} + +func (c *char) consumeHp(src int) func() { + return func() { + if c.chargeJudgeStartF != src { + return + } + if c.Core.F > c.chargeJudgeStartF+c.chargeJudgeDur { + return + } + if c.CurrentHPRatio() > 0.5 { + hpDrain := 0.08 * c.MaxHP() + + c.Core.Player.Drain(player.DrainInfo{ + ActorIndex: c.Index, + Abil: "Charged Attack: Equitable Judgment", + Amount: hpDrain, + }) + } + c.QueueCharTask(c.consumeHp(src), 30) + } +} + +func (c *char) consumeDroplet(g *common.SourcewaterDroplet) { + g.Kill() + // TODO: adjust healing delay by ping amount + // the healing is slightly delayed by 5f + c.QueueCharTask(func() { + c.Core.Player.Heal(player.HealInfo{ + Caller: c.Index, + Target: c.Index, + Message: "Sourcewater Droplets Healing", + Src: c.MaxHP() * 0.16, + Bonus: c.Stat(attributes.Heal), + }) + }, 5) +} diff --git a/internal/characters/neuvillette/config.yml b/internal/characters/neuvillette/config.yml new file mode 100644 index 0000000000..53b19bbac5 --- /dev/null +++ b/internal/characters/neuvillette/config.yml @@ -0,0 +1,3 @@ +package_name: "neuvillette" +genshin_id: 10000087 +key: "neuvillette" diff --git a/internal/characters/neuvillette/cons.go b/internal/characters/neuvillette/cons.go new file mode 100644 index 0000000000..50775537b0 --- /dev/null +++ b/internal/characters/neuvillette/cons.go @@ -0,0 +1,145 @@ +package neuvillette + +import ( + "strings" + + "github.com/genshinsim/gcsim/internal/common" + "github.com/genshinsim/gcsim/pkg/core/attacks" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" + "github.com/genshinsim/gcsim/pkg/core/event" + "github.com/genshinsim/gcsim/pkg/core/geometry" + "github.com/genshinsim/gcsim/pkg/core/glog" + "github.com/genshinsim/gcsim/pkg/core/player/character" + "github.com/genshinsim/gcsim/pkg/core/targets" + "github.com/genshinsim/gcsim/pkg/modifier" +) + +const c4ICDKey = "neuvillette-c4-icd" +const c6ICDKey = "neuvillette-c6-icd" + +func (c *char) c1() { + if c.Base.Ascension < 1 { + return + } + + c1 := NeuvA1Keys{event.OnCharacterSwap, "neuvillette-a1-c1-onfield"} + c.a1Statuses = append(c.a1Statuses, c1) + c.Core.Events.Subscribe(c1.Evt, func(args ...interface{}) bool { + next := args[1].(int) + if next == c.Index { + c.AddStatus(c1.Key, 30*60, true) + } + return false + }, c1.Key) +} + +func (c *char) c2() { + if c.Base.Ascension < 1 { + return + } + c2Buff := make([]float64, attributes.EndStatType) + c.AddAttackMod(character.AttackMod{ + Base: modifier.NewBase("neuvillette-c2", -1), + Amount: func(atk *combat.AttackEvent, t combat.Target) ([]float64, bool) { + if strings.Contains(atk.Info.Abil, chargeJudgementName) { + c2Buff[attributes.CD] = 0.14 * float64(c.countA1()) + return c2Buff, true + } + return nil, false + }, + }) +} + +func (c *char) c4() { + c.Core.Events.Subscribe(event.OnHeal, func(args ...interface{}) bool { + target := args[1].(int) + + if c.Core.Player.Active() != c.Index { + return false + } + if c.Index != target { + return false + } + if c.StatusIsActive(c4ICDKey) { + return false + } + + // 4s CD + c.AddStatus(c4ICDKey, 4*60, true) + player := c.Core.Combat.Player() + common.NewSourcewaterDroplet( + c.Core, + geometry.CalcRandomPointFromCenter( + geometry.CalcOffsetPoint( + player.Pos(), + geometry.Point{Y: 8}, + player.Direction(), + ), + 1.3, + 3, + c.Core.Rand, + ), + combat.GadgetTypSourcewaterDropletNeuv, + ) + c.Core.Combat.Log.NewEvent("C4: Spawned 1 droplet", glog.LogCharacterEvent, c.Index) + + return false + }, "neuvillette-c4") +} + +func (c *char) c6DropletCheck(src int) func() { + return func() { + if c.chargeJudgeStartF != src { + return + } + + if c.Core.F > c.chargeJudgeStartF+c.tickAnimLength { + return + } + + if c.chargeJudgeStartF+c.chargeJudgeDur-c.Core.F <= 60 { + droplets := c.getSourcewaterDropletsC6() + + // c6 only absorbs one droplet at a time + if len(droplets) > 0 { + c.Core.Combat.Log.NewEvent("C6: Picked up 1 droplet", glog.LogCharacterEvent, c.Index). + Write("prev-charge-duration", c.chargeJudgeDur). + Write("curr-charge-duration", c.chargeJudgeDur+60) + + // TODO: Check if it's random + c.consumeDroplet(droplets[c.Core.Combat.Rand.Intn(len(droplets))]) + c.chargeJudgeDur += 60 + } + } + + c.QueueCharTask(c.c6DropletCheck(src), 18) + } +} + +func (c *char) c6cb(a combat.AttackCB) { + if a.Target.Type() != targets.TargettableEnemy { + return + } + if c.StatusIsActive(c6ICDKey) { + return + } + c.AddStatus(c6ICDKey, 2*60, true) + ai := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: chargeJudgementName + " (C6)", + AttackTag: attacks.AttackTagExtra, + ICDTag: attacks.ICDTagNeuvilletteC6, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypePierce, + Element: attributes.Hydro, + Durability: 25, + FlatDmg: 0.1 * c.MaxHP() * a1Multipliers[c.countA1()], + } + // C6 projectile stops on first target hit, with 0.5 rad sphere hitbox. + // Because we don't simulate the projectile, it's just a circle hit + ap := combat.NewCircleHitOnTarget(c.Core.Combat.PrimaryTarget(), nil, 0.5) + // it looks like the c6 has 29 frames of delay but I didn't count it rigourously + c.Core.QueueAttack(ai, ap, 29, 29) + c.Core.QueueAttack(ai, ap, 29, 29) +} diff --git a/internal/characters/neuvillette/dash.go b/internal/characters/neuvillette/dash.go new file mode 100644 index 0000000000..05f6851983 --- /dev/null +++ b/internal/characters/neuvillette/dash.go @@ -0,0 +1,10 @@ +package neuvillette + +import ( + "github.com/genshinsim/gcsim/pkg/core/action" +) + +func (c *char) Dash(p map[string]int) (action.Info, error) { + c.chargeEarlyCancelled = false + return c.Character.Dash(p) +} diff --git a/internal/characters/neuvillette/jump.go b/internal/characters/neuvillette/jump.go new file mode 100644 index 0000000000..ed472f2427 --- /dev/null +++ b/internal/characters/neuvillette/jump.go @@ -0,0 +1,10 @@ +package neuvillette + +import ( + "github.com/genshinsim/gcsim/pkg/core/action" +) + +func (c *char) Jump(p map[string]int) (action.Info, error) { + c.chargeEarlyCancelled = false + return c.Character.Jump(p) +} diff --git a/internal/characters/neuvillette/neuvillette.go b/internal/characters/neuvillette/neuvillette.go new file mode 100644 index 0000000000..4998bb3ce9 --- /dev/null +++ b/internal/characters/neuvillette/neuvillette.go @@ -0,0 +1,137 @@ +package neuvillette + +import ( + "math" + + "github.com/genshinsim/gcsim/internal/common" + tmpl "github.com/genshinsim/gcsim/internal/template/character" + "github.com/genshinsim/gcsim/pkg/core" + "github.com/genshinsim/gcsim/pkg/core/action" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" + "github.com/genshinsim/gcsim/pkg/core/geometry" + "github.com/genshinsim/gcsim/pkg/core/info" + "github.com/genshinsim/gcsim/pkg/core/keys" + "github.com/genshinsim/gcsim/pkg/core/player/character" +) + +func init() { + core.RegisterCharFunc(keys.Neuvillette, NewChar) +} + +type char struct { + *tmpl.Character + lastThorn int + lastSkillParticle int + chargeJudgeStartF int + chargeJudgeDur int + tickAnimLength int + tickAnimLengthC6Extend int + chargeEarlyCancelled bool + a1Statuses []NeuvA1Keys + a4Buff []float64 + chargeAi combat.AttackInfo +} + +func NewChar(s *core.Core, w *character.CharWrapper, _ info.CharacterProfile) error { + c := char{} + c.Character = tmpl.NewWithWrapper(s, w) + + c.EnergyMax = 70 + c.NormalHitNum = normalHitNum + c.NormalCon = 3 + c.BurstCon = 5 + + c.lastThorn = math.MinInt / 2 + c.lastSkillParticle = math.MinInt / 2 + + c.chargeEarlyCancelled = false + w.Character = &c + + return nil +} + +func (c *char) Init() error { + if c.Base.Ascension >= 1 { + c.a1() + } + + if c.Base.Ascension >= 4 { + c.a4Buff = make([]float64, attributes.EndStatType) + c.a4() + } + + if c.Base.Cons >= 1 { + c.c1() + } + + if c.Base.Cons >= 2 { + c.c2() + } + + if c.Base.Cons >= 4 { + c.c4() + } + + return nil +} + +func (c *char) ActionStam(a action.Action, p map[string]int) float64 { + if a == action.ActionCharge { + return 0 + } + return c.Character.ActionStam(a, p) +} + +func (c *char) getSourcewaterDroplets() []*common.SourcewaterDroplet { + player := c.Core.Combat.Player() + + // TODO: this is an approximation based on an ongoing KQM ticket (faster-neuvi-balls) + segment := combat.NewCircleHitOnTargetFanAngle(player, nil, 14, 60) + rect := combat.NewBoxHitOnTarget(player, geometry.Point{Y: -7}, 7, 14) + + droplets := make([]*common.SourcewaterDroplet, 0) + for _, g := range c.Core.Combat.Gadgets() { + droplet, ok := g.(*common.SourcewaterDroplet) + if !ok { + continue + } + if !droplet.IsWithinArea(rect) && !droplet.IsWithinArea(segment) { + continue + } + droplets = append(droplets, droplet) + } + + return droplets +} + +func (c *char) getSourcewaterDropletsC6() []*common.SourcewaterDroplet { + player := c.Core.Combat.Player() + + circle := combat.NewCircleHitOnTarget(player, nil, 15) + + droplets := make([]*common.SourcewaterDroplet, 0) + for _, g := range c.Core.Combat.Gadgets() { + droplet, ok := g.(*common.SourcewaterDroplet) + if !ok { + continue + } + if !droplet.IsWithinArea(circle) { + continue + } + droplets = append(droplets, droplet) + } + + return droplets +} + +func (c *char) Condition(fields []string) (any, error) { + switch fields[0] { + case "droplets": + return len(c.getSourcewaterDroplets()), nil + case "droplets-c6": + return len(c.getSourcewaterDropletsC6()), nil + default: + return c.Character.Condition(fields) + } +} diff --git a/internal/characters/neuvillette/skill.go b/internal/characters/neuvillette/skill.go new file mode 100644 index 0000000000..a8f1378f79 --- /dev/null +++ b/internal/characters/neuvillette/skill.go @@ -0,0 +1,153 @@ +package neuvillette + +import ( + "github.com/genshinsim/gcsim/internal/common" + "github.com/genshinsim/gcsim/internal/frames" + "github.com/genshinsim/gcsim/pkg/core/action" + "github.com/genshinsim/gcsim/pkg/core/attacks" + "github.com/genshinsim/gcsim/pkg/core/attributes" + "github.com/genshinsim/gcsim/pkg/core/combat" + "github.com/genshinsim/gcsim/pkg/core/geometry" + "github.com/genshinsim/gcsim/pkg/core/glog" + "github.com/genshinsim/gcsim/pkg/core/targets" +) + +var skillFrames []int +var skillHitmarks = [2]int{23, 60} +var skillDropletOffsets = [][][]float64{{{-1, 3}, {0, 3.8}, {1, 3}}, {{-2, 7}, {0, 8}, {2, 7}}, {{-3, 10}, {0, 11}, {3, 10}}} +var skillDropletRandomRanges = [][][]float64{{{0.5, 1.5}, {0.5, 1.5}, {0.5, 1.5}}, {{1, 2.5}, {3.5, 4}, {1, 2.5}}, {{2, 3}, {2, 4}, {2, 3}}} + +const ( + skillAlignedICD = 10 * 60 + skillAlignedICDKey = "neuvillette-aligned-icd" + + particleCount = 4 + particleICD = 0.3 * 60 + particleICDKey = "neuvillette-particle-icd" +) + +func init() { + skillFrames = frames.InitAbilSlice(42) + skillFrames[action.ActionCharge] = 21 + skillFrames[action.ActionBurst] = 30 + skillFrames[action.ActionDash] = 29 + skillFrames[action.ActionJump] = 32 + skillFrames[action.ActionWalk] = 41 + skillFrames[action.ActionSwap] = 29 + // skill -> skill is unknown +} + +func (c *char) Skill(p map[string]int) (action.Info, error) { + c.chargeEarlyCancelled = false + + ai := combat.AttackInfo{ + ActorIndex: c.Index, + Abil: "O Tears, I Shall Repay", + AttackTag: attacks.AttackTagElementalArt, + ICDTag: attacks.ICDTagNone, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypeDefault, + Element: attributes.Hydro, + Durability: 25, + FlatDmg: skill[c.TalentLvlSkill()] * c.MaxHP(), + } + // TODO: if target is out of range then pos should be player pos + Y: 8 offset + skillPos := c.Core.Combat.PrimaryTarget().Pos() + c.Core.QueueAttack( + ai, + combat.NewCircleHitOnTarget(skillPos, nil, 6), + skillHitmarks[0], //TODO: snapshot delay? + skillHitmarks[0], + c.makeDropletCB(), + c.particleCB, + ) + + aiThorn := combat.AttackInfo{ + // TODO: Apply Pneuma + ActorIndex: c.Index, + Abil: "Spiritbreath Thorn (" + c.Base.Key.Pretty() + ")", + AttackTag: attacks.AttackTagElementalArt, + ICDTag: attacks.ICDTagNone, + ICDGroup: attacks.ICDGroupDefault, + StrikeType: attacks.StrikeTypeSpear, + Element: attributes.Hydro, + Durability: 0, + Mult: thorn[c.TalentLvlSkill()], + HitlagFactor: 0.01, + CanBeDefenseHalted: true, + } + c.QueueCharTask(func() { + if c.StatusIsActive(skillAlignedICDKey) { + return + } + c.AddStatus(skillAlignedICDKey, skillAlignedICD, true) + + c.Core.QueueAttack( + aiThorn, + combat.NewCircleHitOnTarget(skillPos, nil, 4.5), + skillHitmarks[1]-skillHitmarks[0], // TODO: snapshot delay? + skillHitmarks[1]-skillHitmarks[0], + ) + }, skillHitmarks[1]) + + c.SetCDWithDelay(action.ActionSkill, 12*60, 20) + + return action.Info{ + Frames: frames.NewAbilFunc(skillFrames), + AnimationLength: skillFrames[action.InvalidAction], + CanQueueAfter: skillFrames[action.ActionCharge], // earliest cancel + State: action.SkillState, + }, nil +} + +func (c *char) particleCB(a combat.AttackCB) { + if a.Target.Type() != targets.TargettableEnemy { + return + } + if c.StatusIsActive(particleICDKey) { + return + } + c.AddStatus(particleICDKey, particleICD, true) + + c.Core.QueueParticle(c.Base.Key.String(), particleCount, attributes.Hydro, c.ParticleDelay) +} + +func (c *char) makeDropletCB() combat.AttackCBFunc { + done := false + return func(a combat.AttackCB) { + if a.Target.Type() != targets.TargettableEnemy { + return + } + if done { + return + } + done = true + + // determine which droplet offset and random ranges to use based on distance to first target hit + player := c.Core.Combat.Player() + i := 2 + if a.Target.IsWithinArea(combat.NewCircleHitOnTarget(player.Pos(), nil, 5)) { + i = 0 + } else if a.Target.IsWithinArea(combat.NewCircleHitOnTarget(player.Pos(), nil, 10)) { + i = 1 + } + + for j := 0; j < 3; j++ { + common.NewSourcewaterDroplet( + c.Core, + geometry.CalcRandomPointFromCenter( + geometry.CalcOffsetPoint( + player.Pos(), + geometry.Point{X: skillDropletOffsets[i][j][0], Y: skillDropletOffsets[i][j][1]}, + player.Direction(), + ), + skillDropletRandomRanges[i][j][0], + skillDropletRandomRanges[i][j][1], + c.Core.Rand, + ), + combat.GadgetTypSourcewaterDropletNeuv, + ) + } + c.Core.Combat.Log.NewEvent("Skill: Spawned 3 droplets", glog.LogCharacterEvent, c.Index) + } +} diff --git a/internal/characters/neuvillette/stats.go b/internal/characters/neuvillette/stats.go new file mode 100644 index 0000000000..fc02fecb81 --- /dev/null +++ b/internal/characters/neuvillette/stats.go @@ -0,0 +1,165 @@ +package neuvillette + +var ( + attack = [][]float64{ + { + 0.5458, + 0.5867, + 0.6276, + 0.6822, + 0.7231, + 0.7641, + 0.8187, + 0.8732, + 0.9278, + 0.9824, + 1.0370, + 1.0915, + 1.1598, + 1.2280, + 1.2962, + }, + { + 0.4625, + 0.4971, + 0.5318, + 0.5781, + 0.6128, + 0.6474, + 0.6937, + 0.7399, + 0.7862, + 0.8324, + 0.8787, + 0.9249, + 0.9827, + 1.0405, + 1.0983, + }, + { + 0.7234, + 0.7776, + 0.8319, + 0.9042, + 0.9585, + 1.0127, + 1.0851, + 1.1574, + 1.2297, + 1.3021, + 1.3744, + 1.4468, + 1.5372, + 1.6276, + 1.7180, + }, + } + + charge = []float64{ + 1.3680, + 1.4706, + 1.5732, + 1.7100, + 1.8126, + 1.9152, + 2.0520, + 2.1888, + 2.3256, + 2.4624, + 2.5992, + 2.7360, + 2.9070, + 3.0780, + 3.2490, + } + + chargeJudgement = []float64{ + 0.0732, + 0.0791, + 0.0851, + 0.0936, + 0.0996, + 0.1064, + 0.1157, + 0.1251, + 0.1345, + 0.1447, + 0.1549, + 0.1651, + 0.1753, + 0.1855, + 0.1957, + } + + skill = []float64{ + 0.1286, + 0.1383, + 0.1479, + 0.1608, + 0.1704, + 0.1801, + 0.1930, + 0.2058, + 0.2187, + 0.2316, + 0.2444, + 0.2573, + 0.2734, + 0.2894, + 0.3055, + } + + thorn = []float64{ + 0.2080, + 0.2236, + 0.2392, + 0.2600, + 0.2756, + 0.2912, + 0.3120, + 0.3328, + 0.3536, + 0.3744, + 0.3952, + 0.4160, + 0.4420, + 0.4680, + 0.4940, + } + + burst = []float64{ + 0.2226, + 0.2393, + 0.2560, + 0.2782, + 0.2949, + 0.3116, + 0.3339, + 0.3561, + 0.3784, + 0.4006, + 0.4229, + 0.4452, + 0.4730, + 0.5008, + 0.5286, + } + + burstWaterfall = []float64{ + 0.0911, + 0.0979, + 0.1047, + 0.1138, + 0.1206, + 0.1275, + 0.1366, + 0.1457, + 0.1548, + 0.1639, + 0.1730, + 0.1821, + 0.1935, + 0.2049, + 0.2163, + } +) diff --git a/internal/characters/neuvillette/walk.go b/internal/characters/neuvillette/walk.go new file mode 100644 index 0000000000..4a464c851d --- /dev/null +++ b/internal/characters/neuvillette/walk.go @@ -0,0 +1,14 @@ +package neuvillette + +import ( + "fmt" + + "github.com/genshinsim/gcsim/pkg/core/action" +) + +func (c *char) Walk(p map[string]int) (action.Info, error) { + if c.chargeEarlyCancelled { + return action.Info{}, fmt.Errorf("%v: Cannot early cancel Charged Attack: Equitable Judgement with Walk", c.CharWrapper.Base.Key) + } + return c.Character.Walk(p) +} diff --git a/internal/characters/ningguang/attack.go b/internal/characters/ningguang/attack.go index c84b176cea..572db00e27 100644 --- a/internal/characters/ningguang/attack.go +++ b/internal/characters/ningguang/attack.go @@ -119,7 +119,6 @@ func (c *char) Attack(p map[string]int) (action.Info, error) { c.prevAttack = nextAttack atkspd := c.Stat(attributes.AtkSpd) - return action.Info{ Frames: func(next action.Action) int { return frames.AtkSpdAdjust(attackFrames[nextAttack][next], atkspd) diff --git a/internal/characters/traveler/common/hydro/asc.go b/internal/characters/traveler/common/hydro/asc.go index 242fe7497e..8fb16eb124 100644 --- a/internal/characters/traveler/common/hydro/asc.go +++ b/internal/characters/traveler/common/hydro/asc.go @@ -31,9 +31,8 @@ func (c *char) makeA1CB() combat.AttackCBFunc { } count++ - droplet := c.newDropblet() + droplet := c.newDroplet() c.Core.Combat.AddGadget(droplet) - c.droplets = append(c.droplets, droplet) c.AddStatus(a1ICDKey, 60, true) } } @@ -71,7 +70,7 @@ func (c *char) a1PickUp(count int) { } } -func (c *char) newDropblet() *common.SourcewaterDroplet { +func (c *char) newDroplet() *common.SourcewaterDroplet { player := c.Core.Combat.Player() pos := geometry.CalcRandomPointFromCenter( geometry.CalcOffsetPoint( @@ -83,17 +82,6 @@ func (c *char) newDropblet() *common.SourcewaterDroplet { 3, c.Core.Rand, ) - - droplet := common.NewSourcewaterDroplet(c.Core, pos) - remove := func() { - for i, g := range c.droplets { - if g.Key() == droplet.Key() { - c.droplets = append(c.droplets[:i], c.droplets[i+1:]...) // delete from the array - break - } - } - } - droplet.OnExpiry = remove - droplet.OnKill = remove + droplet := common.NewSourcewaterDroplet(c.Core, pos, combat.GadgetTypSourcewaterDropletHydroTrav) return droplet } diff --git a/internal/characters/traveler/common/hydro/travelerhydro.go b/internal/characters/traveler/common/hydro/travelerhydro.go index 0da7fd9d3e..e08f0a1dd1 100644 --- a/internal/characters/traveler/common/hydro/travelerhydro.go +++ b/internal/characters/traveler/common/hydro/travelerhydro.go @@ -1,7 +1,6 @@ package hydro import ( - "github.com/genshinsim/gcsim/internal/common" tmpl "github.com/genshinsim/gcsim/internal/template/character" "github.com/genshinsim/gcsim/pkg/core" "github.com/genshinsim/gcsim/pkg/core/attributes" @@ -11,9 +10,8 @@ import ( type char struct { *tmpl.Character - droplets []*common.SourcewaterDroplet - a4Bonus float64 - gender int + a4Bonus float64 + gender int } func NewChar(gender int) core.NewCharacterFunc { @@ -36,16 +34,5 @@ func NewChar(gender int) core.NewCharacterFunc { } func (c *char) Init() error { - c.droplets = make([]*common.SourcewaterDroplet, 0) - return nil } - -func (c *char) Condition(fields []string) (any, error) { - switch fields[0] { - case "droplets": - return len(c.droplets), nil - default: - return c.Character.Condition(fields) - } -} diff --git a/internal/characters/wanderer/attack.go b/internal/characters/wanderer/attack.go index 474c049917..ed443ec315 100644 --- a/internal/characters/wanderer/attack.go +++ b/internal/characters/wanderer/attack.go @@ -116,7 +116,6 @@ func (c *char) Attack(p map[string]int) (action.Info, error) { defer c.AdvanceNormalIndex() atkspd := c.Stat(attributes.AtkSpd) - return action.Info{ Frames: func(next action.Action) int { return windup + @@ -170,7 +169,6 @@ func (c *char) WindfavoredAttack(p map[string]int) (action.Info, error) { defer c.AdvanceNormalIndex() atkspd := c.Stat(attributes.AtkSpd) - return action.Info{ Frames: func(next action.Action) int { return windup + diff --git a/internal/characters/wanderer/charge.go b/internal/characters/wanderer/charge.go index 6cac96f630..d39172562a 100644 --- a/internal/characters/wanderer/charge.go +++ b/internal/characters/wanderer/charge.go @@ -64,9 +64,7 @@ func (c *char) ChargeAttack(p map[string]int) (action.Info, error) { c.makeA1ElectroCB(), c.particleCB, ) - atkspd := c.Stat(attributes.AtkSpd) - return action.Info{ Frames: func(next action.Action) int { return windup + @@ -103,9 +101,7 @@ func (c *char) WindfavoredChargeAttack(p map[string]int) (action.Info, error) { c.makeA1ElectroCB(), c.particleCB, ) - atkspd := c.Stat(attributes.AtkSpd) - return action.Info{ Frames: func(next action.Action) int { return windup + diff --git a/internal/common/normalattackstate.go b/internal/common/normalattackstate.go index dda516061c..300e77e02e 100644 --- a/internal/common/normalattackstate.go +++ b/internal/common/normalattackstate.go @@ -131,6 +131,7 @@ func init() { percentDelay5[keys.Lisa] = 0 percentDelay5[keys.Mona] = 0 percentDelay5[keys.Klee] = 0 + percentDelay5[keys.Neuvillette] = 0 } func Get5PercentN0Delay(activeChar *character.CharWrapper) int { @@ -158,6 +159,8 @@ func Get0PercentN0Delay(activeChar *character.CharWrapper) int { return 6 case keys.Lyney: return 5 + case keys.Neuvillette: + return 0 } return 0 } diff --git a/internal/common/sourcewaterdroplet.go b/internal/common/sourcewaterdroplet.go index e88d414d30..ed8f27f6dc 100644 --- a/internal/common/sourcewaterdroplet.go +++ b/internal/common/sourcewaterdroplet.go @@ -4,6 +4,8 @@ import ( "github.com/genshinsim/gcsim/pkg/core" "github.com/genshinsim/gcsim/pkg/core/combat" "github.com/genshinsim/gcsim/pkg/core/geometry" + "github.com/genshinsim/gcsim/pkg/core/glog" + "github.com/genshinsim/gcsim/pkg/core/targets" "github.com/genshinsim/gcsim/pkg/gadget" ) @@ -11,16 +13,20 @@ type SourcewaterDroplet struct { *gadget.Gadget } -func NewSourcewaterDroplet(core *core.Core, pos geometry.Point) *SourcewaterDroplet { +func NewSourcewaterDroplet(core *core.Core, pos geometry.Point, typ combat.GadgetTyp) *SourcewaterDroplet { p := &SourcewaterDroplet{} - p.Gadget = gadget.New(core, pos, 0.3, combat.GadgetTypSourcewaterDroplet) + p.Gadget = gadget.New(core, pos, 0.3, typ) p.Gadget.Duration = 878 core.Combat.AddGadget(p) return p } + func (s *SourcewaterDroplet) HandleAttack(*combat.AttackEvent) float64 { return 0 } func (s *SourcewaterDroplet) SetDirection(trg geometry.Point) {} func (s *SourcewaterDroplet) SetDirectionToClosestEnemy() {} func (s *SourcewaterDroplet) CalcTempDirection(trg geometry.Point) geometry.Point { return geometry.DefaultDirection() } + +func (s *SourcewaterDroplet) Type() targets.TargettableType { return targets.TargettableGadget } +func (s *SourcewaterDroplet) Attack(*combat.AttackEvent, glog.Event) (float64, bool) { return 0, false } diff --git a/pkg/conditional/gadgets.go b/pkg/conditional/gadgets.go index b17cf705ae..85f6abcd73 100644 --- a/pkg/conditional/gadgets.go +++ b/pkg/conditional/gadgets.go @@ -3,6 +3,7 @@ package conditional import ( "fmt" + "github.com/genshinsim/gcsim/internal/common" "github.com/genshinsim/gcsim/pkg/core" "github.com/genshinsim/gcsim/pkg/reactable" ) @@ -14,6 +15,8 @@ func evalGadgets(c *core.Core, fields []string) (int, error) { switch fields[1] { case "dendrocore": return evalDendroCore(c, fields[2]) + case "sourcewaterdroplet": + return evalSourcewaterDroplet(c, fields[2]) default: return 0, fmt.Errorf("bad gadgets condition: invalid criteria %v", fields[1]) } @@ -33,3 +36,18 @@ func evalDendroCore(c *core.Core, key string) (int, error) { return 0, fmt.Errorf("bad gadgets (dendrocore) condition: invalid criteria %v", key) } } + +func evalSourcewaterDroplet(c *core.Core, key string) (int, error) { + switch key { + case "count": + count := 0 + for i := 0; i < c.Combat.GadgetCount(); i++ { + if _, ok := c.Combat.Gadget(i).(*common.SourcewaterDroplet); ok { + count++ + } + } + return count, nil + default: + return 0, fmt.Errorf("bad gadgets (sourcewaterdroplet) condition: invalid criteria %v", key) + } +} diff --git a/pkg/core/attacks/icd.go b/pkg/core/attacks/icd.go index d701b02471..e686b68394 100644 --- a/pkg/core/attacks/icd.go +++ b/pkg/core/attacks/icd.go @@ -53,6 +53,7 @@ const ( ICDTagLyneyEndBoom ICDTagLyneyEndBoomEnhanced ICDTagTravelerDewdrop + ICDTagNeuvilletteC6 ICDTagLength ) diff --git a/pkg/core/combat/gadget.go b/pkg/core/combat/gadget.go index 7ee328091c..e5febc121f 100644 --- a/pkg/core/combat/gadget.go +++ b/pkg/core/combat/gadget.go @@ -20,7 +20,8 @@ const ( GadgetTypYueguiJumping GadgetTypBaronBunny GadgetTypGrinMalkinHat - GadgetTypSourcewaterDroplet + GadgetTypSourcewaterDropletHydroTrav + GadgetTypSourcewaterDropletNeuv GadgetTypTest EndGadgetTyp ) @@ -34,7 +35,8 @@ func init() { gadgetLimits[GadgetTypLeaLotus] = 1 gadgetLimits[GadgetTypYueguiThrowing] = 2 gadgetLimits[GadgetTypYueguiJumping] = 3 - gadgetLimits[GadgetTypSourcewaterDroplet] = 12 + gadgetLimits[GadgetTypSourcewaterDropletHydroTrav] = 12 + gadgetLimits[GadgetTypSourcewaterDropletNeuv] = 12 } type Gadget interface { diff --git a/pkg/core/curves/charactercurves.go b/pkg/core/curves/charactercurves.go index 98c3dd9f3e..24bd6b49f2 100644 --- a/pkg/core/curves/charactercurves.go +++ b/pkg/core/curves/charactercurves.go @@ -4622,4 +4622,69 @@ var CharBaseMap = map[keys.Char]CharBase{ }, }, }, + keys.Neuvillette: { + Rarity: 5, + Body: info.BodyMale, + Element: attributes.Hydro, + Region: info.ZoneFontaine, + WeaponClass: info.WeaponClassCatalyst, + HPCurve: GROW_CURVE_HP_S5, + AtkCurve: GROW_CURVE_ATTACK_S5, + DefCurve: GROW_CURVE_HP_S5, + BaseHP: 1143.9840087890625, + BaseAtk: 16.218019485473633, + BaseDef: 44.872501373291016, + Specialized: attributes.CD, + PromotionBonus: []PromoData{ + { + MaxLevel: 20, + HP: 0, + Atk: 0, + Def: 0, + Special: 0, + }, + { + MaxLevel: 40, + HP: 980.8628540039062, + Atk: 13.904470443725586, + Def: 38.474998474121094, + Special: 0, + }, + { + MaxLevel: 50, + HP: 1677.791748046875, + Atk: 23.78396224975586, + Def: 65.8125, + Special: 0.09600000083446503, + }, + { + MaxLevel: 60, + HP: 2607.0302734375, + Atk: 36.95661544799805, + Def: 102.26249694824219, + Special: 0.19200000166893005, + }, + { + MaxLevel: 70, + HP: 3303.958984375, + Atk: 46.83610916137695, + Def: 129.60000610351562, + Special: 0.19200000166893005, + }, + { + MaxLevel: 80, + HP: 4000.887939453125, + Atk: 56.71560287475586, + Def: 156.9375, + Special: 0.2879999876022339, + }, + { + MaxLevel: 90, + HP: 4697.81689453125, + Atk: 66.5950927734375, + Def: 184.27499389648438, + Special: 0.3840000033378601, + }, + }, + }, } diff --git a/pkg/core/keys/char.go b/pkg/core/keys/char.go index 87f115beea..b3837f9b1a 100644 --- a/pkg/core/keys/char.go +++ b/pkg/core/keys/char.go @@ -127,6 +127,7 @@ const ( Kaveh Lyney Lynette + Neuvillette TestCharDoNotUse EndCharKeys ) @@ -219,6 +220,7 @@ var charNames = []string{ "kaveh", "lyney", "lynette", + "neuvillette", "test_char_do_not_use", } @@ -310,6 +312,7 @@ var charPrettyName = []string{ "Kaveh", "Lyney", "Lynette", + "Neuvillette", "!!!TEST CHAR DO NOT USE!!!", } @@ -397,5 +400,6 @@ var CharKeyToEle = map[Char]attributes.Element{ Kaveh: attributes.Dendro, Lyney: attributes.Pyro, Lynette: attributes.Anemo, + Neuvillette: attributes.Hydro, TestCharDoNotUse: attributes.Geo, } diff --git a/pkg/shortcut/characters.go b/pkg/shortcut/characters.go index c0b92f78fe..f811b50031 100644 --- a/pkg/shortcut/characters.go +++ b/pkg/shortcut/characters.go @@ -154,4 +154,7 @@ var CharNameToKey = map[string]keys.Char{ "kirara": keys.Kirara, "lyney": keys.Lyney, "lynette": keys.Lynette, + "neuvillette": keys.Neuvillette, + "neuv": keys.Neuvillette, + "chiefjusticeoffontaine": keys.Neuvillette, } diff --git a/pkg/simulation/imports.go b/pkg/simulation/imports.go index 3ba6d8f176..6052ca42af 100644 --- a/pkg/simulation/imports.go +++ b/pkg/simulation/imports.go @@ -96,6 +96,7 @@ import ( _ "github.com/genshinsim/gcsim/internal/characters/mika" _ "github.com/genshinsim/gcsim/internal/characters/mona" _ "github.com/genshinsim/gcsim/internal/characters/nahida" + _ "github.com/genshinsim/gcsim/internal/characters/neuvillette" _ "github.com/genshinsim/gcsim/internal/characters/nilou" _ "github.com/genshinsim/gcsim/internal/characters/ningguang" _ "github.com/genshinsim/gcsim/internal/characters/noelle" diff --git a/ui/packages/db/src/Data/char_data.generated.json b/ui/packages/db/src/Data/char_data.generated.json index c136cccfdb..4e963c0578 100644 --- a/ui/packages/db/src/Data/char_data.generated.json +++ b/ui/packages/db/src/Data/char_data.generated.json @@ -840,6 +840,21 @@ "burst_energy_cost": 50 } }, + "neuvillette": { + "id": 10000087, + "key": "neuvillette", + "rarity": "QUALITY_ORANGE", + "body": "BODY_MALE", + "element": "Water", + "weapon_class": "WEAPON_CATALYST", + "icon_name": "UI_AvatarIcon_Neuvillette", + "skill_details": { + "skill": 10872, + "burst": 10875, + "attack": 10871, + "burst_energy_cost": 70 + } + }, "nilou": { "id": 10000070, "key": "nilou", diff --git a/ui/packages/docs/docs/reference/fields.md b/ui/packages/docs/docs/reference/fields.md index dffbadb077..f31823f00e 100644 --- a/ui/packages/docs/docs/reference/fields.md +++ b/ui/packages/docs/docs/reference/fields.md @@ -27,6 +27,7 @@ For example, if you are looking for the tag for Lisa's A4 (defense shred), then | `stam` | - | - | - | Evaluates to the player's remaining stamina. | | `construct` | `duration`/`count` | construct name | - | Evaluates to the duration/count of the specified construct. See individual character page for acceptable construct names. | | `gadgets` | `dendrocore` | `count` | - | Evaluates to the current number of Dendro Cores. | +| `gadgets` | `sourcewaterdroplet` | `count` | - | Evaluates to the current number of Sourcewater Droplets. Use character specific fields to get number of Sourcewater Droplets in range. | | `keys` | `char`/`weapon`/`artifact` | char/weapon/artifact name | - | Evaluates to the key for the specified char/weapon/artifact name. See the relevant character/weapon/artifact page for acceptable names. | | `state` | - | - | - | Evaluates to the current state of the player. | | character name | `cons` | - | - | Evaluates to the character's constellation count. | diff --git a/ui/packages/docs/src/components/Actions/character_data.json b/ui/packages/docs/src/components/Actions/character_data.json index 702315949d..256b36195d 100644 --- a/ui/packages/docs/src/components/Actions/character_data.json +++ b/ui/packages/docs/src/components/Actions/character_data.json @@ -1408,6 +1408,40 @@ "legal": true } ], + "neuvillette": [ + { + "ability": "normal", + "legal": true + }, + { + "ability": "charge", + "legal": true + }, + { + "ability": "skill", + "legal": true + }, + { + "ability": "burst", + "legal": true + }, + { + "ability": "dash", + "legal": true + }, + { + "ability": "jump", + "legal": true + }, + { + "ability": "walk", + "legal": true + }, + { + "ability": "swap", + "legal": true + } + ], "nilou": [ { "ability": "normal", @@ -2550,4 +2584,4 @@ "legal": true } ] -} +} \ No newline at end of file diff --git a/ui/packages/docs/src/components/AoE/character_data.json b/ui/packages/docs/src/components/AoE/character_data.json index 09cde2b05c..a17922323d 100644 --- a/ui/packages/docs/src/components/AoE/character_data.json +++ b/ui/packages/docs/src/components/AoE/character_data.json @@ -4422,6 +4422,83 @@ } ] }, + "neuvillette": { + "normal": [ + { + "ability": "N1", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 1.0 + }, + { + "ability": "N2", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 1.0 + }, + { + "ability": "N3", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 1.5 + } + ], + "charge": [ + { + "ability": "CA-Judgement", + "shape": "Box", + "center": "Player", + "boxX": 3.5, + "boxY": 15 + }, + { + "ability": "CA", + "shape": "Box", + "center": "Player", + "boxX": 3, + "boxY": 8 + } + ], + "skill": [ + { + "ability": "E-Initial", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 6 + }, + { + "ability": "E-Spiritbreath-Thorn", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 4.5 + } + ], + "burst": [ + { + "ability": "Q-Initial", + "shape": "Circle", + "center": "Player", + "radius": 8, + "offsetY": 1 + }, + { + "ability": "Q-Waterfall", + "shape": "Circle", + "center": "GlobalValue", + "radius": 5, + "notes": "There are 2 attacks. If there is a target within 10m, they will spawn with centers randomly placed in a 1.5m radius circle from target pos, with offsetX -1.5/1.5, respectively, using the target's direction. Otherwise, the two attacks will spawn on the player with offsetX -3/4 and offsetY 7.5/6, respectively." + } + ], + "cons": [ + { + "ability": "C6", + "shape": "Circle", + "center": "PrimaryTarget", + "radius": 0.5, + "notes": "C6 projectile mechanics (collision) are not properly implemented yet." + } + ] + }, "nilou": { "normal": [ { diff --git a/ui/packages/docs/src/components/Fields/character_data.json b/ui/packages/docs/src/components/Fields/character_data.json index 4108e9c9ed..d2c121745d 100644 --- a/ui/packages/docs/src/components/Fields/character_data.json +++ b/ui/packages/docs/src/components/Fields/character_data.json @@ -120,7 +120,7 @@ ], "desc": "Number of Prop Surplus stacks." } - ], + ], "ningguang": [ { "fields": [ @@ -167,12 +167,18 @@ "desc": "Number of Declension stacks." } ], - "travelerhydro": [ + "neuvillette": [ + { + "fields": [ + ".neuvillette.droplets" + ], + "desc": "Number of Sourcewater droplets in range for Charged Attack Empowerment: Legal Evaluation." + }, { "fields": [ - ".travelerhydro.droplets" + ".neuvillette.droplets-c6" ], - "desc": "Number of Sourcewater droplets created by Traveler." + "desc": "Number of Sourcewater droplets in range for C6." } ] } \ No newline at end of file diff --git a/ui/packages/docs/src/components/Frames/character_data.json b/ui/packages/docs/src/components/Frames/character_data.json index 0319561937..b5402f4482 100644 --- a/ui/packages/docs/src/components/Frames/character_data.json +++ b/ui/packages/docs/src/components/Frames/character_data.json @@ -605,6 +605,20 @@ "count": "https://docs.google.com/spreadsheets/d/1MBeFddkD4OtHdBMncqW_j18NC-lUMqA9noVo_pH26H0/edit?usp=sharing" } ], + "neuvillette": [ + { + "vid_credit": "charliex3000", + "count_credit": "dejaroo", + "vid": "https://youtu.be/I4nCOk2g17o", + "count": "https://docs.google.com/spreadsheets/d/1-gPxRq_NX8hBXLijRYroabSgT85S0ND6WiFLjwc2b_k/edit#usp=sharing" + }, + { + "vid_credit": "charliex3000", + "count_credit": "charliex3000", + "vid": "https://youtu.be/HViQwc6zGrs", + "count": "https://docs.google.com/spreadsheets/d/1-gPxRq_NX8hBXLijRYroabSgT85S0ND6WiFLjwc2b_k/edit#usp=sharing" + } + ], "nilou": [ { "vid_credit": "Kolibri#7675", diff --git a/ui/packages/docs/src/components/Hitlag/character_data.json b/ui/packages/docs/src/components/Hitlag/character_data.json index 710c63b623..0dabee46ae 100644 --- a/ui/packages/docs/src/components/Hitlag/character_data.json +++ b/ui/packages/docs/src/components/Hitlag/character_data.json @@ -1842,6 +1842,17 @@ } ] }, + "neuvillette": { + "skill": [ + { + "ability": "E-Spiritbreath-Thorn", + "hitHaltTime": 0, + "hitHaltTimeScale": 0.01, + "canBeDefenseHalt": true, + "deployable": false + } + ] + }, "nilou": { "normal": [ { diff --git a/ui/packages/docs/src/components/Names/character_data.json b/ui/packages/docs/src/components/Names/character_data.json index 465deee215..ab43266b64 100644 --- a/ui/packages/docs/src/components/Names/character_data.json +++ b/ui/packages/docs/src/components/Names/character_data.json @@ -86,6 +86,10 @@ "kusanali", "lesserlordkusanali" ], + "neuvillette": [ + "neuv", + "chiefjusticeoffontaine" + ], "nilou": [], "ningguang": [ "ning" @@ -176,4 +180,4 @@ "zhong", "zl" ] - } \ No newline at end of file +} \ No newline at end of file diff --git a/ui/packages/docs/src/components/Params/character_data.json b/ui/packages/docs/src/components/Params/character_data.json index a809047118..1df5d6659d 100644 --- a/ui/packages/docs/src/components/Params/character_data.json +++ b/ui/packages/docs/src/components/Params/character_data.json @@ -831,5 +831,17 @@ "param": "pickup_droplets", "desc": "Number of picked up Sourcewater Droplets. Default 0." } + ], + "neuvillette": [ + { + "ability": "charge", + "param": "short", + "desc": "0 for Charged Attack: Equitable Judgment (default), 1 for Charged Attack. Charged Attack will still absorb droplets if possible. This mirrors in game behaviour." + }, + { + "ability": "charge", + "param": "ticks", + "desc": "Number of ticks for Charged Attack: Equitable Judgment. Default is maximum number of ticks, minimum 1. Only works if short = 0. If the number of ticks is not the maximum, the next action must be Burst, Skill, Dash, or Jump." + } ] } \ No newline at end of file diff --git a/ui/packages/ui/src/Data/char_data.generated.json b/ui/packages/ui/src/Data/char_data.generated.json index c136cccfdb..4e963c0578 100644 --- a/ui/packages/ui/src/Data/char_data.generated.json +++ b/ui/packages/ui/src/Data/char_data.generated.json @@ -840,6 +840,21 @@ "burst_energy_cost": 50 } }, + "neuvillette": { + "id": 10000087, + "key": "neuvillette", + "rarity": "QUALITY_ORANGE", + "body": "BODY_MALE", + "element": "Water", + "weapon_class": "WEAPON_CATALYST", + "icon_name": "UI_AvatarIcon_Neuvillette", + "skill_details": { + "skill": 10872, + "burst": 10875, + "attack": 10871, + "burst_energy_cost": 70 + } + }, "nilou": { "id": 10000070, "key": "nilou", diff --git a/ui/packages/ui/src/Translation/locales/IngameNames.json b/ui/packages/ui/src/Translation/locales/IngameNames.json index 3bf7d97df6..484a22eaf8 100644 --- a/ui/packages/ui/src/Translation/locales/IngameNames.json +++ b/ui/packages/ui/src/Translation/locales/IngameNames.json @@ -119,6 +119,7 @@ "lyney": "Lyney", "lynette": "Lynette", "freminet": "Freminet", + "neuvillette": "Neuvillette", "aetherelectro": "Aether (Electro)", "lumineelectro": "Lumine (Electro)", "aetheranemo": "Aether (Anemo)", @@ -421,6 +422,7 @@ "lyney": "林尼", "lynette": "琳妮特", "freminet": "菲米尼", + "neuvillette": "那维莱特", "aetherelectro": "空 (雷元素)", "lumineelectro": "荧 (雷元素)", "aetheranemo": "空 (风元素)", @@ -723,6 +725,7 @@ "lyney": "リネ", "lynette": "リネット", "freminet": "フレミネ", + "neuvillette": "ヌヴィレット", "aetherelectro": "空 (雷元素)", "lumineelectro": "蛍 (雷元素)", "aetheranemo": "空 (風元素)", @@ -1025,6 +1028,7 @@ "lyney": "Lyney", "lynette": "Lynette", "freminet": "Fréminet", + "neuvillette": "Neuvillette", "aetherelectro": "Éter (Electro)", "lumineelectro": "Lumina (Electro)", "aetheranemo": "Éter (Anemo)", @@ -1327,6 +1331,7 @@ "lyney": "Лини", "lynette": "Линетт", "freminet": "Фремине", + "neuvillette": "Нёвиллет", "aetherelectro": "Итэр (Электро)", "lumineelectro": "Люмин (Электро)", "aetheranemo": "Итэр (Анемо)", @@ -1629,6 +1634,7 @@ "lyney": "Lyney", "lynette": "Lynette", "freminet": "Fréminet", + "neuvillette": "Neuvillette", "aetherelectro": "Leer (Elektro)", "lumineelectro": "Lumine (Elektro)", "aetheranemo": "Leer (Anemo)",