646 lines
16 KiB
Elixir
646 lines
16 KiB
Elixir
defmodule Odinsea.Game.MiniGame do
|
|
@moduledoc """
|
|
Mini Game system for Omok and Match Card games in player shops.
|
|
Ported from src/server/shops/MapleMiniGame.java
|
|
|
|
Mini games allow players to:
|
|
- Play Omok (5-in-a-row)
|
|
- Play Match Card (memory game)
|
|
- Track wins/losses/ties
|
|
- Earn game points
|
|
|
|
Game Types:
|
|
- 1 = Omok (5-in-a-row)
|
|
- 2 = Match Card (memory matching)
|
|
|
|
Game lifecycle:
|
|
1. Owner creates game with type and description
|
|
2. Visitor joins and both mark ready
|
|
3. Game starts and players take turns
|
|
4. Game ends with win/loss/tie
|
|
"""
|
|
|
|
use GenServer
|
|
require Logger
|
|
|
|
# Game type constants
|
|
@game_type_omok 1
|
|
@game_type_match_card 2
|
|
|
|
# Shop type constants (from IMaplePlayerShop)
|
|
@shop_type_omok 3
|
|
@shop_type_match_card 4
|
|
|
|
# Board size for Omok
|
|
@omok_board_size 15
|
|
|
|
# Default slots for mini games
|
|
@max_slots 2
|
|
|
|
# Struct for the mini game state
|
|
defstruct [
|
|
:id,
|
|
:owner_id,
|
|
:owner_name,
|
|
:item_id,
|
|
:description,
|
|
:password,
|
|
:game_type,
|
|
:piece_type,
|
|
:map_id,
|
|
:channel,
|
|
:visitors,
|
|
:ready,
|
|
:points,
|
|
:exit_after,
|
|
:open,
|
|
:available,
|
|
# Omok specific
|
|
:board,
|
|
:loser,
|
|
:turn,
|
|
# Match card specific
|
|
:match_cards,
|
|
:first_slot,
|
|
:tie_requested
|
|
]
|
|
|
|
@doc """
|
|
Starts a new mini game GenServer.
|
|
"""
|
|
def start_link(opts) do
|
|
game_id = Keyword.fetch!(opts, :id)
|
|
GenServer.start_link(__MODULE__, opts, name: via_tuple(game_id))
|
|
end
|
|
|
|
@doc """
|
|
Creates a new mini game.
|
|
"""
|
|
def create(opts) do
|
|
game_type = opts[:game_type] || @game_type_omok
|
|
|
|
%__MODULE__{
|
|
id: opts[:id] || generate_id(),
|
|
owner_id: opts[:owner_id],
|
|
owner_name: opts[:owner_name],
|
|
item_id: opts[:item_id],
|
|
description: opts[:description] || "",
|
|
password: opts[:password] || "",
|
|
game_type: game_type,
|
|
piece_type: opts[:piece_type] || 0,
|
|
map_id: opts[:map_id],
|
|
channel: opts[:channel],
|
|
visitors: %{},
|
|
ready: {false, false},
|
|
points: {0, 0},
|
|
exit_after: {false, false},
|
|
open: true,
|
|
available: true,
|
|
# Omok board (15x15 grid)
|
|
board: create_empty_board(),
|
|
loser: 0,
|
|
turn: 1,
|
|
# Match card
|
|
match_cards: [],
|
|
first_slot: 0,
|
|
tie_requested: -1
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Returns the shop type for this game.
|
|
"""
|
|
def shop_type(%__MODULE__{game_type: type}) do
|
|
case type do
|
|
@game_type_omok -> @shop_type_omok
|
|
@game_type_match_card -> @shop_type_match_card
|
|
_ -> @shop_type_omok
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the game type constant.
|
|
"""
|
|
def game_type_omok, do: @game_type_omok
|
|
def game_type_match_card, do: @game_type_match_card
|
|
|
|
@doc """
|
|
Gets the current game state.
|
|
"""
|
|
def get_state(game_pid) when is_pid(game_pid) do
|
|
GenServer.call(game_pid, :get_state)
|
|
end
|
|
|
|
def get_state(game_id) do
|
|
case lookup(game_id) do
|
|
{:ok, pid} -> get_state(pid)
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Looks up a game by ID.
|
|
"""
|
|
def lookup(game_id) do
|
|
case Registry.lookup(Odinsea.MiniGameRegistry, game_id) do
|
|
[{pid, _}] -> {:ok, pid}
|
|
[] -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Adds a visitor to the game.
|
|
Returns the visitor slot or {:error, reason}.
|
|
"""
|
|
def add_visitor(game_id, character_id, character_pid) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:add_visitor, character_id, character_pid})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Removes a visitor from the game.
|
|
"""
|
|
def remove_visitor(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:remove_visitor, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Sets a player as ready/not ready.
|
|
"""
|
|
def set_ready(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:set_ready, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Checks if a player is ready.
|
|
"""
|
|
def is_ready?(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:is_ready, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Starts the game (if all players ready).
|
|
"""
|
|
def start_game(game_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, :start_game)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Makes an Omok move.
|
|
"""
|
|
def make_omok_move(game_id, character_id, x, y, piece_type) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:omok_move, character_id, x, y, piece_type})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Selects a card in Match Card game.
|
|
"""
|
|
def select_card(game_id, character_id, slot) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:select_card, character_id, slot})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Requests a tie.
|
|
"""
|
|
def request_tie(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:request_tie, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Answers a tie request.
|
|
"""
|
|
def answer_tie(game_id, character_id, accept) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:answer_tie, character_id, accept})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Skips turn (forfeits move).
|
|
"""
|
|
def skip_turn(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:skip_turn, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gives up (forfeits game).
|
|
"""
|
|
def give_up(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:give_up, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Sets exit after game flag.
|
|
"""
|
|
def set_exit_after(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:set_exit_after, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Checks if player wants to exit after game.
|
|
"""
|
|
def is_exit_after?(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:is_exit_after, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the visitor slot for a character.
|
|
"""
|
|
def get_visitor_slot(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:get_visitor_slot, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Checks if character is the owner.
|
|
"""
|
|
def is_owner?(game_id, character_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, {:is_owner, character_id})
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Closes the game.
|
|
"""
|
|
def close_game(game_id) do
|
|
with {:ok, pid} <- lookup(game_id) do
|
|
GenServer.call(pid, :close_game)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the number of matches needed to win.
|
|
"""
|
|
def get_matches_to_win(piece_type) do
|
|
case piece_type do
|
|
0 -> 6
|
|
1 -> 10
|
|
2 -> 15
|
|
_ -> 6
|
|
end
|
|
end
|
|
|
|
# ============================================================================
|
|
# GenServer Callbacks
|
|
# ============================================================================
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
state = create(opts)
|
|
{:ok, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:get_state, _from, state) do
|
|
{:reply, state, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:add_visitor, character_id, character_pid}, _from, state) do
|
|
visitor_count = map_size(state.visitors)
|
|
|
|
if visitor_count >= @max_slots - 1 do
|
|
{:reply, {:error, :full}, state}
|
|
else
|
|
slot = visitor_count + 1
|
|
new_visitors = Map.put(state.visitors, character_id, %{pid: character_pid, slot: slot})
|
|
{:reply, {:ok, slot}, %{state | visitors: new_visitors}}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:remove_visitor, character_id}, _from, state) do
|
|
new_visitors = Map.delete(state.visitors, character_id)
|
|
{:reply, :ok, %{state | visitors: new_visitors}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:set_ready, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if slot > 0 do
|
|
{r0, r1} = state.ready
|
|
new_ready = if slot == 1, do: {not r0, r1}, else: {r0, not r1}
|
|
{:reply, :ok, %{state | ready: new_ready}}
|
|
else
|
|
{:reply, {:error, :not_visitor}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:is_ready, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
{r0, r1} = state.ready
|
|
ready = if slot == 1, do: r0, else: r1
|
|
{:reply, ready, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:start_game, _from, state) do
|
|
{r0, r1} = state.ready
|
|
|
|
if r0 and r1 do
|
|
# Initialize game based on type
|
|
new_state =
|
|
case state.game_type do
|
|
@game_type_omok ->
|
|
%{state | board: create_empty_board(), open: false}
|
|
|
|
@game_type_match_card ->
|
|
cards = generate_match_cards(state.piece_type)
|
|
%{state | match_cards: cards, open: false}
|
|
|
|
_ ->
|
|
%{state | open: false}
|
|
end
|
|
|
|
{:reply, {:ok, new_state.loser}, new_state}
|
|
else
|
|
{:reply, {:error, :not_ready}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:omok_move, character_id, x, y, piece_type}, _from, state) do
|
|
# Check if it's this player's turn (loser goes first)
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if slot != state.loser + 1 do
|
|
{:reply, {:error, :not_your_turn}, state}
|
|
else
|
|
# Check if position is valid and empty
|
|
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
|
{:reply, {:error, :invalid_position}, state}
|
|
else
|
|
current_piece = get_board_piece(state.board, x, y)
|
|
|
|
if current_piece != 0 do
|
|
{:reply, {:error, :position_occupied}, state}
|
|
else
|
|
# Place piece
|
|
new_board = set_board_piece(state.board, x, y, piece_type)
|
|
|
|
# Check for win
|
|
won = check_omok_win(new_board, x, y, piece_type)
|
|
|
|
# Next turn
|
|
next_loser = rem(state.loser + 1, @max_slots)
|
|
|
|
new_state = %{
|
|
state
|
|
| board: new_board,
|
|
loser: next_loser
|
|
}
|
|
|
|
if won do
|
|
# Award point
|
|
{p0, p1} = state.points
|
|
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
|
{:reply, {:win, slot}, %{new_state | points: new_points, open: true}}
|
|
else
|
|
{:reply, {:ok, won}, new_state}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:select_card, character_id, slot}, _from, state) do
|
|
# Match card logic
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if slot != state.loser + 1 do
|
|
{:reply, {:error, :not_your_turn}, state}
|
|
else
|
|
# Simplified match card logic
|
|
# In full implementation, track first/second card selection and matching
|
|
|
|
turn = state.turn
|
|
|
|
if turn == 1 do
|
|
# First card
|
|
{:reply, {:first_card, slot}, %{state | first_slot: slot, turn: 0}}
|
|
else
|
|
# Second card - check match
|
|
first_card = Enum.at(state.match_cards, state.first_slot - 1)
|
|
second_card = Enum.at(state.match_cards, slot - 1)
|
|
|
|
if first_card == second_card do
|
|
# Match! Award point
|
|
{p0, p1} = state.points
|
|
new_points = if slot == 1, do: {p0 + 1, p1}, else: {p0, p1 + 1}
|
|
|
|
# Check for game win
|
|
{p0_new, p1_new} = new_points
|
|
matches_needed = get_matches_to_win(state.piece_type)
|
|
|
|
if p0_new >= matches_needed or p1_new >= matches_needed do
|
|
{:reply, {:game_win, slot}, %{state | points: new_points, turn: 1, open: true}}
|
|
else
|
|
{:reply, {:match, slot}, %{state | points: new_points, turn: 1}}
|
|
end
|
|
else
|
|
# No match, switch turns
|
|
next_loser = rem(state.loser + 1, @max_slots)
|
|
{:reply, {:no_match, slot}, %{state | turn: 1, loser: next_loser}}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:request_tie, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if state.tie_requested == -1 do
|
|
{:reply, :ok, %{state | tie_requested: slot}}
|
|
else
|
|
{:reply, {:error, :already_requested}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:answer_tie, character_id, accept}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if state.tie_requested != -1 and state.tie_requested != slot do
|
|
if accept do
|
|
# Tie accepted
|
|
{p0, p1} = state.points
|
|
{:reply, {:tie, slot}, %{state | tie_requested: -1, points: {p0, p1}, open: true}}
|
|
else
|
|
{:reply, {:deny, slot}, %{state | tie_requested: -1}}
|
|
end
|
|
else
|
|
{:reply, {:error, :invalid_request}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:skip_turn, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if slot == state.loser + 1 do
|
|
next_loser = rem(state.loser + 1, @max_slots)
|
|
{:reply, :ok, %{state | loser: next_loser}}
|
|
else
|
|
{:reply, {:error, :not_your_turn}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:give_up, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
# Other player wins
|
|
winner = if slot == 1, do: 2, else: 1
|
|
{:reply, {:give_up, winner}, %{state | open: true}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:set_exit_after, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
|
|
if slot > 0 do
|
|
{e0, e1} = state.exit_after
|
|
new_exit = if slot == 1, do: {not e0, e1}, else: {e0, not e1}
|
|
{:reply, :ok, %{state | exit_after: new_exit}}
|
|
else
|
|
{:reply, {:error, :not_visitor}, state}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:is_exit_after, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
{e0, e1} = state.exit_after
|
|
exit = if slot == 1, do: e0, else: e1
|
|
{:reply, exit, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:get_visitor_slot, character_id}, _from, state) do
|
|
slot = get_slot_for_character_internal(state, character_id)
|
|
{:reply, slot, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:is_owner, character_id}, _from, state) do
|
|
{:reply, character_id == state.owner_id, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:close_game, _from, state) do
|
|
# Remove all visitors
|
|
Enum.each(state.visitors, fn {_id, data} ->
|
|
send(data.pid, {:game_closed, state.id})
|
|
end)
|
|
|
|
{:stop, :normal, :ok, state}
|
|
end
|
|
|
|
# ============================================================================
|
|
# Private Helper Functions
|
|
# ============================================================================
|
|
|
|
defp via_tuple(game_id) do
|
|
{:via, Registry, {Odinsea.MiniGameRegistry, game_id}}
|
|
end
|
|
|
|
defp generate_id do
|
|
:erlang.unique_integer([:positive])
|
|
end
|
|
|
|
defp get_slot_for_character_internal(state, character_id) do
|
|
cond do
|
|
character_id == state.owner_id -> 1
|
|
true -> Map.get(state.visitors, character_id, %{}) |> Map.get(:slot, -1)
|
|
end
|
|
end
|
|
|
|
defp create_empty_board do
|
|
for _ <- 1..@omok_board_size do
|
|
for _ <- 1..@omok_board_size, do: 0
|
|
end
|
|
end
|
|
|
|
defp get_board_piece(board, x, y) do
|
|
row = Enum.at(board, y)
|
|
Enum.at(row, x)
|
|
end
|
|
|
|
defp set_board_piece(board, x, y, piece) do
|
|
row = Enum.at(board, y)
|
|
new_row = List.replace_at(row, x, piece)
|
|
List.replace_at(board, y, new_row)
|
|
end
|
|
|
|
defp generate_match_cards(piece_type) do
|
|
matches_needed = get_matches_to_win(piece_type)
|
|
|
|
cards =
|
|
for i <- 0..(matches_needed - 1) do
|
|
[i, i]
|
|
end
|
|
|> List.flatten()
|
|
|
|
# Shuffle cards
|
|
Enum.shuffle(cards)
|
|
end
|
|
|
|
# Omok win checking - check all directions from the last move
|
|
defp check_omok_win(board, x, y, piece_type) do
|
|
directions = [
|
|
{1, 0}, # Horizontal
|
|
{0, 1}, # Vertical
|
|
{1, 1}, # Diagonal \
|
|
{1, -1} # Diagonal /
|
|
]
|
|
|
|
Enum.any?(directions, fn {dx, dy} ->
|
|
count = count_in_direction(board, x, y, dx, dy, piece_type) +
|
|
count_in_direction(board, x, y, -dx, -dy, piece_type) - 1
|
|
count >= 5
|
|
end)
|
|
end
|
|
|
|
defp count_in_direction(board, x, y, dx, dy, piece_type, count \\ 0) do
|
|
if x < 0 or x >= @omok_board_size or y < 0 or y >= @omok_board_size do
|
|
count
|
|
else
|
|
piece = get_board_piece(board, x, y)
|
|
|
|
if piece == piece_type do
|
|
count_in_direction(board, x + dx, y + dy, dx, dy, piece_type, count + 1)
|
|
else
|
|
count
|
|
end
|
|
end
|
|
end
|
|
end
|