Files
Bubberstation/code/modules/basketball/controller.dm
SkyratBot e9e7d7a461 [MIRROR] Minigame DLC - Intergalactic Basketball League [MDB IGNORE] (#20048)
* Minigame DLC - Intergalactic Basketball League (#72459)

## About The Pull Request
New DLC bout to drop.

![dreamseeker_45s0qiPMXE](https://user-images.githubusercontent.com/5195984/210466427-35b90d46-6620-45e2-8b21-66d1dcada76f.png)

Lots of new things included:
- New basketball minigame that can be played between 2-7 players
- Crafting recipe for basketballs using leather sheets
- Crafting recipe for basketball hoops using metal, rods, and durathread
- New basketball sounds for the ball and hoops
- New scorecard that can be reset using CtrlClick
- Basketball hoops can be rotated using a wrench and AltClick
- Dunking and shooting animations.

### New basketball mechanics that now utilize stamina:

- Dunking costs large stamina and you must be directly adjacent to the
hoop and click on it.
- Shooting costs medium stamina and uses RMB. Shooting lets you aim the
ball over peoples heads, meaning anyone obstructing your path will be
bypassed. There is a half second delay during shooting where someone can
bump or push to prevent the shot from succeeding.
- Shooting from further away results in less accuracy. If you do not
click directly on the hoop, there is also an accuracy penalty!
- Passing costs no stamina and uses LMB. Trying to score into the hoop
via passing results in a reduced chance.
- Spinning costs medium stamina while holding the ball. It gives a
reduced chance for the ball to be stolen but decreases accuracy for
shooting.
- Pushing a player using RMB will attempt to steal the ball and drain
their stamina.
- The chance to steal the ball is based on the stamina of both players
and the direction they are facing. If the person with the ball is at low
stamina, and the person stealing is at full stamina, they will have a
higher chance. Likewise, if the person with the ball is face to face
with the stealer, then there is a higher chance for the ball to be
stolen. If the person has their back to the stealer, then it's a lower
chance.
- Shooting from more than 2 tiles away, results in 3 points. See below
picture to know the distance.

![dreamseeker_1iFLhQGx01](https://user-images.githubusercontent.com/5195984/210469319-162b9745-fcae-4261-92ef-228388eb4f6f.png)

### Now to introduce the teams:

<details>
<summary>Nanotrasen Basketball Department</summary>

![dreamseeker_baSqp2nipv](https://user-images.githubusercontent.com/5195984/210469887-9e0a92d5-d4bd-4da8-9e73-b11d91fdfcd8.png)

</details>

<details>
<summary>Greytide Worldwide</summary>

![dreamseeker_quzZ3KnwpX](https://user-images.githubusercontent.com/5195984/210469923-ed774656-f5cc-43bc-8314-f8309a01c474.png)

</details>

<details>
<summary>Lusty Xenomorphs</summary>

![dreamseeker_VDeT3JQkNF](https://user-images.githubusercontent.com/5195984/210469944-a229e0cc-4b2e-4754-a0b4-6b36953dca2e.png)

</details>

<details>
<summary>Space Surfers</summary>

![dreamseeker_Dh91fznQbN](https://user-images.githubusercontent.com/5195984/210469963-9a85b4e3-b69d-4b66-8c96-4e2ff2b3b983.png)

</details>

---

Big shoutout to the nukie round a few weeks ago where the nuke ops
challenged the crew (and clown) to a basketball match on their rebuilt
basketball shuttle. The nukies won, but it made me realize that the
basketball mechanics were very raw and needed some polishing.

#### TODO LIST

- [x] Fix bug where ball only goes over peoples heads if they are 1 tile
away
- [x] Remove leftover code comments and procs
- [x] Rebalance stamina values (maybe move this to different ball types)
- [x] Fix basketball stadium template runtiming from wall smoothing
during load
- [x] Fix space surfer stadium having an air breach somewhere
- [x] Add more sounds for when ball is passed, shot, or dunked
- [x] Make it so that holding a ball while on the floor isn't possible
(to avoid those meta cheese strats)
- [x] Drop basketball lets mobs make sounds when spinning (need to
detach signal?)
- [x] Finish adding a simple lobby menu for minigame

## Why It's Good For The Game
_If you can't slam with the best, then jam with the rest._

## Changelog
🆑
add: Add crafting recipe for basketballs (leather sheets) and basketball
hoops (metal, rods, and durathread)
add: Add new basketball minigame for 2-7 players. There are 4 different
courts and teams by default with more planned to be added later.
add: New basketball mechanics that uses stamina. Shoot with RMB, pass
with LMB, and dunk by clicking the hoop while adjacent. Spinning while
holding the ball decreases the chance for someone to steal the ball, but
it decreases your shooting accuracy. Shooting from 2 tiles away lets you
score 3 points.
qol: Basketballs now play a buzzer sound when someone scores. CtrlClick
will reset the scorecard and AltClick with a wrench will rotate the
hoop.
qol: Dunking and shooting animations for basketball.
soundadd: Added basketball bounce sound with credits attribution
imageadd: Added basketball icon to minigames. Move baseball and
dodgeball icons to toy/balls.dmi
/🆑

* Minigame DLC - Intergalactic Basketball League

* Update CentCom_skyrat_z2.dmm

* raptor

---------

Co-authored-by: Tim <timothymtorres@gmail.com>
Co-authored-by: lessthnthree <three@lessthanthree.dk>
Co-authored-by: Paxilmaniac <paxilmaniac@gmail.com>
2023-03-27 02:24:29 +01:00

409 lines
17 KiB
Plaintext

///how many people can play basketball without issues (running out of spawns, procs not expecting more than this amount of people, etc)
#define BASKETBALL_MIN_PLAYER_COUNT 2
#define BASKETBALL_MAX_PLAYER_COUNT 7
#define BASKETBALL_TEAM_HOME "home"
#define BASKETBALL_TEAM_AWAY "away"
/// list of ghosts who want to play basketball, every time someone enters the list it checks to see if enough are in
GLOBAL_LIST_EMPTY(basketball_signup)
/// list of ghosts who want to play basketball that have since disconnected. They are kept in the lobby, but not counted for starting a game.
GLOBAL_LIST_EMPTY(basketball_bad_signup)
/// the current global basketball game running.
GLOBAL_VAR(basketball_game)
/**
* The basketball controller handles the basketball minigame in progress.
* It is first created when the first ghost signs up to play.
*/
/datum/basketball_controller
/// Template picked when the game starts. used for the name and desc reading
var/datum/map_template/basketball/current_map
/// Map generation tool that deletes the current map after the game finishes
var/datum/map_generator/massdelete/map_deleter
/// Total amount of time basketball is played for
var/game_duration = 3 MINUTES
/// List of all players ckeys involved in the minigame
var/list/minigame_players = list()
/// Spawn points for home team players
var/list/home_team_landmarks = list()
/// List of home team players ckeys
var/list/home_team_players = list()
/// The basketball hoop used by home team
var/obj/structure/hoop/minigame/home_hoop
/// Spawn points for away team players
var/list/away_team_landmarks = list()
/// List of away team players ckeys
var/list/away_team_players = list()
/// The basketball hoop used by away team
var/obj/structure/hoop/minigame/away_hoop
/// Spawn point for referee (there should only be one spot on minigame map)
var/list/referee_landmark = list()
/datum/basketball_controller/New()
. = ..()
GLOB.basketball_game = src
map_deleter = new
/datum/basketball_controller/Destroy(force, ...)
. = ..()
GLOB.basketball_game = null
end_game()
qdel(map_deleter)
/**
* Triggers at beginning of the game when there is a confirmed list of valid, ready players.
* Creates a 100% ready game that has NOT started (no players in bodies)
* Followed by start game
*
* Does the following:
* * Picks map, and loads it
* * Grabs landmarks if it is the first time it's loading
* * Puts players in each team randomly
* Arguments:
* * ready_players: list of filtered, sane players (so not playing or disconnected) for the game to put into roles
*/
/datum/basketball_controller/proc/prepare_game(ready_players)
var/list/possible_maps = subtypesof(/datum/map_template/basketball)
var/turf/spawn_area = get_turf(locate(/obj/effect/landmark/basketball/game_area) in GLOB.landmarks_list)
current_map = pick(possible_maps)
current_map = new current_map
if(!spawn_area)
CRASH("No spawn area detected for Basketball Minigame!")
var/list/bounds = current_map.load(spawn_area)
if(!bounds)
CRASH("Loading basketball map failed!")
map_deleter.defineRegion(spawn_area, locate(spawn_area.x + 23, spawn_area.y + 25, spawn_area.z), replace = TRUE) //so we're ready to mass delete when round ends
var/turf/home_hoop_turf = get_turf(locate(/obj/effect/landmark/basketball/team_spawn/home_hoop) in GLOB.landmarks_list)
if(!home_hoop_turf)
CRASH("No home landmark for basketball hoop detected!")
home_hoop = (locate(/obj/structure/hoop/minigame) in home_hoop_turf)
if(!home_hoop)
CRASH("No minigame basketball hoop detected for home team!")
if(!home_team_landmarks.len)
for(var/obj/effect/landmark/basketball/team_spawn/home/possible_spawn in GLOB.landmarks_list)
home_team_landmarks += possible_spawn
var/turf/away_hoop_turf = get_turf(locate(/obj/effect/landmark/basketball/team_spawn/away_hoop) in GLOB.landmarks_list)
if(!away_hoop_turf)
CRASH("No away landmark for basketball hoop detected!")
away_hoop = (locate(/obj/structure/hoop/minigame) in away_hoop_turf)
if(!away_hoop)
CRASH("No minigame basketball hoop detected for away team!")
if(!away_team_landmarks.len)
for(var/obj/effect/landmark/basketball/team_spawn/away/possible_spawn in GLOB.landmarks_list)
away_team_landmarks += possible_spawn
for(var/obj/effect/landmark/basketball/team_spawn/referee/possible_spawn in GLOB.landmarks_list)
referee_landmark += possible_spawn
start_game(ready_players)
/**
* The game by this point is now all set up, and so we can put people in their bodies.
*/
/datum/basketball_controller/proc/start_game(ready_players)
message_admins("The players have spoken! Voting has enabled the basketball minigame!")
notify_ghosts(
"Basketball minigame is about to start!",
source = home_hoop,
header = "Basketball Minigame",
ghost_sound = 'sound/effects/ghost2.ogg',
notify_volume = 75,
action = NOTIFY_ORBIT,
)
create_bodies(ready_players)
addtimer(CALLBACK(src, PROC_REF(victory)), game_duration)
for(var/i in 1 to 10)
addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(playsound), home_hoop, 'sound/items/timer.ogg', 75, FALSE), game_duration - (i SECONDS))
addtimer(CALLBACK(home_hoop, TYPE_PROC_REF(/atom/movable/, say), "[i] seconds left"), game_duration - (i SECONDS))
addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(playsound), away_hoop, 'sound/items/timer.ogg', 75, FALSE), game_duration - (i SECONDS))
addtimer(CALLBACK(away_hoop, TYPE_PROC_REF(/atom/movable/, say), "[i] seconds left"), game_duration - (i SECONDS))
/**
* Called when the game is setting up, AFTER map is loaded but BEFORE the game start. Creates and places each body and gives the correct player key
*/
/datum/basketball_controller/proc/create_bodies(ready_players)
var/list/possible_away_teams = subtypesof(/datum/map_template/basketball) - current_map.type
var/datum/map_template/basketball/away_map = pick(possible_away_teams)
away_map = new away_map
var/list/home_spawnpoints = home_team_landmarks.Copy()
var/list/away_spawnpoints = away_team_landmarks.Copy()
var/list/referee_spawnpoint = referee_landmark.Copy()
var/obj/effect/landmark/basketball/team_spawn/spawn_landmark
var/team_uniform
var/team_name
// rename the hoops to their appropriate teams names
home_hoop.name = current_map.team_name
away_hoop.name = away_map.team_name
var/player_count = 0
// if total players is odd number then the odd man out is a referee
var/minigame_has_referee = length(ready_players) % 2
for(var/player_key in ready_players)
player_count++
minigame_players |= player_key
var/is_player_referee = (player_count == length(ready_players) && minigame_has_referee)
if(is_player_referee)
spawn_landmark = pick_n_take(referee_spawnpoint)
team_uniform = /datum/outfit/basketball/referee
else if(player_count % 2) // odd is home team
spawn_landmark = pick_n_take(home_spawnpoints)
home_team_players |= player_key
away_hoop.team_ckeys |= player_key // to restrict scoring on opponents hoop rapidly
team_uniform = current_map.home_team_uniform
team_name = current_map.team_name
else // even is away team
spawn_landmark = pick_n_take(away_spawnpoints)
away_team_players |= player_key
home_hoop.team_ckeys |= player_key // to restrict scoring on opponents hoop rapidly
team_uniform = away_map.home_team_uniform
team_name = away_map.team_name
var/mob/living/carbon/human/baller = new(get_turf(spawn_landmark))
if(baller.dna.species.outfit_important_for_life)
baller.set_species(/datum/species/human)
ADD_TRAIT(baller, TRAIT_NOFIRE, BASKETBALL_MINIGAME_TRAIT)
ADD_TRAIT(baller, TRAIT_NOBREATH, BASKETBALL_MINIGAME_TRAIT)
ADD_TRAIT(baller, TRAIT_CANNOT_CRYSTALIZE, BASKETBALL_MINIGAME_TRAIT)
// this is basketball, not a boxing match
ADD_TRAIT(baller, TRAIT_PACIFISM, BASKETBALL_MINIGAME_TRAIT)
baller.equipOutfit(team_uniform)
var/client/player_client = GLOB.directory[player_key]
if(player_client)
player_client.prefs.safe_transfer_prefs_to(baller, is_antag = TRUE)
baller.key = player_key
SEND_SOUND(baller, sound('sound/misc/whistle.ogg', volume=30))
if(is_player_referee)
to_chat(baller, span_notice("You are a referee. Make sure the teams play fair and use your whistle to call fouls appropriately."))
else
to_chat(baller, span_notice("You are a basketball player for the [team_name]. Score as much as you can before time runs out."))
to_chat(baller, span_info("LMB to pass the ball while on help intent (zero stamina cost/) - accuracy penalty when scoring)"))
to_chat(baller, span_info("RMB to shoot the ball ([STAMINA_COST_SHOOTING] stamina cost) - this goes over players heads"))
to_chat(baller, span_info("Click directly on hoop while adjacent to dunk ([STAMINA_COST_DUNKING] stamina cost)"))
to_chat(baller, span_info("Spinning decreases other players disarm chance against you but reduces shooting accuracy ([STAMINA_COST_SPINNING] stamina cost)"))
/**
* Called after the game is finished. Sends end game notifications to teams and dusts the losers.
*/
/datum/basketball_controller/proc/victory()
var/is_game_draw
var/list/winner_team_ckeys = list()
var/list/loser_team_ckeys = list()
var/winner_team_name
if(home_hoop.total_score == away_hoop.total_score)
is_game_draw = TRUE
winner_team_ckeys |= home_team_players
winner_team_ckeys |= away_team_players
else if(home_hoop.total_score > away_hoop.total_score)
winner_team_ckeys = away_team_players
winner_team_name = away_hoop.name
loser_team_ckeys = home_team_players
else if(home_hoop.total_score < away_hoop.total_score)
winner_team_ckeys = home_team_players
winner_team_name = home_hoop.name
loser_team_ckeys = away_team_players
if(is_game_draw)
for(var/ckey in winner_team_ckeys)
var/mob/living/competitor = get_mob_by_ckey(ckey)
var/area/mob_area = get_area(competitor)
if(istype(competitor) && istype(mob_area, /area/centcom/basketball))
to_chat(competitor, span_hypnophrase("The game resulted in a draw!"))
else
for(var/ckey in winner_team_ckeys)
var/mob/living/competitor = get_mob_by_ckey(ckey)
var/area/mob_area = get_area(competitor)
if(istype(competitor) && istype(mob_area, /area/centcom/basketball))
to_chat(competitor, span_hypnophrase("[winner_team_name] team wins!"))
for(var/ckey in loser_team_ckeys)
var/mob/living/competitor = get_mob_by_ckey(ckey)
var/area/mob_area = get_area(competitor)
if(istype(competitor) && istype(mob_area, /area/centcom/basketball))
to_chat(competitor, span_hypnophrase("[winner_team_name] team wins!"))
competitor.dust()
addtimer(CALLBACK(src, PROC_REF(end_game)), 20 SECONDS) // give winners time for a victory lap
/**
* Cleans up the game, resetting variables back to the beginning and removing the map with the generator.
*/
/datum/basketball_controller/proc/end_game()
for(var/ckey in minigame_players)
var/mob/living/competitor = get_mob_by_ckey(ckey)
var/area/mob_area = get_area(competitor)
if(istype(competitor) && istype(mob_area, /area/centcom/basketball))
QDEL_NULL(competitor)
map_deleter.generate() //remove the map, it will be loaded at the start of the next one
QDEL_NULL(current_map)
//map gen does not deal with landmarks
QDEL_LIST(home_team_landmarks)
QDEL_LIST(away_team_landmarks)
QDEL_LIST(referee_landmark)
/**
* Called when enough players have signed up to fill a setup. DOESN'T NECESSARILY MEAN THE GAME WILL START.
*
* Checks for a custom setup, if so gets the required players from that and if not it sets the player requirement to BASKETBALL_MAX_PLAYER_COUNT and generates one IF basic setup starts a game.
* Checks if everyone signed up is an observer, and is still connected. If people aren't, they're removed from the list.
* If there aren't enough players post sanity, it aborts. otherwise, it selects enough people for the game and starts preparing the game for real.
*/
/datum/basketball_controller/proc/basic_setup()
//final list for all the players who will be in this game
var/list/filtered_keys = list()
//cuts invalid players from signups (disconnected/not a ghost)
var/list/possible_keys = list()
for(var/key in GLOB.basketball_signup)
if(GLOB.directory[key])
var/client/C = GLOB.directory[key]
if(isobserver(C.mob))
possible_keys += key
continue
GLOB.basketball_signup -= key //not valid to play when we checked so remove them from signups
//if there were not enough players, don't start. we already trimmed the list to now hold only valid signups
if(length(possible_keys) < BASKETBALL_MIN_PLAYER_COUNT)
return
var/req_players = length(possible_keys) >= BASKETBALL_MAX_PLAYER_COUNT ? BASKETBALL_MAX_PLAYER_COUNT : length(possible_keys)
//if there were too many players, still start but only make filtered keys as big as it needs to be (cut excess)
//also removes people who do get into final player list from the signup so they have to sign up again when game ends
for(var/i in 1 to req_players)
var/chosen_key = pick_n_take(possible_keys)
filtered_keys += chosen_key
GLOB.basketball_signup -= chosen_key
//small message about not getting into this game for clarity on why they didn't get in
for(var/unpicked in possible_keys)
var/client/unpicked_client = GLOB.directory[unpicked]
to_chat(unpicked_client, span_danger("Sorry, the starting basketball game has too many players and you were not picked."))
to_chat(unpicked_client, span_warning("You're still signed up, getting messages from the current round, and have another chance to join when the one starting now finishes."))
prepare_game(filtered_keys)
/**
* Filters inactive player into a different list until they reconnect, and removes players who are no longer ghosts.
*/
/datum/basketball_controller/proc/check_signups()
for(var/bad_key in GLOB.basketball_bad_signup)
if(GLOB.directory[bad_key]) //they have reconnected if we can search their key and get a client
GLOB.basketball_bad_signup -= bad_key
GLOB.basketball_signup += bad_key
for(var/key in GLOB.basketball_signup)
var/client/signup_client = GLOB.directory[key]
if(!signup_client) //vice versa but in a variable we use later
GLOB.basketball_signup -= key
GLOB.basketball_bad_signup += key
continue
if(!isobserver(signup_client.mob))
//they are back to playing the game, remove them from the signups
GLOB.basketball_signup -= key
/**
* Called when someone signs up, and sees if there are enough people in the signup list to begin.
*
* Only checks if everyone is actually valid to start (still connected and an observer) if there are enough players (basic_setup)
*/
/datum/basketball_controller/proc/try_autostart()
if(!(GLOB.ghost_role_flags & GHOSTROLE_MINIGAME))
return
if(GLOB.basketball_signup.len >= BASKETBALL_MIN_PLAYER_COUNT) //enough people to try and make something (or debug mode)
basic_setup()
/datum/basketball_controller/ui_state(mob/user)
return GLOB.always_state
/datum/basketball_controller/ui_interact(mob/user, datum/tgui/ui)
ui = SStgui.try_update_ui(user, src, ui)
if(!ui)
ui = new(user, src, "BasketballPanel")
ui.set_autoupdate(FALSE)
ui.open()
/datum/basketball_controller/ui_data(mob/user)
. = ..()
.["total_votes"] = GLOB.basketball_signup.len
.["players_min"] = BASKETBALL_MIN_PLAYER_COUNT
.["players_max"] = BASKETBALL_MAX_PLAYER_COUNT
var/list/lobby_data = list()
for(var/key in GLOB.basketball_signup + GLOB.basketball_bad_signup)
var/list/lobby_member = list()
lobby_member["ckey"] = key
lobby_member["status"] = (key in GLOB.basketball_bad_signup) ? "Disconnected" : "Ready"
lobby_data += list(lobby_member)
.["lobbydata"] = lobby_data
/datum/basketball_controller/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
. = ..()
if(.)
return
var/mob/dead/observer/user = ui.user
if(!istype(user)) // only ghosts
return
var/client/ghost_client = user.client
if(!SSticker.HasRoundStarted())
to_chat(ghost_client, span_warning("Wait for the round to start."))
return
switch(action)
if("basketball_signup")
if(GLOB.basketball_signup[ghost_client.ckey])
GLOB.basketball_signup -= ghost_client.ckey // double check this works?
to_chat(ghost_client, span_notice("You unregister from basketball."))
else
GLOB.basketball_signup[ghost_client.ckey] = ghost_client
to_chat(ghost_client, span_notice("You sign up for basketball."))
check_signups()
return TRUE
if("basketball_start")
if(!GLOB.basketball_signup[ghost_client.ckey])
to_chat(ghost_client, span_notice("You must sign up to start the game."))
return
if(current_map)
to_chat(ghost_client, span_notice("Wait for current basketball game to finish."))
return
try_autostart()
return TRUE
/**
* Creates the global datum for playing basketball games, destroys the last if that's required and returns the new.
*/
/proc/create_basketball_game()
if(GLOB.basketball_game)
QDEL_NULL(GLOB.basketball_game)
var/datum/basketball_controller/basketball_minigame = new()
return basketball_minigame