kimi gone wild
This commit is contained in:
393
lib/odinsea/game/events/coconut.ex
Normal file
393
lib/odinsea/game/events/coconut.ex
Normal file
@@ -0,0 +1,393 @@
|
||||
defmodule Odinsea.Game.Events.Coconut do
|
||||
@moduledoc """
|
||||
Coconut Event - Team-based coconut hitting competition.
|
||||
Ported from Java `server.events.MapleCoconut`.
|
||||
|
||||
## Gameplay
|
||||
- Two teams (Maple vs Story) compete to hit coconuts
|
||||
- Coconuts spawn and fall when hit
|
||||
- Team with most hits at end wins
|
||||
- 5 minute time limit with potential 1 minute bonus time
|
||||
|
||||
## Map
|
||||
- Single map: 109080000
|
||||
|
||||
## Win Condition
|
||||
- Team with higher score after 5 minutes wins
|
||||
- If tied, 1 minute bonus time is awarded
|
||||
- If still tied after bonus, no winner
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Coconut struct representing a single coconut"
|
||||
@type coconut :: %{
|
||||
id: non_neg_integer(),
|
||||
hits: non_neg_integer(),
|
||||
hittable: boolean(),
|
||||
stopped: boolean(),
|
||||
hit_time: integer() # Unix timestamp ms
|
||||
}
|
||||
|
||||
@typedoc "Coconut event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
coconuts: [coconut()],
|
||||
maple_score: non_neg_integer(), # Team 0
|
||||
story_score: non_neg_integer(), # Team 1
|
||||
count_bombing: non_neg_integer(),
|
||||
count_falling: non_neg_integer(),
|
||||
count_stopped: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109080000]
|
||||
@event_duration 300_000 # 5 minutes in ms
|
||||
@bonus_duration 60_000 # 1 minute bonus time
|
||||
@total_coconuts 506
|
||||
@warp_out_delay 10_000 # 10 seconds after game end
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Coconut event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:coconut, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
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}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: initialize_coconuts(),
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
count_bombing: 80,
|
||||
count_falling: 401,
|
||||
count_stopped: 20,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
# Cancel all schedules
|
||||
Event.cancel_schedules(event.base)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
coconuts: [],
|
||||
maple_score: 0,
|
||||
story_score: 0,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Coconut event doesn't use this - winners determined by time.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Sends coconut score packet.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
# Send coconut score packet
|
||||
Logger.debug("Sending coconut score to #{character.name}: Maple #{event.maple_score}, Story #{event.story_score}")
|
||||
|
||||
# In real implementation: send packet with scores
|
||||
# Packet format: coconutScore(maple_score, story_score)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the coconut event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Coconut event on channel #{event.base.channel_id}")
|
||||
|
||||
# Set coconuts hittable
|
||||
event = set_hittable(event, true)
|
||||
|
||||
# Broadcast event start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, :hit_coconut)
|
||||
|
||||
# Start 5-minute countdown
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule end check
|
||||
schedule_ref = EventTimer.schedule(
|
||||
fn -> check_winner(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%{event | schedules: [schedule_ref]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a coconut by ID.
|
||||
Returns nil if ID is out of range.
|
||||
"""
|
||||
def get_coconut(%__MODULE__{coconuts: coconuts}, id) when id >= 0 and id < length(coconuts) do
|
||||
Enum.at(coconuts, id)
|
||||
end
|
||||
|
||||
def get_coconut(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Returns all coconuts.
|
||||
"""
|
||||
def get_all_coconuts(%__MODULE__{coconuts: coconuts}), do: coconuts
|
||||
|
||||
@doc """
|
||||
Sets whether coconuts are hittable.
|
||||
"""
|
||||
def set_hittable(%__MODULE__{coconuts: coconuts} = event, hittable) do
|
||||
updated_coconuts = Enum.map(coconuts, fn coconut ->
|
||||
%{coconut | hittable: hittable}
|
||||
end)
|
||||
|
||||
%{event | coconuts: updated_coconuts}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of available bombings.
|
||||
"""
|
||||
def get_bombings(%__MODULE__{count_bombing: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements bombing count.
|
||||
"""
|
||||
def bomb_coconut(%__MODULE__{count_bombing: count} = event) do
|
||||
%{event | count_bombing: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of falling coconuts available.
|
||||
"""
|
||||
def get_falling(%__MODULE__{count_falling: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements falling count.
|
||||
"""
|
||||
def fall_coconut(%__MODULE__{count_falling: count} = event) do
|
||||
%{event | count_falling: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the number of stopped coconuts.
|
||||
"""
|
||||
def get_stopped(%__MODULE__{count_stopped: count}), do: count
|
||||
|
||||
@doc """
|
||||
Decrements stopped count.
|
||||
"""
|
||||
def stop_coconut(%__MODULE__{count_stopped: count} = event) do
|
||||
%{event | count_stopped: max(0, count - 1)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current scores [maple, story].
|
||||
"""
|
||||
def get_coconut_score(%__MODULE__{} = event) do
|
||||
[event.maple_score, event.story_score]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets Team Maple score.
|
||||
"""
|
||||
def get_maple_score(%__MODULE__{maple_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Gets Team Story score.
|
||||
"""
|
||||
def get_story_score(%__MODULE__{story_score: score}), do: score
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Maple.
|
||||
"""
|
||||
def add_maple_score(%__MODULE__{maple_score: score} = event) do
|
||||
%{event | maple_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a point to Team Story.
|
||||
"""
|
||||
def add_story_score(%__MODULE__{story_score: score} = event) do
|
||||
%{event | story_score: score + 1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records a hit on a coconut.
|
||||
"""
|
||||
def hit_coconut(%__MODULE__{coconuts: coconuts} = event, coconut_id, team) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
updated_coconuts = List.update_at(coconuts, coconut_id, fn coconut ->
|
||||
%{coconut |
|
||||
hits: coconut.hits + 1,
|
||||
hit_time: now + 1000 # 1 second cooldown
|
||||
}
|
||||
end)
|
||||
|
||||
# Add score to appropriate team
|
||||
event = %{event | coconuts: updated_coconuts}
|
||||
|
||||
event = case team do
|
||||
0 -> add_maple_score(event)
|
||||
1 -> add_story_score(event)
|
||||
_ -> event
|
||||
end
|
||||
|
||||
event
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp initialize_coconuts do
|
||||
Enum.map(0..(@total_coconuts - 1), fn id ->
|
||||
%{
|
||||
id: id,
|
||||
hits: 0,
|
||||
hittable: false,
|
||||
stopped: false,
|
||||
hit_time: 0
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp check_winner(%__MODULE__{} = event) do
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Tie - bonus time
|
||||
bonus_time(event)
|
||||
else
|
||||
# We have a winner
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end
|
||||
|
||||
defp bonus_time(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event tied! Starting bonus time...")
|
||||
|
||||
# Broadcast bonus time
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@bonus_duration, 1000)})
|
||||
|
||||
# Schedule final check
|
||||
EventTimer.schedule(
|
||||
fn ->
|
||||
if get_maple_score(event) == get_story_score(event) do
|
||||
# Still tied - no winner
|
||||
end_game_no_winner(event)
|
||||
else
|
||||
winner_team = if get_maple_score(event) > get_story_score(event), do: 0, else: 1
|
||||
end_game(event, winner_team)
|
||||
end
|
||||
end,
|
||||
@bonus_duration
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game(%__MODULE__{} = event, winner_team) do
|
||||
team_name = if winner_team == 0, do: "Maple", else: "Story"
|
||||
Logger.info("Coconut event ended! Team #{team_name} wins!")
|
||||
|
||||
# Broadcast winner
|
||||
Event.broadcast_to_event(event.base, {:victory, winner_team})
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, winner_team) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp end_game_no_winner(%__MODULE__{} = event) do
|
||||
Logger.info("Coconut event ended with no winner (tie)")
|
||||
|
||||
# Broadcast no winner
|
||||
Event.broadcast_to_event(event.base, :no_winner)
|
||||
|
||||
# Schedule warp out
|
||||
EventTimer.schedule(
|
||||
fn -> warp_out(event, nil) end,
|
||||
@warp_out_delay
|
||||
)
|
||||
end
|
||||
|
||||
defp warp_out(%__MODULE__{} = event, winner_team) do
|
||||
# Make coconuts unhittable
|
||||
event = set_hittable(event, false)
|
||||
|
||||
# Give prizes to winners, warp everyone back
|
||||
# In real implementation:
|
||||
# - Get all characters on map
|
||||
# - For each character:
|
||||
# - If on winning team, give prize
|
||||
# - Warp back to saved location
|
||||
|
||||
Logger.info("Warping out all players from coconut event")
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
298
lib/odinsea/game/events/fitness.ex
Normal file
298
lib/odinsea/game/events/fitness.ex
Normal file
@@ -0,0 +1,298 @@
|
||||
defmodule Odinsea.Game.Events.Fitness do
|
||||
@moduledoc """
|
||||
Fitness Event - Maple Physical Fitness Test obstacle course.
|
||||
Ported from Java `server.events.MapleFitness`.
|
||||
|
||||
## Gameplay
|
||||
- 4 stage obstacle course that players must navigate
|
||||
- Time limit of 10 minutes
|
||||
- Players who reach the end within time limit get prize
|
||||
- Death during event results in elimination
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109040000 (Start - monkeys throwing bananas)
|
||||
- Stage 2: 109040001 (Stage 2 - monkeys)
|
||||
- Stage 3: 109040002 (Stage 3 - traps)
|
||||
- Stage 4: 109040003 (Stage 4 - last stage)
|
||||
- Finish: 109040004
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map within 10 minutes
|
||||
- All finishers get prize regardless of order
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Fitness event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil, # Unix timestamp ms
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 600_000, # 10 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109040000, 109040001, 109040002, 109040003, 109040004]
|
||||
@event_duration 600_000 # 10 minutes in ms
|
||||
@message_interval 60_000 # Broadcast messages every minute
|
||||
|
||||
# Message schedule based on time remaining
|
||||
@messages [
|
||||
{10_000, "You have 10 sec left. Those of you unable to beat the game, we hope you beat it next time! Great job everyone!! See you later~"},
|
||||
{110_000, "Alright, you don't have much time remaining. Please hurry up a little!"},
|
||||
{210_000, "The 4th stage is the last one for [The Maple Physical Fitness Test]. Please don't give up at the last minute and try your best. The reward is waiting for you at the very top!"},
|
||||
{310_000, "The 3rd stage offers traps where you may see them, but you won't be able to step on them. Please be careful of them as you make your way up."},
|
||||
{400_000, "For those who have heavy lags, please make sure to move slowly to avoid falling all the way down because of lags."},
|
||||
{500_000, "Please remember that if you die during the event, you'll be eliminated from the game. If you're running out of HP, either take a potion or recover HP first before moving on."},
|
||||
{600_000, "The most important thing you'll need to know to avoid the bananas thrown by the monkeys is *Timing* Timing is everything in this!"},
|
||||
{660_000, "The 2nd stage offers monkeys throwing bananas. Please make sure to avoid them by moving along at just the right timing."},
|
||||
{700_000, "Please remember that if you die during the event, you'll be eliminated from the game. You still have plenty of time left, so either take a potion or recover HP first before moving on."},
|
||||
{780_000, "Everyone that clears [The Maple Physical Fitness Test] on time will be given an item, regardless of the order of finish, so just relax, take your time, and clear the 4 stages."},
|
||||
{840_000, "There may be a heavy lag due to many users at stage 1 all at once. It won't be difficult, so please make sure not to fall down because of heavy lag."},
|
||||
{900_000, "[MapleStory Physical Fitness Test] consists of 4 stages, and if you happen to die during the game, you'll be eliminated from the game, so please be careful of that."}
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Fitness event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:fitness, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
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
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Fitness event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 20)
|
||||
Character.finish_achievement(character, 20)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending fitness clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet with time left
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the fitness event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Fitness event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
# Start message broadcasting
|
||||
msg_ref = start_message_schedule(event)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref, msg_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the time elapsed in milliseconds.
|
||||
"""
|
||||
def get_time_elapsed(%__MODULE__{time_started: nil}), do: 0
|
||||
def get_time_elapsed(%__MODULE__{time_started: started}) do
|
||||
System.system_time(:millisecond) - started
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player is eliminated (died during event).
|
||||
"""
|
||||
def eliminated?(character) do
|
||||
# Check if character died while on event maps
|
||||
# This would check character state
|
||||
character.hp <= 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Eliminates a player from the event.
|
||||
"""
|
||||
def eliminate_player(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} eliminated from Fitness event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp start_message_schedule(%__MODULE__{} = event) do
|
||||
# Register recurring task for message broadcasting
|
||||
{:ok, ref} = EventTimer.register(
|
||||
fn -> check_and_broadcast_messages(event) end,
|
||||
@message_interval,
|
||||
0
|
||||
)
|
||||
|
||||
ref
|
||||
end
|
||||
|
||||
defp check_and_broadcast_messages(%__MODULE__{} = event) do
|
||||
time_left = get_time_left(event)
|
||||
|
||||
# Find messages that should be broadcast based on time left
|
||||
messages_to_send = Enum.filter(@messages, fn {threshold, _} ->
|
||||
time_left <= threshold and time_left > threshold - @message_interval
|
||||
end)
|
||||
|
||||
Enum.each(messages_to_send, fn {_, message} ->
|
||||
Event.broadcast_to_event(event.base, {:server_notice, message})
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, this would update the map portal state
|
||||
# allowing or preventing players from entering
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Fitness event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
332
lib/odinsea/game/events/ola_ola.ex
Normal file
332
lib/odinsea/game/events/ola_ola.ex
Normal file
@@ -0,0 +1,332 @@
|
||||
defmodule Odinsea.Game.Events.OlaOla do
|
||||
@moduledoc """
|
||||
Ola Ola Event - Portal guessing game (similar to Survival but with portals).
|
||||
Ported from Java `server.events.MapleOla`.
|
||||
|
||||
## Gameplay
|
||||
- 3 stages with random correct portals
|
||||
- Players must guess which portal leads forward
|
||||
- Wrong portals send players back or eliminate them
|
||||
- Fastest to finish wins
|
||||
|
||||
## Maps
|
||||
- Stage 1: 109030001 (5 portals: ch00-ch04)
|
||||
- Stage 2: 109030002 (8 portals: ch00-ch07)
|
||||
- Stage 3: 109030003 (16 portals: ch00-ch15)
|
||||
|
||||
## Win Condition
|
||||
- Reach the finish map by choosing correct portals
|
||||
- First to finish gets best prize, all finishers get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OlaOla event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
stages: [non_neg_integer()], # Correct portal indices for each stage
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
stages: [0, 0, 0], # Will be randomized on start
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109030001, 109030002, 109030003]
|
||||
@event_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# Stage configurations
|
||||
@stage_config [
|
||||
%{map: 109030001, portals: 5, prefix: "ch"}, # Stage 1: 5 portals
|
||||
%{map: 109030002, portals: 8, prefix: "ch"}, # Stage 2: 8 portals
|
||||
%{map: 109030003, portals: 16, prefix: "ch"} # Stage 3: 16 portals
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OlaOla event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ola_ola, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
event_duration: @event_duration,
|
||||
schedules: []
|
||||
}
|
||||
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
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: [0, 0, 0],
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
Randomizes correct portals for next game.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Randomize correct portals for each stage
|
||||
stages = [
|
||||
random_stage_portal(0), # Stage 1: 0-4
|
||||
random_stage_portal(1), # Stage 2: 0-7
|
||||
random_stage_portal(2) # Stage 3: 0-15
|
||||
]
|
||||
|
||||
# Hack check: stage 1 portal 2 is inaccessible
|
||||
stages = if Enum.at(stages, 0) == 2 do
|
||||
List.replace_at(stages, 0, 3)
|
||||
else
|
||||
stages
|
||||
end
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
stages: stages,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Ola Ola event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 21)
|
||||
Character.finish_achievement(character, 21)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Ola Ola clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Ola Ola event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Ola Ola event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(@event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
@event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character chose the correct portal for their current stage.
|
||||
|
||||
## Parameters
|
||||
- event: The OlaOla event state
|
||||
- portal_name: The portal name (e.g., "ch00", "ch05")
|
||||
- map_id: Current map ID
|
||||
|
||||
## Returns
|
||||
- true if correct portal
|
||||
- false if wrong portal
|
||||
"""
|
||||
def correct_portal?(%__MODULE__{stages: stages}, portal_name, map_id) do
|
||||
# Get stage index from map ID
|
||||
stage_index = get_stage_index(map_id)
|
||||
|
||||
if stage_index == nil do
|
||||
false
|
||||
else
|
||||
# Get correct portal for this stage
|
||||
correct = Enum.at(stages, stage_index)
|
||||
|
||||
# Format correct portal name
|
||||
correct_name = format_portal_name(correct)
|
||||
|
||||
portal_name == correct_name
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the correct portal name for a stage.
|
||||
"""
|
||||
def get_correct_portal(%__MODULE__{stages: stages}, stage_index) when stage_index in 0..2 do
|
||||
correct = Enum.at(stages, stage_index)
|
||||
format_portal_name(correct)
|
||||
end
|
||||
|
||||
def get_correct_portal(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil}), do: @event_duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current stage (0-2) for a map ID.
|
||||
"""
|
||||
def get_stage_index(map_id) do
|
||||
Enum.find_index(@map_ids, &(&1 == map_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the stage configuration.
|
||||
"""
|
||||
def stage_config, do: @stage_config
|
||||
|
||||
@doc """
|
||||
Handles a player attempting to use a portal.
|
||||
Returns {:ok, destination_map} for correct portal, :error for wrong portal.
|
||||
"""
|
||||
def attempt_portal(%__MODULE__{} = event, portal_name, current_map_id) do
|
||||
if correct_portal?(event, portal_name, current_map_id) do
|
||||
# Correct portal - advance to next stage
|
||||
stage = get_stage_index(current_map_id)
|
||||
|
||||
if stage < 2 do
|
||||
next_map = Enum.at(@map_ids, stage + 1)
|
||||
{:ok, next_map}
|
||||
else
|
||||
# Finished all stages
|
||||
{:finished, 109050000} # Finish map
|
||||
end
|
||||
else
|
||||
# Wrong portal - fail
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp random_stage_portal(stage_index) do
|
||||
portal_count = Enum.at(@stage_config, stage_index).portals
|
||||
:rand.uniform(portal_count) - 1 # 0-based
|
||||
end
|
||||
|
||||
defp format_portal_name(portal_num) do
|
||||
# Format as ch00, ch01, etc.
|
||||
if portal_num < 10 do
|
||||
"ch0#{portal_num}"
|
||||
else
|
||||
"ch#{portal_num}"
|
||||
end
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Ola Ola event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation, get all players on event maps and warp them
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
349
lib/odinsea/game/events/ox_quiz.ex
Normal file
@@ -0,0 +1,349 @@
|
||||
defmodule Odinsea.Game.Events.OxQuiz do
|
||||
@moduledoc """
|
||||
OX Quiz Event - True/False quiz game with position-based answers.
|
||||
Ported from Java `server.events.MapleOxQuiz`.
|
||||
|
||||
## Gameplay
|
||||
- 10 questions are asked
|
||||
- Players stand on O (true/right side) or X (false/left side) side
|
||||
- Wrong answer = eliminated (HP set to 0)
|
||||
- Correct answer = gain EXP
|
||||
- Last players standing win
|
||||
|
||||
## Maps
|
||||
- Single map: 109020001
|
||||
- X side: x < -234, y > -26
|
||||
- O side: x > -234, y > -26
|
||||
|
||||
## Win Condition
|
||||
- Answer correctly to survive all 10 questions
|
||||
- Remaining players at end get prize
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Events.OxQuizQuestions
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
times_asked: non_neg_integer(),
|
||||
current_question: OxQuizQuestions.question() | nil,
|
||||
finished: boolean(),
|
||||
question_delay: non_neg_integer(), # ms before showing question
|
||||
answer_delay: non_neg_integer(), # ms before revealing answer
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [109020001]
|
||||
@max_questions 10
|
||||
|
||||
# Position boundaries for O (true) vs X (false)
|
||||
@o_side_bounds %{x_min: -234, x_max: 9999, y_min: -26, y_max: 9999}
|
||||
@x_side_bounds %{x_min: -9999, x_max: -234, y_min: -26, y_max: 9999}
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new OX Quiz event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:ox_quiz, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
question_delay: 10_000,
|
||||
answer_delay: 10_000,
|
||||
schedules: []
|
||||
}
|
||||
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
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
times_asked: 0,
|
||||
current_question: nil,
|
||||
finished: false,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes.
|
||||
OX Quiz doesn't use this - winners determined by survival.
|
||||
"""
|
||||
def finished(_event, _character) do
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into the event map.
|
||||
Unmutes player (allows chat during quiz).
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = _event, character) do
|
||||
# Unmute player (allow chat)
|
||||
# In real implementation: Character.set_temp_mute(character, false)
|
||||
Logger.debug("Player #{character.name} loaded OX Quiz map, unmuting")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz event gameplay.
|
||||
Begins asking questions.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting OX Quiz event on channel #{event.base.channel_id}")
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Start asking questions
|
||||
send_question(%{event | finished: false})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the next question to all players.
|
||||
"""
|
||||
def send_question(%__MODULE__{finished: true} = event), do: event
|
||||
|
||||
def send_question(%__MODULE__{} = event) do
|
||||
# Grab random question
|
||||
question = OxQuizQuestions.get_random_question()
|
||||
|
||||
# Schedule question display
|
||||
question_ref = EventTimer.schedule(
|
||||
fn -> display_question(event, question) end,
|
||||
event.question_delay
|
||||
)
|
||||
|
||||
# Schedule answer reveal
|
||||
answer_ref = EventTimer.schedule(
|
||||
fn -> reveal_answer(event, question) end,
|
||||
event.question_delay + event.answer_delay
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
current_question: question,
|
||||
schedules: [question_ref, answer_ref | event.schedules]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Displays the question to all players.
|
||||
"""
|
||||
def display_question(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def display_question(%__MODULE__{} = event, question) do
|
||||
# Check end conditions
|
||||
if should_end_event?(event) do
|
||||
end_event(event)
|
||||
else
|
||||
# Broadcast question
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_show, question_set, question_id, true})
|
||||
Event.broadcast_to_event(event.base, {:clock, 10}) # 10 seconds to answer
|
||||
|
||||
Logger.debug("OX Quiz: Displaying question #{question_id} from set #{question_set}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reveals the answer and processes results.
|
||||
"""
|
||||
def reveal_answer(%__MODULE__{finished: true}, _question), do: :ok
|
||||
|
||||
def reveal_answer(%__MODULE__{} = event, question) do
|
||||
if event.finished do
|
||||
:ok
|
||||
else
|
||||
# Broadcast answer reveal
|
||||
{question_set, question_id} = question.ids
|
||||
Event.broadcast_to_event(event.base, {:ox_quiz_hide, question_set, question_id})
|
||||
|
||||
# Process each player
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Check their position vs answer
|
||||
# - Wrong position: set HP to 0
|
||||
# - Correct position: give EXP
|
||||
|
||||
Logger.debug("OX Quiz: Revealing answer for question #{question_id}: #{question.answer}")
|
||||
|
||||
# Increment question count
|
||||
event = %{event | times_asked: event.times_asked + 1}
|
||||
|
||||
# Continue to next question
|
||||
send_question(event)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player's position corresponds to the correct answer.
|
||||
|
||||
## Parameters
|
||||
- answer: :o (true) or :x (false)
|
||||
- x: Player X position
|
||||
- y: Player Y position
|
||||
|
||||
## Returns
|
||||
- true if position matches answer
|
||||
- false if wrong position
|
||||
"""
|
||||
def correct_position?(:o, x, y) do
|
||||
x > -234 and y > -26
|
||||
end
|
||||
|
||||
def correct_position?(:x, x, y) do
|
||||
x < -234 and y > -26
|
||||
end
|
||||
|
||||
@doc """
|
||||
Processes a player's answer based on their position.
|
||||
Returns {:correct, exp} or {:wrong, 0}
|
||||
"""
|
||||
def check_player_answer(question_answer, player_x, player_y) do
|
||||
player_answer = if player_x > -234, do: :o, else: :x
|
||||
|
||||
if player_answer == question_answer do
|
||||
{:correct, 3000} # 3000 EXP for correct answer
|
||||
else
|
||||
{:wrong, 0}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current question number.
|
||||
"""
|
||||
def current_question_number(%__MODULE__{times_asked: asked}), do: asked + 1
|
||||
|
||||
@doc """
|
||||
Gets the maximum number of questions.
|
||||
"""
|
||||
def max_questions, do: @max_questions
|
||||
|
||||
@doc """
|
||||
Mutes a player (after event ends).
|
||||
"""
|
||||
def mute_player(character) do
|
||||
# In real implementation: Character.set_temp_mute(character, true)
|
||||
Logger.debug("Muting player #{character.name}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp should_end_event?(%__MODULE__{} = event) do
|
||||
# End if 10 questions asked or only 1 player left
|
||||
event.times_asked >= @max_questions or count_alive_players(event) <= 1
|
||||
end
|
||||
|
||||
defp count_alive_players(%__MODULE__{} = _event) do
|
||||
# In real implementation:
|
||||
# - Get all players on map
|
||||
# - Count non-GM, alive players
|
||||
# For now, return placeholder
|
||||
10
|
||||
end
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("OX Quiz event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Mark as finished
|
||||
event = %{event | finished: true}
|
||||
|
||||
# Broadcast end
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The event has ended"})
|
||||
|
||||
# Process winners
|
||||
# In real implementation:
|
||||
# - Get all alive, non-GM players
|
||||
# - Give prize to each
|
||||
# - Give achievement (ID 19)
|
||||
# - Mute players
|
||||
# - Warp back
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
283
lib/odinsea/game/events/ox_quiz_questions.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule Odinsea.Game.Events.OxQuizQuestions do
|
||||
@moduledoc """
|
||||
OX Quiz Question Database.
|
||||
Ported from Java `server.events.MapleOxQuizFactory`.
|
||||
|
||||
Stores true/false questions loaded from database or fallback data.
|
||||
Questions are organized into sets and IDs for efficient lookup.
|
||||
|
||||
## Question Format
|
||||
- question: The question text
|
||||
- display: How to display the answer (O/X)
|
||||
- answer: :o for true, :x for false
|
||||
- question_set: Category/set number
|
||||
- question_id: ID within the set
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "OX Quiz question struct"
|
||||
@type question :: %{
|
||||
question: String.t(),
|
||||
display: String.t(),
|
||||
answer: :o | :x,
|
||||
ids: {non_neg_integer(), non_neg_integer()} # {question_set, question_id}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# GenServer State
|
||||
# ============================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
defstruct [
|
||||
:questions, # Map of {{set, id} => question}
|
||||
:ets_table
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts the OX Quiz question cache.
|
||||
"""
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a random question from the database.
|
||||
"""
|
||||
def get_random_question do
|
||||
GenServer.call(__MODULE__, :get_random_question)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a specific question by set and ID.
|
||||
"""
|
||||
def get_question(question_set, question_id) do
|
||||
GenServer.call(__MODULE__, {:get_question, question_set, question_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all questions.
|
||||
"""
|
||||
def get_all_questions do
|
||||
GenServer.call(__MODULE__, :get_all_questions)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the total number of questions.
|
||||
"""
|
||||
def question_count do
|
||||
GenServer.call(__MODULE__, :question_count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reloads questions from database.
|
||||
"""
|
||||
def reload do
|
||||
GenServer.cast(__MODULE__, :reload)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Server Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
# Create ETS table for fast lookups
|
||||
ets = :ets.new(:ox_quiz_questions, [:set, :protected, :named_table])
|
||||
|
||||
# Load initial questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(ets, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions loaded: #{map_size(questions)} questions")
|
||||
|
||||
{:ok, %__MODULE__{questions: questions, ets_table: ets}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_random_question, _from, state) do
|
||||
question = get_random_question_impl(state)
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:get_question, set, id}, _from, state) do
|
||||
question = Map.get(state.questions, {set, id})
|
||||
{:reply, question, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_all_questions, _from, state) do
|
||||
{:reply, Map.values(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:question_count, _from, state) do
|
||||
{:reply, map_size(state.questions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast(:reload, state) do
|
||||
# Clear ETS
|
||||
:ets.delete_all_objects(state.ets_table)
|
||||
|
||||
# Reload questions
|
||||
questions = load_questions()
|
||||
|
||||
# Store in ETS
|
||||
Enum.each(questions, fn {key, q} ->
|
||||
:ets.insert(state.ets_table, {key, q})
|
||||
end)
|
||||
|
||||
Logger.info("OX Quiz Questions reloaded: #{map_size(questions)} questions")
|
||||
|
||||
{:noreply, %{state | questions: questions}}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_random_question_impl(state) do
|
||||
questions = Map.values(state.questions)
|
||||
|
||||
if length(questions) > 0 do
|
||||
Enum.random(questions)
|
||||
else
|
||||
# Return fallback question if none loaded
|
||||
fallback_question()
|
||||
end
|
||||
end
|
||||
|
||||
defp load_questions do
|
||||
# Try to load from database
|
||||
# In real implementation:
|
||||
# - Query wz_oxdata table
|
||||
# - Parse each row into question struct
|
||||
|
||||
# For now, use fallback questions
|
||||
fallback_questions()
|
||||
|> Enum.map(fn q -> {{elem(q.ids, 0), elem(q.ids, 1)}, q} end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp parse_answer("o"), do: :o
|
||||
defp parse_answer("O"), do: :o
|
||||
defp parse_answer("x"), do: :x
|
||||
defp parse_answer("X"), do: :x
|
||||
defp parse_answer(_), do: :o # Default to true
|
||||
|
||||
# ============================================================================
|
||||
# Fallback Questions
|
||||
# ============================================================================
|
||||
|
||||
defp fallback_question do
|
||||
%{
|
||||
question: "MapleStory was first released in 2003?",
|
||||
display: "O",
|
||||
answer: :o,
|
||||
ids: {0, 0}
|
||||
}
|
||||
end
|
||||
|
||||
defp fallback_questions do
|
||||
[
|
||||
# Set 1: General MapleStory Knowledge
|
||||
%{question: "MapleStory was first released in 2003?", display: "O", answer: :o, ids: {1, 1}},
|
||||
%{question: "The maximum level in MapleStory is 200?", display: "O", answer: :o, ids: {1, 2}},
|
||||
%{question: "Henesys is the starting town for all beginners?", display: "X", answer: :x, ids: {1, 3}},
|
||||
%{question: "The Pink Bean is a boss monster?", display: "O", answer: :o, ids: {1, 4}},
|
||||
%{question: "Magicians use swords as their primary weapon?", display: "X", answer: :x, ids: {1, 5}},
|
||||
%{question: "The EXP curve gets steeper at higher levels?", display: "O", answer: :o, ids: {1, 6}},
|
||||
%{question: "Gachapon gives random items for NX?", display: "O", answer: :o, ids: {1, 7}},
|
||||
%{question: "Warriors have the highest INT growth?", display: "X", answer: :x, ids: {1, 8}},
|
||||
%{question: "The Cash Shop sells permanent pets?", display: "O", answer: :o, ids: {1, 9}},
|
||||
%{question: "All monsters in Maple Island are passive?", display: "O", answer: :o, ids: {1, 10}},
|
||||
|
||||
# Set 2: Classes and Jobs
|
||||
%{question: "Beginners can use the Three Snails skill?", display: "O", answer: :o, ids: {2, 1}},
|
||||
%{question: "Magicians require the most DEX to advance?", display: "X", answer: :x, ids: {2, 2}},
|
||||
%{question: "Thieves can use claws and daggers?", display: "O", answer: :o, ids: {2, 3}},
|
||||
%{question: "Pirates are the only class that can use guns?", display: "O", answer: :o, ids: {2, 4}},
|
||||
%{question: "Archers specialize in close-range combat?", display: "X", answer: :x, ids: {2, 5}},
|
||||
%{question: "First job advancement happens at level 10?", display: "O", answer: :o, ids: {2, 6}},
|
||||
%{question: "All classes can use magic attacks?", display: "X", answer: :x, ids: {2, 7}},
|
||||
%{question: "Bowmen require arrows to attack?", display: "O", answer: :o, ids: {2, 8}},
|
||||
%{question: "Warriors have the highest HP pool?", display: "O", answer: :o, ids: {2, 9}},
|
||||
%{question: "Cygnus Knights are available at level 1?", display: "X", answer: :x, ids: {2, 10}},
|
||||
|
||||
# Set 3: Monsters and Maps
|
||||
%{question: "Blue Snails are found on Maple Island?", display: "O", answer: :o, ids: {3, 1}},
|
||||
%{question: "Zakum is located in the Dead Mine?", display: "O", answer: :o, ids: {3, 2}},
|
||||
%{question: "Pigs drop pork items?", display: "O", answer: :o, ids: {3, 3}},
|
||||
%{question: "The highest level map is Victoria Island?", display: "X", answer: :x, ids: {3, 4}},
|
||||
%{question: "Balrog is a level 100 boss?", display: "O", answer: :o, ids: {3, 5}},
|
||||
%{question: "Mushmom is a giant mushroom monster?", display: "O", answer: :o, ids: {3, 6}},
|
||||
%{question: "All monsters respawn immediately after death?", display: "X", answer: :x, ids: {3, 7}},
|
||||
%{question: "Jr. Balrog spawns in Sleepywood Dungeon?", display: "O", answer: :o, ids: {3, 8}},
|
||||
%{question: "Orbis Tower connects Orbis to El Nath?", display: "O", answer: :o, ids: {3, 9}},
|
||||
%{question: "Ludibrium is a town made of toys?", display: "O", answer: :o, ids: {3, 10}},
|
||||
|
||||
# Set 4: Items and Equipment
|
||||
%{question: "Equipment can have potential stats?", display: "O", answer: :o, ids: {4, 1}},
|
||||
%{question: "Mesos are the currency of MapleStory?", display: "O", answer: :o, ids: {4, 2}},
|
||||
%{question: "Scrolls always succeed?", display: "X", answer: :x, ids: {4, 3}},
|
||||
%{question: "Potions restore HP and MP?", display: "O", answer: :o, ids: {4, 4}},
|
||||
%{question: " NX Cash is required to buy Cash Shop items?", display: "O", answer: :o, ids: {4, 5}},
|
||||
%{question: "All equipment can be traded?", display: "X", answer: :x, ids: {4, 6}},
|
||||
%{question: "Stars are thrown by Night Lords?", display: "O", answer: :o, ids: {4, 7}},
|
||||
%{question: "Beginners can equip level 100 items?", display: "X", answer: :x, ids: {4, 8}},
|
||||
%{question: "Clean Slate Scrolls remove failed slots?", display: "O", answer: :o, ids: {4, 9}},
|
||||
%{question: "Chaos Scrolls randomize item stats?", display: "O", answer: :o, ids: {4, 10}},
|
||||
|
||||
# Set 5: Quests and NPCs
|
||||
%{question: "Mai is the first quest NPC beginners meet?", display: "O", answer: :o, ids: {5, 1}},
|
||||
%{question: "All quests can be repeated?", display: "X", answer: :x, ids: {5, 2}},
|
||||
%{question: "NPCs with \"!\" above them give quests?", display: "O", answer: :o, ids: {5, 3}},
|
||||
%{question: "Party quests require exactly 6 players?", display: "X", answer: :x, ids: {5, 4}},
|
||||
%{question: "Roger sells potions in Henesys?", display: "X", answer: :x, ids: {5, 5}},
|
||||
%{question: "The Lost City is another name for Kerning City?", display: "X", answer: :x, ids: {5, 6}},
|
||||
%{question: "Guilds can have up to 200 members?", display: "O", answer: :o, ids: {5, 7}},
|
||||
%{question: "All NPCs can be attacked?", display: "X", answer: :x, ids: {5, 8}},
|
||||
%{question: "Big Headward sells hairstyles?", display: "O", answer: :o, ids: {5, 9}},
|
||||
%{question: "The Storage Keeper stores items for free?", display: "X", answer: :x, ids: {5, 10}},
|
||||
|
||||
# Set 6: Game Mechanics
|
||||
%{question: "Fame can be given or taken once per day?", display: "O", answer: :o, ids: {6, 1}},
|
||||
%{question: "Party play gives bonus EXP?", display: "O", answer: :o, ids: {6, 2}},
|
||||
%{question: "Dying causes EXP loss?", display: "O", answer: :o, ids: {6, 3}},
|
||||
%{question: "All skills have no cooldown?", display: "X", answer: :x, ids: {6, 4}},
|
||||
%{question: "Trade window allows up to 9 items?", display: "O", answer: :o, ids: {6, 5}},
|
||||
%{question: "Mounting a pet requires level 70?", display: "X", answer: :x, ids: {6, 6}},
|
||||
%{question: "Monster Book tracks monster information?", display: "O", answer: :o, ids: {6, 7}},
|
||||
%{question: "Bosses have purple health bars?", display: "O", answer: :o, ids: {6, 8}},
|
||||
%{question: "Channel changing is instant?", display: "X", answer: :x, ids: {6, 9}},
|
||||
%{question: "Expedition mode is for large boss fights?", display: "O", answer: :o, ids: {6, 10}},
|
||||
|
||||
# Set 7: Trivia
|
||||
%{question: "MapleStory is developed by Nexon?", display: "O", answer: :o, ids: {7, 1}},
|
||||
%{question: "The Black Mage is the main antagonist?", display: "O", answer: :o, ids: {7, 2}},
|
||||
%{question: "Elvis is a monster in MapleStory?", display: "X", answer: :x, ids: {7, 3}},
|
||||
%{question: "Golems are made of rock?", display: "O", answer: :o, ids: {7, 4}},
|
||||
%{question: "Maple Island is shaped like a maple leaf?", display: "O", answer: :o, ids: {7, 5}},
|
||||
%{question: "All classes can fly?", display: "X", answer: :x, ids: {7, 6}},
|
||||
%{question: "The Moon Bunny is a boss?", display: "X", answer: :x, ids: {7, 7}},
|
||||
%{question: "Scissors of Karma make items tradable?", display: "O", answer: :o, ids: {7, 8}},
|
||||
%{question: "Monster Life is a farming minigame?", display: "O", answer: :o, ids: {7, 9}},
|
||||
%{question: "FM stands for Free Market?", display: "O", answer: :o, ids: {7, 10}}
|
||||
]
|
||||
end
|
||||
end
|
||||
437
lib/odinsea/game/events/snowball.ex
Normal file
437
lib/odinsea/game/events/snowball.ex
Normal file
@@ -0,0 +1,437 @@
|
||||
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
|
||||
247
lib/odinsea/game/events/survival.ex
Normal file
247
lib/odinsea/game/events/survival.ex
Normal file
@@ -0,0 +1,247 @@
|
||||
defmodule Odinsea.Game.Events.Survival do
|
||||
@moduledoc """
|
||||
Survival Event - Last-man-standing platform challenge.
|
||||
Ported from Java `server.events.MapleSurvival`.
|
||||
|
||||
## Gameplay
|
||||
- Players must navigate platforms without falling
|
||||
- Fall once = elimination
|
||||
- Last players to survive win
|
||||
|
||||
## Maps
|
||||
- Stage 1: 809040000
|
||||
- Stage 2: 809040100
|
||||
|
||||
## Win Condition
|
||||
- Survive until time runs out
|
||||
- Last players standing win
|
||||
"""
|
||||
|
||||
alias Odinsea.Game.Event
|
||||
alias Odinsea.Game.Timer.EventTimer
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
require Logger
|
||||
|
||||
# ============================================================================
|
||||
# Types
|
||||
# ============================================================================
|
||||
|
||||
@typedoc "Survival event state"
|
||||
@type t :: %__MODULE__{
|
||||
base: Event.t(),
|
||||
time_started: integer() | nil,
|
||||
event_duration: non_neg_integer(),
|
||||
schedules: [reference()]
|
||||
}
|
||||
|
||||
defstruct [
|
||||
:base,
|
||||
time_started: nil,
|
||||
event_duration: 360_000, # 6 minutes default
|
||||
schedules: []
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Constants
|
||||
# ============================================================================
|
||||
|
||||
@map_ids [809040000, 809040100]
|
||||
@default_duration 360_000 # 6 minutes in ms
|
||||
|
||||
# ============================================================================
|
||||
# Event Implementation
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Creates a new Survival event for the given channel.
|
||||
"""
|
||||
def new(channel_id) do
|
||||
base = Event.new(:survival, channel_id, @map_ids)
|
||||
|
||||
%__MODULE__{
|
||||
base: base,
|
||||
time_started: nil,
|
||||
event_duration: @default_duration,
|
||||
schedules: []
|
||||
}
|
||||
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
|
||||
# Cancel existing schedules
|
||||
cancel_schedules(event)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
base = %{event.base | is_running: true, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up the event after it ends.
|
||||
"""
|
||||
def unreset(%__MODULE__{} = event) do
|
||||
cancel_schedules(event)
|
||||
|
||||
# Open entry portal
|
||||
set_portal_state(event, "join00", true)
|
||||
|
||||
base = %{event.base | is_running: false, player_count: 0}
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
base: base,
|
||||
time_started: nil,
|
||||
schedules: []
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player finishes (reaches end map).
|
||||
Gives prize and achievement.
|
||||
"""
|
||||
def finished(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} finished Survival event!")
|
||||
|
||||
# Give prize
|
||||
Event.give_prize(character)
|
||||
|
||||
# Give achievement (ID 25)
|
||||
Character.finish_achievement(character, 25)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Called when a player loads into an event map.
|
||||
Sends clock if timer is running.
|
||||
"""
|
||||
def on_map_load(%__MODULE__{} = event, character) do
|
||||
if is_timer_started(event) do
|
||||
time_left = get_time_left(event)
|
||||
Logger.debug("Sending Survival clock to #{character.name}: #{div(time_left, 1000)}s remaining")
|
||||
# Send clock packet
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Survival event gameplay.
|
||||
"""
|
||||
def start_event(%__MODULE__{} = event) do
|
||||
Logger.info("Starting Survival event on channel #{event.base.channel_id}")
|
||||
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Close entry portal
|
||||
set_portal_state(event, "join00", false)
|
||||
|
||||
# Broadcast start
|
||||
Event.broadcast_to_event(event.base, :event_started)
|
||||
Event.broadcast_to_event(event.base, {:clock, div(event.event_duration, 1000)})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "The portal has now opened. Press the up arrow key at the portal to enter."})
|
||||
Event.broadcast_to_event(event.base, {:server_notice, "Fall down once, and never get back up again! Get to the top without falling down!"})
|
||||
|
||||
# Schedule event end
|
||||
end_ref = EventTimer.schedule(
|
||||
fn -> end_event(event) end,
|
||||
event.event_duration
|
||||
)
|
||||
|
||||
%__MODULE__{
|
||||
event |
|
||||
time_started: now,
|
||||
schedules: [end_ref]
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the timer has started.
|
||||
"""
|
||||
def is_timer_started(%__MODULE__{time_started: nil}), do: false
|
||||
def is_timer_started(%__MODULE__{}), do: true
|
||||
|
||||
@doc """
|
||||
Gets the total event duration in milliseconds.
|
||||
"""
|
||||
def get_time(%__MODULE__{event_duration: duration}), do: duration
|
||||
|
||||
@doc """
|
||||
Gets the time remaining in milliseconds.
|
||||
"""
|
||||
def get_time_left(%__MODULE__{time_started: nil, event_duration: duration}), do: duration
|
||||
def get_time_left(%__MODULE__{time_started: started, event_duration: duration}) do
|
||||
elapsed = System.system_time(:millisecond) - started
|
||||
max(0, duration - elapsed)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles a player falling (elimination).
|
||||
"""
|
||||
def player_fell(%__MODULE__{} = event, character) do
|
||||
Logger.info("Player #{character.name} fell and was eliminated from Survival event")
|
||||
|
||||
# Warp player out
|
||||
Event.warp_back(character)
|
||||
|
||||
# Unregister from event
|
||||
base = Event.unregister_player(event.base, character.id)
|
||||
|
||||
%{event | base: base}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a player position is valid (on platform).
|
||||
Falling below a certain Y coordinate = elimination.
|
||||
"""
|
||||
def valid_position?(%__MODULE__{}, %{y: y}) do
|
||||
# Y threshold for falling (map-specific)
|
||||
y > -500 # Example threshold
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
defp cancel_schedules(%__MODULE__{schedules: schedules} = event) do
|
||||
Enum.each(schedules, fn ref ->
|
||||
EventTimer.cancel(ref)
|
||||
end)
|
||||
|
||||
%{event | schedules: []}
|
||||
end
|
||||
|
||||
defp set_portal_state(%__MODULE__{}, _portal_name, _state) do
|
||||
# In real implementation, update map portal state
|
||||
:ok
|
||||
end
|
||||
|
||||
defp end_event(%__MODULE__{} = event) do
|
||||
Logger.info("Survival event ended on channel #{event.base.channel_id}")
|
||||
|
||||
# Warp out all remaining players
|
||||
# In real implementation:
|
||||
# - Get all players on event maps
|
||||
# - Give prizes to survivors
|
||||
# - Warp each back to saved location
|
||||
|
||||
# Unreset event
|
||||
unreset(event)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user