From 0215cce64e404592e09b53a131871953d87d7976 Mon Sep 17 00:00:00 2001 From: AfonsoMartins26 Date: Mon, 24 Nov 2025 21:16:10 +0000 Subject: [PATCH 1/3] feat: add horse race backoffice --- assets/css/components.css | 1 + assets/css/components/horse_race.css | 170 ++++++++ assets/js/app.js | 3 +- assets/js/hooks/horse_race.js | 406 ++++++++++++++++++ assets/js/hooks/index.js | 1 + lib/pearl/minigames.ex | 85 ++++ .../horse_race_live/form_component.ex | 190 ++++++++ .../minigames_live/horse_race_live/index.ex | 318 ++++++++++++++ .../live/backoffice/minigames_live/index.ex | 21 +- .../backoffice/minigames_live/index.html.heex | 39 ++ lib/pearl_web/router.ex | 5 + priv/static/images/icons/horse_race.svg | 64 +++ 12 files changed, 1300 insertions(+), 3 deletions(-) create mode 100644 assets/css/components/horse_race.css create mode 100644 assets/js/hooks/horse_race.js create mode 100644 lib/pearl_web/live/backoffice/minigames_live/horse_race_live/form_component.ex create mode 100644 lib/pearl_web/live/backoffice/minigames_live/horse_race_live/index.ex create mode 100644 priv/static/images/icons/horse_race.svg 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..fc1e41a --- /dev/null +++ b/assets/css/components/horse_race.css @@ -0,0 +1,170 @@ +@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; + } +} \ 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..51223f3 100644 --- a/lib/pearl/minigames.ex +++ b/lib/pearl/minigames.ex @@ -1702,4 +1702,89 @@ defmodule Pearl.Minigames do end end) end + + @horse_race_multiplier_key "horse_race_multiplier" + @horse_race_duration_key "horse_race_duration" + @horse_race_entry_fee_key "horse_race_entry_fee" + @horse_race_number_of_horses_key "horse_race_number_of_horses" + @horse_race_house_fee_key "horse_race_house_fee" + @horse_race_active_key "horse_race_active" + + def get_horse_race_multiplier do + case Constants.get(@horse_race_multiplier_key) do + {:ok, multiplier} -> multiplier + {:error, _} -> + change_horse_race_multiplier(2.0) + 2.0 + end + end + + def change_horse_race_multiplier(multiplier) when is_number(multiplier) do + Constants.set(@horse_race_multiplier_key, multiplier) + end + + def get_horse_race_duration do + case Constants.get(@horse_race_duration_key) do + {:ok, duration} -> duration + {:error, _} -> + change_horse_race_duration(2) + 2 + end + end + + def change_horse_race_duration(minutes) when is_integer(minutes) do + Constants.set(@horse_race_duration_key, minutes) + end + + def get_horse_race_entry_fee do + case Constants.get(@horse_race_entry_fee_key) do + {:ok, fee} -> fee + {:error, _} -> + change_horse_race_entry_fee(100) + 100 + end + end + + def change_horse_race_entry_fee(fee) when is_integer(fee) do + Constants.set(@horse_race_entry_fee_key, fee) + end + + def get_horse_race_number_of_horses do + case Constants.get(@horse_race_number_of_horses_key) do + {:ok, count} -> count + {:error, _} -> + change_horse_race_number_of_horses(5) + 5 + end + end + + 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_key, count) + end + + def get_horse_race_house_fee do + case Constants.get(@horse_race_house_fee_key) do + {:ok, fee} -> fee + {:error, _} -> + change_horse_race_house_fee(5.0) + 5.0 + end + end + + def change_horse_race_house_fee(fee) when is_number(fee) do + Constants.set(@horse_race_house_fee_key, fee) + end + + def horse_race_active? do + case Constants.get(@horse_race_active_key) do + {:ok, active} -> active + {:error, _} -> + change_horse_race_active(false) + false + end + end + + def change_horse_race_active(active?) when is_boolean(active?) do + Constants.set(@horse_race_active_key, 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..8da6247 --- /dev/null +++ b/lib/pearl_web/live/backoffice/minigames_live/horse_race_live/index.ex @@ -0,0 +1,318 @@ +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 %> +
+
+
🐴
+
#{index + 1}
+
+ +
+
+
+
+
+
+
+
+
+ +
+ <%= 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 %> +
+
+

Nota ver se mudo isto do style para o hook

+ + +
+ """ + 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..6ae4eb9 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/horse_race")} +> + <.live_component + id="horse-race-configurator" + module={PearlWeb.Backoffice.MinigamesLive.HorseRace.FormComponent} + patch={~p"/dashboard/minigames/horse_race"} + /> + + +<.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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d84f4dbbd154d5e3dca6f10508d14abc41a92401 Mon Sep 17 00:00:00 2001 From: AfonsoMartins26 Date: Mon, 24 Nov 2025 21:34:30 +0000 Subject: [PATCH 2/3] feat: pass the CI --- assets/css/components/horse_race.css | 13 +++++++++ lib/pearl/minigames.ex | 27 ++++++++++++++----- .../minigames_live/horse_race_live/index.ex | 15 ++--------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/assets/css/components/horse_race.css b/assets/css/components/horse_race.css index fc1e41a..d2a8999 100644 --- a/assets/css/components/horse_race.css +++ b/assets/css/components/horse_race.css @@ -167,4 +167,17 @@ 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/lib/pearl/minigames.ex b/lib/pearl/minigames.ex index 51223f3..62fde28 100644 --- a/lib/pearl/minigames.ex +++ b/lib/pearl/minigames.ex @@ -1712,7 +1712,9 @@ defmodule Pearl.Minigames do def get_horse_race_multiplier do case Constants.get(@horse_race_multiplier_key) do - {:ok, multiplier} -> multiplier + {:ok, multiplier} -> + multiplier + {:error, _} -> change_horse_race_multiplier(2.0) 2.0 @@ -1725,7 +1727,9 @@ defmodule Pearl.Minigames do def get_horse_race_duration do case Constants.get(@horse_race_duration_key) do - {:ok, duration} -> duration + {:ok, duration} -> + duration + {:error, _} -> change_horse_race_duration(2) 2 @@ -1738,7 +1742,9 @@ defmodule Pearl.Minigames do def get_horse_race_entry_fee do case Constants.get(@horse_race_entry_fee_key) do - {:ok, fee} -> fee + {:ok, fee} -> + fee + {:error, _} -> change_horse_race_entry_fee(100) 100 @@ -1751,20 +1757,25 @@ defmodule Pearl.Minigames do def get_horse_race_number_of_horses do case Constants.get(@horse_race_number_of_horses_key) do - {:ok, count} -> count + {:ok, count} -> + count + {:error, _} -> change_horse_race_number_of_horses(5) 5 end end - def change_horse_race_number_of_horses(count) when is_integer(count) and count >= 3 and count <= 8 do + 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_key, count) end def get_horse_race_house_fee do case Constants.get(@horse_race_house_fee_key) do - {:ok, fee} -> fee + {:ok, fee} -> + fee + {:error, _} -> change_horse_race_house_fee(5.0) 5.0 @@ -1777,7 +1788,9 @@ defmodule Pearl.Minigames do def horse_race_active? do case Constants.get(@horse_race_active_key) do - {:ok, active} -> active + {:ok, active} -> + active + {:error, _} -> change_horse_race_active(false) false 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 index 8da6247..68acdd0 100644 --- 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 @@ -84,7 +84,8 @@ defmodule PearlWeb.Backoffice.MinigamesLive.HorseRace.Index do <% end %> -
+
+
@@ -142,17 +143,6 @@ defmodule PearlWeb.Backoffice.MinigamesLive.HorseRace.Index do <% end %>
-

Nota ver se mudo isto do style para o hook

- - """ end @@ -273,7 +263,6 @@ defmodule PearlWeb.Backoffice.MinigamesLive.HorseRace.Index do 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) From 345dac17c81888aeab4a96a6e041dcd60c002e97 Mon Sep 17 00:00:00 2001 From: AfonsoMartins26 Date: Mon, 24 Nov 2025 22:58:02 +0000 Subject: [PATCH 3/3] feat: refactor constants --- lib/pearl/minigames.ex | 128 +++++++++++++++--- .../backoffice/minigames_live/index.html.heex | 4 +- 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/lib/pearl/minigames.ex b/lib/pearl/minigames.ex index 62fde28..8d90434 100644 --- a/lib/pearl/minigames.ex +++ b/lib/pearl/minigames.ex @@ -1703,15 +1703,16 @@ defmodule Pearl.Minigames do end) end - @horse_race_multiplier_key "horse_race_multiplier" - @horse_race_duration_key "horse_race_duration" - @horse_race_entry_fee_key "horse_race_entry_fee" - @horse_race_number_of_horses_key "horse_race_number_of_horses" - @horse_race_house_fee_key "horse_race_house_fee" - @horse_race_active_key "horse_race_active" + @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_key) do + case Constants.get("horse_race_multiplier") do {:ok, multiplier} -> multiplier @@ -1721,12 +1722,28 @@ defmodule Pearl.Minigames do 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_key, multiplier) + 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_key) do + case Constants.get("horse_race_duration") do {:ok, duration} -> duration @@ -1736,12 +1753,28 @@ defmodule Pearl.Minigames do 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_key, minutes) + 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_key) do + case Constants.get("horse_race_entry_fee") do {:ok, fee} -> fee @@ -1751,12 +1784,28 @@ defmodule Pearl.Minigames do 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_key, fee) + 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_key) do + case Constants.get("horse_race_number_of_horses") do {:ok, count} -> count @@ -1766,13 +1815,32 @@ defmodule Pearl.Minigames do 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_key, count) + 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_key) do + case Constants.get("horse_race_house_fee") do {:ok, fee} -> fee @@ -1782,12 +1850,28 @@ defmodule Pearl.Minigames do 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_key, fee) + 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_key) do + case Constants.get("horse_race_active") do {:ok, active} -> active @@ -1797,7 +1881,15 @@ defmodule Pearl.Minigames do 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_key, active?) + Constants.set("horse_race_active", active?) 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 6ae4eb9..246e691 100644 --- a/lib/pearl_web/live/backoffice/minigames_live/index.html.heex +++ b/lib/pearl_web/live/backoffice/minigames_live/index.html.heex @@ -172,12 +172,12 @@ id="horse-race-config-modal" wrapper_class="p-3" show - on_cancel={JS.patch(~p"/dashboard/minigames/horse_race")} + on_cancel={JS.patch(~p"/dashboard/minigames/")} > <.live_component id="horse-race-configurator" module={PearlWeb.Backoffice.MinigamesLive.HorseRace.FormComponent} - patch={~p"/dashboard/minigames/horse_race"} + patch={~p"/dashboard/minigames/"} />