diff --git a/assets/css/components.css b/assets/css/components.css
index 3d21318..13d0d75 100644
--- a/assets/css/components.css
+++ b/assets/css/components.css
@@ -3,3 +3,4 @@
@import './components/dropdown.css';
@import './components/coinflip.css';
@import './components/slots_reel.css';
+@import './components/horse_race.css';
diff --git a/assets/css/components/horse_race.css b/assets/css/components/horse_race.css
new file mode 100644
index 0000000..d2a8999
--- /dev/null
+++ b/assets/css/components/horse_race.css
@@ -0,0 +1,183 @@
+@keyframes horse-gallop {
+ 0% {
+ transform: scaleY(1) translateY(0px);
+ }
+ 25% {
+ transform: scaleY(0.9) translateY(-1px);
+ }
+ 50% {
+ transform: scaleY(1.1) translateY(1px);
+ }
+ 75% {
+ transform: scaleY(0.9) translateY(-1px);
+ }
+ 100% {
+ transform: scaleY(1) translateY(0px);
+ }
+}
+
+.horse-marker {
+ transition: left 0.5s ease-out;
+ animation: horse-gallop 0.6s infinite;
+}
+
+.horse-marker.racing {
+ animation: horse-gallop 0.4s infinite;
+}
+
+.horse-progress {
+ transition: width 0.5s ease-out;
+}
+
+@keyframes winner-announce {
+ 0% {
+ transform: scale(0.5) rotateY(90deg);
+ opacity: 0;
+ }
+ 50% {
+ transform: scale(1.1) rotateY(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1) rotateY(0deg);
+ opacity: 1;
+ }
+}
+
+.winner-announcement {
+ animation: winner-announce 1s ease-out;
+ transform-origin: center;
+}
+
+@keyframes confetti {
+ 0% {
+ transform: translateY(-100vh) rotateZ(0deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(100vh) rotateZ(360deg);
+ opacity: 0;
+ }
+}
+
+.confetti-piece {
+ position: absolute;
+ animation: confetti 3s linear infinite;
+}
+
+@keyframes finish-line-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
+ }
+ 70% {
+ box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
+ }
+}
+
+.finish-line-pulse {
+ animation: finish-line-pulse 1s ease-out;
+}
+
+.horse-marker.near-finish {
+ animation: horse-gallop 0.3s infinite, bounce-near-finish 0.8s ease-in-out infinite;
+}
+
+@keyframes bounce-near-finish {
+ 0%, 100% {
+ transform: translateY(0) scale(1);
+ }
+ 50% {
+ transform: translateY(-2px) scale(1.05);
+ }
+}
+
+@keyframes countdown-pulse {
+ 0% {
+ transform: scale(0.8);
+ opacity: 0;
+ }
+ 50% {
+ transform: scale(1.2);
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1);
+ opacity: 1;
+ }
+}
+
+.countdown-pulse {
+ animation: countdown-pulse 1s ease-in-out;
+}
+
+@keyframes racing-stripes {
+ 0% {
+ background-position-x: 0;
+ }
+ 100% {
+ background-position-x: 20px;
+ }
+}
+
+.horse-progress.racing {
+ animation: racing-stripes 0.5s linear infinite;
+}
+
+@keyframes shimmer {
+ 0% {
+ background-position: -468px 0;
+ }
+ 100% {
+ background-position: 468px 0;
+ }
+}
+
+.race-shimmer {
+ background: linear-gradient(
+ 90deg,
+ #f0f0f0 0%,
+ #ffffff 50%,
+ #f0f0f0 100%
+ );
+ background-size: 468px 104px;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.horse-percentage {
+ transition: all 0.3s ease;
+}
+
+.horse-percentage.updated {
+ animation: percentage-update 0.3s ease-out;
+}
+
+@keyframes percentage-update {
+ 0% {
+ transform: scale(1);
+ color: inherit;
+ }
+ 50% {
+ transform: scale(1.1);
+ color: #10b981;
+ }
+ 100% {
+ transform: scale(1);
+ color: inherit;
+ }
+}
+
+@keyframes bounce-custom {
+ 0%, 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+
+.animate-bounce {
+ animation: bounce-custom 0.6s infinite;
+}
\ No newline at end of file
diff --git a/assets/js/app.js b/assets/js/app.js
index ba81779..856efe4 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -22,7 +22,7 @@ import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
import live_select from "live_select"
-import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene , Banner, ReelAnimation, PaytableModal, ZipUpload } from "./hooks";
+import { QrScanner, Wheel, Confetti, Countdown, Sorting, CoinFlip, Redirect, CredentialScene , Banner, ReelAnimation, PaytableModal, ZipUpload, HorseRace } from "./hooks";
let Hooks = {
QrScanner: QrScanner,
@@ -37,6 +37,7 @@ let Hooks = {
ReelAnimation: ReelAnimation,
PaytableModal: PaytableModal,
ZipUpload: ZipUpload,
+ HorseRace: HorseRace,
...live_select
};
diff --git a/assets/js/hooks/horse_race.js b/assets/js/hooks/horse_race.js
new file mode 100644
index 0000000..8cbd131
--- /dev/null
+++ b/assets/js/hooks/horse_race.js
@@ -0,0 +1,406 @@
+export const HorseRace = {
+ mounted() {
+ this.raceTimer = null;
+ this.startTime = null;
+ this.isRunning = false;
+ this.endTime = null;
+ this.horses = [];
+ this.horseSpeeds = [];
+ this.lastUpdateTime = 0;
+ this.raceFinished = false;
+ this.firstWinner = null;
+ this.winnerAnnounced = false;
+
+ this.componentId = this.el.getAttribute("id");
+
+ this.handleEvent("start_race", (data) => {
+ this.startRace(data);
+ });
+
+ this.handleEvent("stop_race", () => {
+ this.stopRace();
+ });
+
+ this.handleEvent("reset_race", () => {
+ this.resetRace();
+ });
+
+ this.initializeHorses();
+ },
+
+ initializeHorses() {
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+ this.horses = Array(horseMarkers.length).fill(0);
+ this.horseSpeeds = this.generateHorseSpeeds(horseMarkers.length);
+ },
+
+ generateHorseSpeeds(count) {
+ const speeds = [];
+
+ for (let i = 0; i < count; i++) {
+ const baseSpeed = 0.95 + Math.random() * 0.10;
+ const variation = 0.02 + Math.random() * 0.03;
+
+ speeds.push({
+ baseSpeed: baseSpeed,
+ variation: variation
+ });
+ }
+
+ return speeds;
+ },
+
+ startRace(data) {
+ if (this.isRunning) return;
+
+ this.showCountdown(() => {
+ this.isRunning = true;
+ this.raceFinished = false;
+ this.firstWinner = null;
+ this.winnerAnnounced = false;
+ this.startTime = Date.now();
+
+ const durationSeconds = data?.duration || 120;
+ this.endTime = this.startTime + durationSeconds * 1000;
+
+ this.addRacingAnimations();
+
+ this.raceTimer = setInterval(() => {
+ const now = Date.now();
+ const elapsed = (now - this.startTime) / 1000;
+ const remaining = Math.max(0, this.endTime - now);
+ const remainingSeconds = Math.floor(remaining / 1000);
+
+ const timerElement = document.getElementById("race-timer");
+ if (timerElement) {
+ timerElement.textContent = this.formatTime(remainingSeconds);
+ }
+
+ this.updateHorsePositions(elapsed, durationSeconds);
+
+ this.pushEvent("update_race", { elapsed });
+
+ if (remaining <= 0) {
+ this.endRace();
+ }
+ }, 100);
+ });
+ },
+
+ showCountdown(callback) {
+ const countdown = document.createElement("div");
+ countdown.id = "race-countdown";
+ countdown.className = "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-black bg-opacity-80 text-white text-9xl font-bold rounded-full w-48 h-48 flex items-center justify-center countdown-pulse";
+
+ document.body.appendChild(countdown);
+
+ let count = 3;
+ countdown.textContent = count;
+
+ const countdownInterval = setInterval(() => {
+ count--;
+ if (count > 0) {
+ countdown.textContent = count;
+ } else {
+ countdown.textContent = "GO!";
+ setTimeout(() => {
+ countdown.remove();
+ callback();
+ }, 500);
+ clearInterval(countdownInterval);
+ }
+ }, 1000);
+ },
+
+ addRacingAnimations() {
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+
+ horseMarkers.forEach((marker) => {
+ marker.classList.add("racing");
+ });
+ },
+
+ removeRacingAnimations() {
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+
+ horseMarkers.forEach((marker) => {
+ marker.classList.remove("racing", "near-finish");
+ });
+ },
+
+ updateHorsePositions(elapsed, totalDuration) {
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+ const horsePercentages = document.querySelectorAll(".horse-percentage");
+
+ horseMarkers.forEach((marker, index) => {
+ if (index >= this.horses.length) return;
+
+ const speed = this.horseSpeeds[index];
+ const timeProgress = elapsed / totalDuration;
+
+ let basePosition = timeProgress * speed.baseSpeed * 85;
+ const randomFactor = (Math.random() - 0.5) * speed.variation * 3;
+ basePosition += randomFactor;
+
+ if (timeProgress > 0.5) {
+ const surgeIntensity = (timeProgress - 0.5) / 0.5;
+ const surgeFactor = Math.random() * 10 * surgeIntensity;
+ basePosition += surgeFactor;
+ }
+
+ if (timeProgress > 0.7) {
+ const sprintIntensity = (timeProgress - 0.7) / 0.3;
+ const sprintBoost = Math.random() * 8 * sprintIntensity;
+ basePosition += sprintBoost;
+ }
+
+ if (timeProgress > 0.9) {
+ const finalPushIntensity = (timeProgress - 0.9) / 0.1;
+ const finalPush = Math.random() * 12 * finalPushIntensity;
+ basePosition += finalPush;
+ }
+
+ let newPosition = Math.max(this.horses[index], basePosition);
+ newPosition = Math.min(newPosition, 100);
+ this.horses[index] = newPosition;
+
+ const visualPosition = newPosition;
+
+ if (visualPosition >= 100) {
+ marker.style.left = `calc(95% + 0px)`;
+ } else if (visualPosition >= 95) {
+ const finalProgress = (visualPosition - 95) / 5;
+ const finalPosition = 92 + (finalProgress * 3);
+ marker.style.left = `calc(${finalPosition}% + 0px)`;
+ } else {
+ const startOffset = 0;
+ const scaledPosition = (visualPosition / 95) * 92;
+ marker.style.left = `calc(${scaledPosition}% + ${startOffset}px)`;
+ }
+
+ if (visualPosition > 80) {
+ marker.classList.add("near-finish");
+ } else {
+ marker.classList.remove("near-finish");
+ }
+
+ const emoji = marker.querySelector("span");
+ if (emoji) {
+ if (newPosition >= 100) {
+ if (!this.firstWinner && !this.winnerAnnounced) {
+ this.firstWinner = index + 1;
+ this.winnerAnnounced = true;
+ emoji.textContent = "π";
+ this.triggerFinishLinePulse();
+ this.declareWinner(index + 1);
+ } else if (this.firstWinner === (index + 1)) {
+ emoji.textContent = "π";
+ } else {
+ emoji.textContent = "π";
+ this.triggerFinishLinePulse();
+ }
+ } else if (newPosition >= 98) {
+ emoji.textContent = "π";
+ this.triggerFinishLinePulse();
+ } else {
+ emoji.textContent = "π";
+ }
+ }
+
+ const percentageElement = horsePercentages[index];
+ if (percentageElement) {
+ const displayPercentage = Math.round(newPosition);
+ percentageElement.textContent = `${displayPercentage}%`;
+ percentageElement.classList.add("updated");
+ setTimeout(() => {
+ percentageElement.classList.remove("updated");
+ }, 300);
+ }
+ });
+ },
+
+ triggerFinishLinePulse() {
+ const finishLines = document.querySelectorAll(".finish-line");
+ finishLines.forEach(line => {
+ line.classList.add("finish-line-pulse");
+ setTimeout(() => {
+ line.classList.remove("finish-line-pulse");
+ }, 1000);
+ });
+ },
+
+ declareWinner(horseNumber) {
+ this.showWinnerAnnouncement(horseNumber);
+ this.createConfetti();
+ },
+
+ showWinnerAnnouncement(horseNumber) {
+ const existingAnnouncement = document.getElementById("winner-announcement");
+ if (existingAnnouncement) {
+ existingAnnouncement.remove();
+ }
+
+ const announcement = document.createElement("div");
+ announcement.id = "winner-announcement";
+ announcement.className = "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 bg-gradient-to-r from-yellow-400 to-yellow-600 text-white p-8 rounded-xl shadow-2xl winner-announcement border-4 border-yellow-300";
+ announcement.innerHTML = `
+
+
π
+
VENCEDOR!
+
Cavalo #${horseNumber} ganhou a corrida!
+
ππ
+
ParabΓ©ns! π
+
+ `;
+
+ document.body.appendChild(announcement);
+
+ setTimeout(() => {
+ if (document.getElementById("winner-announcement")) {
+ announcement.remove();
+ }
+ }, 5000);
+ },
+
+ createConfetti() {
+ for (let i = 0; i < 100; i++) {
+ const confetti = document.createElement("div");
+ confetti.className = "confetti-piece";
+ confetti.style.left = Math.random() * 100 + "vw";
+ confetti.style.animationDelay = Math.random() * 3 + "s";
+ confetti.style.backgroundColor = ["#ffdb0d", "#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#ffeaa7"][Math.floor(Math.random() * 6)];
+ confetti.style.width = "10px";
+ confetti.style.height = "10px";
+ document.body.appendChild(confetti);
+
+ setTimeout(() => {
+ if (document.body.contains(confetti)) {
+ confetti.remove();
+ }
+ }, 3000);
+ }
+ },
+
+ endRace() {
+ if (this.raceFinished) return;
+
+ this.raceFinished = true;
+ this.stopRace();
+
+ if (!this.winnerAnnounced) {
+ let maxPosition = 0;
+ let actualWinnerIndex = 0;
+
+ this.horses.forEach((position, index) => {
+ if (position > maxPosition) {
+ maxPosition = position;
+ actualWinnerIndex = index;
+ }
+ });
+
+ this.updateFinalEmojis(actualWinnerIndex);
+ this.showWinnerAnnouncement(actualWinnerIndex + 1);
+ this.createConfetti();
+ }
+ },
+
+ updateFinalEmojis(winnerIndex) {
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+
+ horseMarkers.forEach((marker, index) => {
+ const emoji = marker.querySelector("span");
+ if (emoji) {
+ if (index === winnerIndex) {
+ emoji.textContent = "π";
+ } else if (this.horses[index] >= 98) {
+ emoji.textContent = "π";
+ } else {
+ emoji.textContent = "π";
+ }
+ }
+ });
+ },
+
+ stopRace() {
+ this.isRunning = false;
+ this.raceFinished = true;
+ if (this.raceTimer) {
+ clearInterval(this.raceTimer);
+ this.raceTimer = null;
+ }
+
+ this.removeRacingAnimations();
+ },
+
+ resetRace() {
+ this.stopRace();
+ this.startTime = null;
+ this.endTime = null;
+ this.raceFinished = false;
+ this.firstWinner = null;
+ this.winnerAnnounced = false;
+ this.horses.fill(0);
+
+ const timerElement = document.getElementById("race-timer");
+ const gameElement = document.getElementById("horse-race-game");
+ let totalTime = 120;
+
+ if (gameElement) {
+ const durationData = gameElement.getAttribute("data-duration");
+ if (durationData) {
+ totalTime = parseInt(durationData);
+ }
+ }
+
+ if (timerElement) {
+ timerElement.textContent = this.formatTime(totalTime);
+ }
+
+ const progressBar = document.getElementById("race-progress-bar");
+ if (progressBar) {
+ progressBar.style.width = "0%";
+ }
+
+ const horseMarkers = document.querySelectorAll(".horse-marker");
+ const horsePercentages = document.querySelectorAll(".horse-percentage");
+
+ horseMarkers.forEach((marker) => {
+ marker.style.left = "calc(0% + 0px)";
+ marker.classList.remove("racing", "near-finish");
+ const emoji = marker.querySelector("span");
+ if (emoji) {
+ emoji.textContent = "π";
+ }
+ });
+
+ horsePercentages.forEach((percentage) => {
+ percentage.textContent = "0%";
+ percentage.classList.remove("updated");
+ });
+
+ const announcement = document.getElementById("winner-announcement");
+ if (announcement) {
+ announcement.remove();
+ }
+
+ const countdown = document.getElementById("race-countdown");
+ if (countdown) {
+ countdown.remove();
+ }
+
+ const confettiPieces = document.querySelectorAll(".confetti-piece");
+ confettiPieces.forEach(piece => piece.remove());
+ },
+
+ formatTime(totalSeconds) {
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
+ },
+
+ destroyed() {
+ if (this.raceTimer) {
+ clearInterval(this.raceTimer);
+ }
+ }
+};
\ No newline at end of file
diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js
index a7504bc..8ab3ffe 100644
--- a/assets/js/hooks/index.js
+++ b/assets/js/hooks/index.js
@@ -10,3 +10,4 @@ export { CredentialScene } from "./credential-scene.js";
export { ReelAnimation } from "./reel_animation.js";
export { PaytableModal } from "./paytable_modal.js";
export { ZipUpload } from "./zip_upload.js";
+export { HorseRace } from "./horse_race.js";
diff --git a/lib/pearl/minigames.ex b/lib/pearl/minigames.ex
index fab9c8f..8d90434 100644
--- a/lib/pearl/minigames.ex
+++ b/lib/pearl/minigames.ex
@@ -1702,4 +1702,194 @@ defmodule Pearl.Minigames do
end
end)
end
+
+ @doc """
+ Gets the horse race multiplier.
+
+ ## Examples
+
+ iex> get_horse_race_multiplier()
+ 2.0
+ """
+ def get_horse_race_multiplier do
+ case Constants.get("horse_race_multiplier") do
+ {:ok, multiplier} ->
+ multiplier
+
+ {:error, _} ->
+ change_horse_race_multiplier(2.0)
+ 2.0
+ end
+ end
+
+ @doc """
+ Changes the horse race multiplier.
+
+ ## Examples
+
+ iex> change_horse_race_multiplier(3.5)
+ :ok
+ """
+ def change_horse_race_multiplier(multiplier) when is_number(multiplier) do
+ Constants.set("horse_race_multiplier", multiplier)
+ end
+
+ @doc """
+ Gets the horse race duration in minutes.
+
+ ## Examples
+
+ iex> get_horse_race_duration()
+ 2
+ """
+ def get_horse_race_duration do
+ case Constants.get("horse_race_duration") do
+ {:ok, duration} ->
+ duration
+
+ {:error, _} ->
+ change_horse_race_duration(2)
+ 2
+ end
+ end
+
+ @doc """
+ Changes the horse race duration in minutes.
+
+ ## Examples
+
+ iex> change_horse_race_duration(5)
+ :ok
+ """
+ def change_horse_race_duration(minutes) when is_integer(minutes) do
+ Constants.set("horse_race_duration", minutes)
+ end
+
+ @doc """
+ Gets the horse race entry fee.
+
+ ## Examples
+
+ iex> get_horse_race_entry_fee()
+ 100
+ """
+ def get_horse_race_entry_fee do
+ case Constants.get("horse_race_entry_fee") do
+ {:ok, fee} ->
+ fee
+
+ {:error, _} ->
+ change_horse_race_entry_fee(100)
+ 100
+ end
+ end
+
+ @doc """
+ Changes the horse race entry fee.
+
+ ## Examples
+
+ iex> change_horse_race_entry_fee(250)
+ :ok
+ """
+ def change_horse_race_entry_fee(fee) when is_integer(fee) do
+ Constants.set("horse_race_entry_fee", fee)
+ end
+
+ @doc """
+ Gets the number of horses in a race.
+
+ ## Examples
+
+ iex> get_horse_race_number_of_horses()
+ 5
+ """
+ def get_horse_race_number_of_horses do
+ case Constants.get("horse_race_number_of_horses") do
+ {:ok, count} ->
+ count
+
+ {:error, _} ->
+ change_horse_race_number_of_horses(5)
+ 5
+ end
+ end
+
+ @doc """
+ Changes the number of horses in a race (between 3 and 8).
+
+ ## Examples
+
+ iex> change_horse_race_number_of_horses(7)
+ :ok
+
+ iex> change_horse_race_number_of_horses(2)
+ ** (FunctionClauseError)
+ """
+ def change_horse_race_number_of_horses(count)
+ when is_integer(count) and count >= 3 and count <= 8 do
+ Constants.set("horse_race_number_of_horses", count)
+ end
+
+ @doc """
+ Gets the horse race house fee percentage.
+
+ ## Examples
+
+ iex> get_horse_race_house_fee()
+ 5.0
+ """
+ def get_horse_race_house_fee do
+ case Constants.get("horse_race_house_fee") do
+ {:ok, fee} ->
+ fee
+
+ {:error, _} ->
+ change_horse_race_house_fee(5.0)
+ 5.0
+ end
+ end
+
+ @doc """
+ Changes the horse race house fee percentage.
+
+ ## Examples
+
+ iex> change_horse_race_house_fee(10.0)
+ :ok
+ """
+ def change_horse_race_house_fee(fee) when is_number(fee) do
+ Constants.set("horse_race_house_fee", fee)
+ end
+
+ @doc """
+ Gets the horse race active status.
+
+ ## Examples
+
+ iex> horse_race_active?()
+ true
+ """
+ def horse_race_active? do
+ case Constants.get("horse_race_active") do
+ {:ok, active} ->
+ active
+
+ {:error, _} ->
+ change_horse_race_active(false)
+ false
+ end
+ end
+
+ @doc """
+ Changes the horse race active status.
+
+ ## Examples
+
+ iex> change_horse_race_active(true)
+ :ok
+ """
+ def change_horse_race_active(active?) when is_boolean(active?) do
+ Constants.set("horse_race_active", active?)
+ end
end
diff --git a/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/form_component.ex b/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/form_component.ex
new file mode 100644
index 0000000..9171c29
--- /dev/null
+++ b/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/form_component.ex
@@ -0,0 +1,190 @@
+defmodule PearlWeb.Backoffice.MinigamesLive.HorseRace.FormComponent do
+ @moduledoc false
+ use PearlWeb, :live_component
+
+ import PearlWeb.Components.Forms
+
+ alias Ecto.Changeset
+ alias Pearl.Minigames
+
+ def render(assigns) do
+ ~H"""
+
+ <.page
+ title={gettext("Horse Race Configuration")}
+ subtitle={gettext("Configures horse race minigame's internal settings.")}
+ >
+ <:actions>
+ <.link patch={~p"/dashboard/minigames/horse_race/simulation"}>
+ <.button>
+ <.icon name="hero-play" class="w-5" />
+
+
+
+
+ <.form
+ id="horse-race-config-form"
+ for={@form}
+ phx-submit="save"
+ phx-change="validate"
+ phx-target={@myself}
+ >
+
+ <.field
+ field={@form[:is_active]}
+ name="is_active"
+ label={gettext("Active")}
+ type="switch"
+ help_text={gettext("Defines whether the horse race minigame is active.")}
+ wrapper_class="my-6"
+ />
+ <.field
+ field={@form[:multiplier]}
+ name="multiplier"
+ type="number"
+ step="0.1"
+ label={gettext("Win Multiplier")}
+ help_text={
+ gettext(
+ "Multiplier applied to winnings when betting on the winning horse. E.g., 2.5x means bettors get 2.5x their bet."
+ )
+ }
+ />
+ <.field
+ field={@form[:duration_minutes]}
+ name="duration_minutes"
+ type="number"
+ label={gettext("Race Duration (minutes)")}
+ help_text={gettext("How long the race animation will run in minutes.")}
+ />
+ <.field
+ field={@form[:entry_fee]}
+ name="entry_fee"
+ type="number"
+ label={gettext("Entry Fee (tokens)")}
+ help_text={gettext("Cost in tokens to participate in the horse race.")}
+ />
+
+
+
+
{gettext("Game Settings")}
+
+ <.field
+ field={@form[:number_of_horses]}
+ name="number_of_horses"
+ type="number"
+ label={gettext("Number of Horses")}
+ help_text={gettext("How many horses will race (3-8).")}
+ />
+ <.field
+ field={@form[:house_fee]}
+ name="house_fee"
+ type="number"
+ step="0.1"
+ label={gettext("House Fee (%)")}
+ help_text={gettext("Percentage of pot taken as house fee (0-100).")}
+ />
+
+
+
+
+ <.button phx-disable-with={gettext("Saving...")}>
+ {gettext("Save Configuration")}
+
+
+
+
+
+
+ """
+ end
+
+ def mount(socket) do
+ {:ok,
+ socket
+ |> assign(
+ form:
+ to_form(
+ %{
+ "is_active" => Minigames.horse_race_active?(),
+ "multiplier" => Minigames.get_horse_race_multiplier(),
+ "duration_minutes" => Minigames.get_horse_race_duration(),
+ "entry_fee" => Minigames.get_horse_race_entry_fee(),
+ "number_of_horses" => Minigames.get_horse_race_number_of_horses(),
+ "house_fee" => Minigames.get_horse_race_house_fee()
+ },
+ as: :horse_race_configuration
+ )
+ )}
+ end
+
+ def handle_event("validate", params, socket) do
+ changeset = validate_configuration(params)
+
+ {:noreply,
+ assign(socket, form: to_form(changeset, action: :validate, as: :horse_race_configuration))}
+ end
+
+ def handle_event("save", params, socket) do
+ if valid_config?(params) do
+ Minigames.change_horse_race_multiplier(params["multiplier"] |> String.to_float())
+ Minigames.change_horse_race_duration(params["duration_minutes"] |> String.to_integer())
+ Minigames.change_horse_race_entry_fee(params["entry_fee"] |> String.to_integer())
+
+ Minigames.change_horse_race_number_of_horses(
+ params["number_of_horses"]
+ |> String.to_integer()
+ )
+
+ Minigames.change_horse_race_house_fee(params["house_fee"] |> String.to_float())
+ Minigames.change_horse_race_active("true" == params["is_active"])
+
+ {:noreply, socket |> push_patch(to: ~p"/dashboard/minigames/")}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp validate_configuration(params) do
+ {%{},
+ %{
+ is_active: :boolean,
+ multiplier: :float,
+ duration_minutes: :integer,
+ entry_fee: :integer,
+ number_of_horses: :integer,
+ house_fee: :float
+ }}
+ |> Changeset.cast(params, [
+ :is_active,
+ :multiplier,
+ :duration_minutes,
+ :entry_fee,
+ :number_of_horses,
+ :house_fee
+ ])
+ |> Changeset.validate_required([
+ :multiplier,
+ :duration_minutes,
+ :entry_fee,
+ :number_of_horses,
+ :house_fee
+ ])
+ |> Changeset.validate_number(:multiplier, greater_than: 0)
+ |> Changeset.validate_number(:duration_minutes, greater_than: 0)
+ |> Changeset.validate_number(:entry_fee, greater_than_or_equal_to: 0)
+ |> Changeset.validate_number(:number_of_horses,
+ greater_than_or_equal_to: 3,
+ less_than_or_equal_to: 8
+ )
+ |> Changeset.validate_number(:house_fee,
+ greater_than_or_equal_to: 0,
+ less_than_or_equal_to: 100
+ )
+ end
+
+ defp valid_config?(params) do
+ validation = validate_configuration(params)
+ validation.errors == []
+ end
+end
diff --git a/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/index.ex b/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/index.ex
new file mode 100644
index 0000000..68acdd0
--- /dev/null
+++ b/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/index.ex
@@ -0,0 +1,307 @@
+defmodule PearlWeb.Backoffice.MinigamesLive.HorseRace.Index do
+ @moduledoc false
+ use PearlWeb, :live_component
+
+ alias Pearl.Minigames
+
+ def render(assigns) do
+ ~H"""
+
+
+
{gettext("Horse Race Game")}
+
+
+
+
{gettext("Entry Fee")}
+
{@entry_fee} tokens
+
+
+
{gettext("Win Multiplier")}
+
{Float.round(@multiplier, 2)}x
+
+
+
{gettext("Race Duration")}
+
{@duration_minutes} min
+
+
+
{gettext("Time Remaining")}
+
+
+ {format_time(@time_remaining)}
+
+
+
+
+
+
+
+
{gettext("Race Track")}
+
+
+
+
+ <%= for {horse, index} <- Enum.with_index(@horses) do %>
+
+
+
+
+
+
+
+ <%= if horse >= 95 do %>
+ π
+ <% else %>
+ π
+ <% end %>
+
+
+
+
+
+
+
+
+ {round(horse)}%
+
+
+
+ <% end %>
+
+
+
+ <.button
+ phx-click="start_race"
+ phx-target={@myself}
+ disabled={@racing}
+ id="btn-start-race"
+ phx-value-duration={@total_race_time}
+ class="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
+ >
+ <.icon name="hero-play" class="w-5 mr-2" />
+ {gettext("Start Race")}
+
+
+ <%= if @racing do %>
+ <.button
+ phx-click="stop_race"
+ phx-target={@myself}
+ id="btn-stop-race"
+ class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
+ >
+ <.icon name="hero-stop" class="w-5 mr-2" />
+ {gettext("Stop Race")}
+
+ <% end %>
+
+
+
+ <%= if @winner do %>
+
+
+
π
+
+ π {gettext("Cavalo #%{horse} venceu a corrida! π", horse: @winner)}
+
+
+ {gettext("Os pagamentos seriam calculados com base nas apostas feitas.")}
+
+
ππ
+
+
+ <% end %>
+
+
+
+ """
+ end
+
+ def mount(socket) do
+ number_of_horses = Minigames.get_horse_race_number_of_horses()
+ duration_minutes = Minigames.get_horse_race_duration()
+ total_race_time = duration_minutes * 60
+
+ horse_speeds = create_horse_speeds(number_of_horses)
+
+ {:ok,
+ socket
+ |> assign(
+ is_active: Minigames.horse_race_active?(),
+ multiplier: Minigames.get_horse_race_multiplier(),
+ duration_minutes: duration_minutes,
+ entry_fee: Minigames.get_horse_race_entry_fee(),
+ number_of_horses: number_of_horses,
+ house_fee: Minigames.get_horse_race_house_fee(),
+ horses: List.duplicate(0, number_of_horses),
+ horse_speeds: horse_speeds,
+ racing: false,
+ winner: nil,
+ time_remaining: total_race_time,
+ time_elapsed: 0,
+ total_race_time: total_race_time,
+ race_start_time: nil
+ )}
+ end
+
+ def handle_event("start_race", params, socket) do
+ number_of_horses = socket.assigns.number_of_horses
+ horse_speeds = create_horse_speeds(number_of_horses)
+ duration = String.to_integer(params["duration"] || "#{socket.assigns.total_race_time}")
+
+ socket =
+ socket
+ |> assign(
+ racing: true,
+ winner: nil,
+ horses: List.duplicate(0, number_of_horses),
+ horse_speeds: horse_speeds,
+ time_remaining: socket.assigns.total_race_time,
+ time_elapsed: 0,
+ race_start_time: System.monotonic_time(:millisecond)
+ )
+ |> push_event("start_race", %{duration: duration})
+
+ {:noreply, socket}
+ end
+
+ def handle_event("stop_race", _params, socket) do
+ socket =
+ socket
+ |> assign(racing: false)
+ |> push_event("stop_race", %{})
+
+ {:noreply, socket}
+ end
+
+ def handle_event("reset_race", _params, socket) do
+ number_of_horses = socket.assigns.number_of_horses
+
+ socket =
+ socket
+ |> assign(
+ horses: List.duplicate(0, number_of_horses),
+ winner: nil,
+ time_remaining: socket.assigns.total_race_time,
+ time_elapsed: 0,
+ racing: false
+ )
+ |> push_event("reset_race", %{})
+
+ {:noreply, socket}
+ end
+
+ defp create_horse_speeds(count) do
+ for _i <- 1..count do
+ base_speed = 0.8 + :rand.uniform() * 0.4
+
+ variation = 0.1 + :rand.uniform() * 0.2
+
+ {base_speed, variation}
+ end
+ end
+
+ defp update_horse_positions(positions, horse_speeds) do
+ positions
+ |> Enum.with_index()
+ |> Enum.map(fn {position, idx} ->
+ {base_speed, variation} = Enum.at(horse_speeds, idx)
+
+ speed_modifier =
+ base_speed + if Enum.random([0, 1]) == 0, do: variation, else: -variation / 2
+
+ increment = speed_modifier * (2 + Enum.random([0, 1, 2]))
+
+ min(position + increment, 100)
+ end)
+ end
+
+ defp find_winner(horses) do
+ horses
+ |> Enum.with_index()
+ |> Enum.max_by(fn {position, _idx} -> position end)
+ |> elem(1)
+ |> (&(&1 + 1)).()
+ end
+
+ defp format_time(seconds) do
+ minutes = div(seconds, 60)
+ secs = rem(seconds, 60)
+ minutes_str = String.pad_leading(Integer.to_string(minutes), 2, "0")
+ secs_str = String.pad_leading(Integer.to_string(secs), 2, "0")
+ "#{minutes_str}:#{secs_str}"
+ end
+
+ def handle_update(%{update: "update_race", params: params}, socket) do
+ if socket.assigns.racing do
+ elapsed = String.to_integer(params["elapsed"])
+ time_remaining = max(0, socket.assigns.total_race_time - elapsed)
+
+ if elapsed >= socket.assigns.total_race_time do
+ horses = Enum.map(socket.assigns.horses, &min(&1, 100))
+ winner = if Enum.any?(horses, &(&1 >= 100)), do: find_winner(horses), else: nil
+
+ {:ok,
+ assign(socket,
+ horses: horses,
+ racing: false,
+ winner: winner,
+ time_remaining: 0,
+ time_elapsed: socket.assigns.total_race_time
+ )}
+ else
+ new_horses =
+ update_horse_positions(socket.assigns.horses, socket.assigns.horse_speeds)
+
+ winner =
+ if Enum.any?(new_horses, &(&1 >= 100)), do: find_winner(new_horses), else: nil
+
+ socket =
+ assign(socket,
+ horses: new_horses,
+ time_remaining: time_remaining,
+ time_elapsed: elapsed,
+ racing: is_nil(winner)
+ )
+
+ {:ok, socket}
+ end
+ else
+ {:ok, socket}
+ end
+ end
+
+ def handle_update(assigns, socket) do
+ {:ok, assign(socket, assigns)}
+ end
+end
diff --git a/lib/pearl_web/live/backoffice/minigames_live/index.ex b/lib/pearl_web/live/backoffice/minigames_live/index.ex
index 5f468a3..3aaef95 100644
--- a/lib/pearl_web/live/backoffice/minigames_live/index.ex
+++ b/lib/pearl_web/live/backoffice/minigames_live/index.ex
@@ -10,13 +10,30 @@ defmodule PearlWeb.Backoffice.MinigamesLive.Index do
edit_slots_reel_icons_icons: %{"minigames" => ["edit"]},
edit_slots_paytable: %{"minigames" => ["edit"]},
edit_slots_payline: %{"minigames" => ["edit"]},
- edit_coin_flip: %{"minigames" => ["edit"]}}
+ edit_coin_flip: %{"minigames" => ["edit"]},
+ edit_horse_race: %{"minigames" => ["edit"]},
+ horse_race: %{"minigames" => ["edit"]}}
def mount(_params, _session, socket) do
{:ok, socket |> assign(:current_page, :minigames)}
end
- def handle_params(_, _params, socket) do
+ def handle_params(_params, _uri, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("update_race", params, socket) do
+ send_update(
+ PearlWeb.Backoffice.MinigamesLive.HorseRace.Index,
+ id: "horse-race-game",
+ update: "update_race",
+ params: params
+ )
+
+ {:noreply, socket}
+ end
+
+ def handle_event(_event, _params, socket) do
{:noreply, socket}
end
end
diff --git a/lib/pearl_web/live/backoffice/minigames_live/index.html.heex b/lib/pearl_web/live/backoffice/minigames_live/index.html.heex
index 8e60251..246e691 100644
--- a/lib/pearl_web/live/backoffice/minigames_live/index.html.heex
+++ b/lib/pearl_web/live/backoffice/minigames_live/index.html.heex
@@ -29,6 +29,16 @@
+
+ <.ensure_permissions user={@current_user} permissions={%{"minigames" => ["edit"]}}>
+ <.link
+ patch={~p"/dashboard/minigames/horse_race"}
+ class="flex flex-col items-center w-full text-2xl gap-4 font-semibold py-8 rounded-2xl dark:bg-darkShade/10 dark:hover:bg-darkShade/20 bg-lightShade/30 hover:bg-lightShade/40 transition-colors"
+ >
+ {gettext("Horse Race")}
+
+
+
@@ -156,3 +166,32 @@
patch={~p"/dashboard/minigames/slots"}
/>
+
+<.modal
+ :if={@live_action in [:edit_horse_race]}
+ id="horse-race-config-modal"
+ wrapper_class="p-3"
+ show
+ on_cancel={JS.patch(~p"/dashboard/minigames/")}
+>
+ <.live_component
+ id="horse-race-configurator"
+ module={PearlWeb.Backoffice.MinigamesLive.HorseRace.FormComponent}
+ patch={~p"/dashboard/minigames/"}
+ />
+
+
+<.modal
+ :if={@live_action in [:horse_race]}
+ id="horse-race-game-modal"
+ wrapper_class="p-3"
+ show
+ on_cancel={JS.patch(~p"/dashboard/minigames/horse_race")}
+>
+
+ <.live_component
+ id="horse-race-game"
+ module={PearlWeb.Backoffice.MinigamesLive.HorseRace.Index}
+ />
+
+
diff --git a/lib/pearl_web/router.ex b/lib/pearl_web/router.ex
index 7c82390..23f389a 100644
--- a/lib/pearl_web/router.ex
+++ b/lib/pearl_web/router.ex
@@ -335,6 +335,11 @@ defmodule PearlWeb.Router do
live "/", MinigamesLive.Index, :index
live "/coin_flip", MinigamesLive.Index, :edit_coin_flip
+
+ scope "/horse_race" do
+ live "/", MinigamesLive.Index, :edit_horse_race
+ live "/simulation", MinigamesLive.Index, :horse_race
+ end
end
scope "/scanner", ScannerLive do
diff --git a/priv/static/images/icons/horse_race.svg b/priv/static/images/icons/horse_race.svg
new file mode 100644
index 0000000..83ff278
--- /dev/null
+++ b/priv/static/images/icons/horse_race.svg
@@ -0,0 +1,64 @@
+
+