438 lines
11 KiB
Elixir
438 lines
11 KiB
Elixir
defmodule Odinsea.Game.Events.Snowball do
|
|
@moduledoc """
|
|
Snowball Event - Team-based snowball rolling competition.
|
|
Ported from Java `server.events.MapleSnowball`.
|
|
|
|
## Gameplay
|
|
- Two teams (Story and Maple) compete to roll snowballs
|
|
- Players hit snowballs to move them forward
|
|
- Snowmen block the opposing team's snowball
|
|
- First team to reach position 899 wins
|
|
|
|
## Maps
|
|
- Single map: 109060000
|
|
|
|
## Teams
|
|
- Team 0 (Story): Bottom snowball, y > -80
|
|
- Team 1 (Maple): Top snowball, y <= -80
|
|
|
|
## Win Condition
|
|
- First team to push snowball past position 899 wins
|
|
"""
|
|
|
|
alias Odinsea.Game.Event
|
|
alias Odinsea.Game.Timer.EventTimer
|
|
|
|
require Logger
|
|
|
|
# ============================================================================
|
|
# Types
|
|
# ============================================================================
|
|
|
|
@typedoc "Snowball state struct"
|
|
@type snowball :: %{
|
|
team: 0 | 1,
|
|
position: non_neg_integer(), # 0-899
|
|
start_point: non_neg_integer(), # Stage progress
|
|
invis: boolean(),
|
|
hittable: boolean(),
|
|
snowman_hp: non_neg_integer(),
|
|
schedule: reference() | nil
|
|
}
|
|
|
|
@typedoc "Snowball event state"
|
|
@type t :: %__MODULE__{
|
|
base: Event.t(),
|
|
snowballs: %{0 => snowball(), 1 => snowball()},
|
|
game_active: boolean()
|
|
}
|
|
|
|
defstruct [
|
|
:base,
|
|
snowballs: %{},
|
|
game_active: false
|
|
]
|
|
|
|
# ============================================================================
|
|
# Constants
|
|
# ============================================================================
|
|
|
|
@map_ids [109060000]
|
|
@finish_position 899
|
|
@snowman_max_hp 7500
|
|
@snowman_invincible_time 10_000 # 10 seconds
|
|
|
|
# Stage positions
|
|
@stage_positions [255, 511, 767]
|
|
|
|
# Damage values
|
|
@damage_normal 10
|
|
@damage_snowman 15
|
|
@damage_snowman_crit 45
|
|
@damage_snowman_miss 0
|
|
|
|
# ============================================================================
|
|
# Event Implementation
|
|
# ============================================================================
|
|
|
|
@doc """
|
|
Creates a new Snowball event for the given channel.
|
|
"""
|
|
def new(channel_id) do
|
|
base = Event.new(:snowball, channel_id, @map_ids)
|
|
|
|
%__MODULE__{
|
|
base: base,
|
|
snowballs: %{},
|
|
game_active: false
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Returns the map IDs for this event type.
|
|
"""
|
|
def map_ids, do: @map_ids
|
|
|
|
@doc """
|
|
Resets the event state for a new game.
|
|
"""
|
|
def reset(%__MODULE__{} = event) do
|
|
base = %{event.base | is_running: true, player_count: 0}
|
|
|
|
# Initialize snowballs for both teams
|
|
snowballs = %{
|
|
0 => create_snowball(0),
|
|
1 => create_snowball(1)
|
|
}
|
|
|
|
%__MODULE__{
|
|
event |
|
|
base: base,
|
|
snowballs: snowballs,
|
|
game_active: false
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Cleans up the event after it ends.
|
|
"""
|
|
def unreset(%__MODULE__{} = event) do
|
|
# Cancel all snowball schedules
|
|
Enum.each(event.snowballs, fn {_, ball} ->
|
|
if ball.schedule do
|
|
EventTimer.cancel(ball.schedule)
|
|
end
|
|
end)
|
|
|
|
base = %{event.base | is_running: false, player_count: 0}
|
|
|
|
%__MODULE__{
|
|
event |
|
|
base: base,
|
|
snowballs: %{},
|
|
game_active: false
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Called when a player finishes.
|
|
Snowball doesn't use this - winner determined by position.
|
|
"""
|
|
def finished(_event, _character) do
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Starts the snowball event gameplay.
|
|
"""
|
|
def start_event(%__MODULE__{} = event) do
|
|
Logger.info("Starting Snowball event on channel #{event.base.channel_id}")
|
|
|
|
# Initialize snowballs
|
|
snowballs = %{
|
|
0 => %{create_snowball(0) | invis: false, hittable: true},
|
|
1 => %{create_snowball(1) | invis: false, hittable: true}
|
|
}
|
|
|
|
event = %{event | snowballs: snowballs, game_active: true}
|
|
|
|
# Broadcast start
|
|
Event.broadcast_to_event(event.base, :event_started)
|
|
Event.broadcast_to_event(event.base, :enter_snowball)
|
|
|
|
# Broadcast initial snowball state
|
|
broadcast_snowball_update(event, 0)
|
|
broadcast_snowball_update(event, 1)
|
|
|
|
event
|
|
end
|
|
|
|
@doc """
|
|
Gets a snowball by team.
|
|
"""
|
|
def get_snowball(%__MODULE__{snowballs: balls}, team) when team in [0, 1] do
|
|
Map.get(balls, team)
|
|
end
|
|
|
|
def get_snowball(_, _), do: nil
|
|
|
|
@doc """
|
|
Gets both snowballs.
|
|
"""
|
|
def get_all_snowballs(%__MODULE__{snowballs: balls}), do: balls
|
|
|
|
@doc """
|
|
Handles a player hitting a snowball.
|
|
|
|
## Parameters
|
|
- event: Snowball event state
|
|
- character: The character hitting
|
|
- position: Character position %{x, y}
|
|
"""
|
|
def hit_snowball(%__MODULE__{game_active: false}, _, _) do
|
|
# Game not active
|
|
:ok
|
|
end
|
|
|
|
def hit_snowball(%__MODULE__{} = event, character, %{x: x, y: y}) do
|
|
# Determine team based on Y position
|
|
team = if y > -80, do: 0, else: 1
|
|
ball = get_snowball(event, team)
|
|
|
|
if ball == nil or ball.invis do
|
|
:ok
|
|
else
|
|
# Check if hitting snowman or snowball
|
|
snowman = x < -360 and x > -560
|
|
|
|
if not snowman do
|
|
# Hitting the snowball
|
|
handle_snowball_hit(event, ball, character, x)
|
|
else
|
|
# Hitting the snowman
|
|
handle_snowman_hit(event, ball, team)
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Updates a snowball's position.
|
|
"""
|
|
def update_position(%__MODULE__{} = event, team, new_position) when team in [0, 1] do
|
|
ball = get_snowball(event, team)
|
|
|
|
if ball do
|
|
updated_ball = %{ball | position: new_position}
|
|
snowballs = Map.put(event.snowballs, team, updated_ball)
|
|
|
|
# Check for stage transitions
|
|
if new_position in @stage_positions do
|
|
updated_ball = %{updated_ball | start_point: updated_ball.start_point + 1}
|
|
broadcast_message(event, team, updated_ball.start_point)
|
|
end
|
|
|
|
# Check for finish
|
|
if new_position >= @finish_position do
|
|
end_game(event, team)
|
|
else
|
|
broadcast_roll(event)
|
|
%{event | snowballs: snowballs}
|
|
end
|
|
else
|
|
event
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Sets a snowball's hittable state.
|
|
"""
|
|
def set_hittable(%__MODULE__{} = event, team, hittable) when team in [0, 1] do
|
|
ball = get_snowball(event, team)
|
|
|
|
if ball do
|
|
updated_ball = %{ball | hittable: hittable}
|
|
snowballs = Map.put(event.snowballs, team, updated_ball)
|
|
%{event | snowballs: snowballs}
|
|
else
|
|
event
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Sets a snowball's visibility.
|
|
"""
|
|
def set_invis(%__MODULE__{} = event, team, invis) when team in [0, 1] do
|
|
ball = get_snowball(event, team)
|
|
|
|
if ball do
|
|
updated_ball = %{ball | invis: invis}
|
|
snowballs = Map.put(event.snowballs, team, updated_ball)
|
|
%{event | snowballs: snowballs}
|
|
else
|
|
event
|
|
end
|
|
end
|
|
|
|
# ============================================================================
|
|
# Private Functions
|
|
# ============================================================================
|
|
|
|
defp create_snowball(team) do
|
|
%{
|
|
team: team,
|
|
position: 0,
|
|
start_point: 0,
|
|
invis: true,
|
|
hittable: true,
|
|
snowman_hp: @snowman_max_hp,
|
|
schedule: nil
|
|
}
|
|
end
|
|
|
|
defp handle_snowball_hit(event, ball, character, char_x) do
|
|
# Calculate damage
|
|
damage = calculate_snowball_damage(ball, char_x)
|
|
|
|
if damage > 0 and ball.hittable do
|
|
# Move snowball
|
|
new_position = ball.position + 1
|
|
update_position(event, ball.team, new_position)
|
|
else
|
|
# Knockback chance (20%)
|
|
if :rand.uniform() < 0.2 do
|
|
# Send knockback packet
|
|
send_knockback(character)
|
|
end
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp handle_snowman_hit(event, ball, team) do
|
|
# Calculate damage
|
|
roll = :rand.uniform()
|
|
|
|
damage = cond do
|
|
roll < 0.05 -> @damage_snowman_crit # 5% crit
|
|
roll < 0.35 -> @damage_snowman_miss # 30% miss
|
|
true -> @damage_snowman # 65% normal
|
|
end
|
|
|
|
if damage > 0 do
|
|
new_hp = ball.snowman_hp - damage
|
|
|
|
if new_hp <= 0 do
|
|
# Snowman destroyed - make enemy ball unhittable
|
|
new_hp = @snowman_max_hp
|
|
|
|
enemy_team = if team == 0, do: 1, else: 0
|
|
event = set_hittable(event, enemy_team, false)
|
|
|
|
# Broadcast message
|
|
broadcast_message(event, enemy_team, 4)
|
|
|
|
# Schedule re-hittable
|
|
schedule_ref = EventTimer.schedule(
|
|
fn ->
|
|
set_hittable(event, enemy_team, true)
|
|
broadcast_message(event, enemy_team, 5)
|
|
end,
|
|
@snowman_invincible_time
|
|
)
|
|
|
|
# Update ball with schedule
|
|
enemy_ball = get_snowball(event, enemy_team)
|
|
if enemy_ball do
|
|
updated_enemy = %{enemy_ball | schedule: schedule_ref}
|
|
snowballs = Map.put(event.snowballs, enemy_team, updated_enemy)
|
|
event = %{event | snowballs: snowballs}
|
|
end
|
|
|
|
# Apply seduce debuff to enemy team
|
|
apply_seduce(event, enemy_team)
|
|
end
|
|
|
|
# Update snowman HP
|
|
updated_ball = %{ball | snowman_hp: new_hp}
|
|
snowballs = Map.put(event.snowballs, team, updated_ball)
|
|
%{event | snowballs: snowballs}
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
defp calculate_snowball_damage(ball, char_x) do
|
|
left_x = get_left_x(ball)
|
|
right_x = get_right_x(ball)
|
|
|
|
# 1% chance for damage, or if in hit zone
|
|
if :rand.uniform() < 0.01 or (char_x > left_x and char_x < right_x) do
|
|
@damage_normal
|
|
else
|
|
0
|
|
end
|
|
end
|
|
|
|
defp get_left_x(%{position: pos}) do
|
|
pos * 3 + 175
|
|
end
|
|
|
|
defp get_right_x(ball) do
|
|
get_left_x(ball) + 275
|
|
end
|
|
|
|
defp broadcast_snowball_update(%__MODULE__{} = event, team) do
|
|
ball = get_snowball(event, team)
|
|
if ball do
|
|
# Broadcast snowball state
|
|
Event.broadcast_to_event(event.base, {:snowball_message, team, ball.start_point})
|
|
end
|
|
end
|
|
|
|
defp broadcast_message(%__MODULE__{} = event, team, message) do
|
|
Event.broadcast_to_event(event.base, {:snowball_message, team, message})
|
|
end
|
|
|
|
defp broadcast_roll(%__MODULE__{} = event) do
|
|
ball0 = get_snowball(event, 0)
|
|
ball1 = get_snowball(event, 1)
|
|
|
|
Event.broadcast_to_event(event.base, {:roll_snowball, ball0, ball1})
|
|
end
|
|
|
|
defp send_knockback(_character) do
|
|
# Send knockback packet to character
|
|
:ok
|
|
end
|
|
|
|
defp apply_seduce(_event, _team) do
|
|
# Apply seduce debuff to enemy team
|
|
# This would use MobSkillFactory to apply debuff
|
|
:ok
|
|
end
|
|
|
|
defp end_game(%__MODULE__{} = event, winner_team) do
|
|
team_name = if winner_team == 0, do: "Story", else: "Maple"
|
|
Logger.info("Snowball event ended! Team #{team_name} wins!")
|
|
|
|
# Make both snowballs invisible
|
|
event = set_invis(event, 0, true)
|
|
event = set_invis(event, 1, true)
|
|
|
|
# Broadcast winner
|
|
Event.broadcast_to_event(
|
|
event.base,
|
|
{:server_notice, "Congratulations! Team #{team_name} has won the Snowball Event!"}
|
|
)
|
|
|
|
# Give prizes to winners
|
|
# In real implementation:
|
|
# - Get all players on map
|
|
# - Winners (based on Y position) get prize
|
|
# - Everyone gets warped back
|
|
|
|
# Unreset event
|
|
unreset(%{event | game_active: false})
|
|
end
|
|
end
|