From 49bbd2b871eb7ff0ed53bd602fa3dbda4eba4efe Mon Sep 17 00:00:00 2001 From: Dakroach Date: Thu, 11 Jun 2026 17:17:54 -0700 Subject: [PATCH] feat: Enhance minigame effects and UI interactions - Implemented LeaveMinigameQueueEffect to handle player removal from queues and runtime. - Added OpenMinigameQueueUIEffect to manage the opening of the minigame queue UI. - Introduced StartPlayerTimerEffect and StopPlayerTimerEffect for managing player timers and scoring. - Enhanced PlaceBlocksEffect with options for filling volumes and hollow placements. - Updated SpawnItemEffect to fix codec handling for item IDs. - Added MountPlayerEffect and DismountPlayerEffect for player interactions with NPCs. - Improved StartQueuedMinigameEffect to check player counts before starting games. - Added localization entries for new effects and UI elements. - Updated manifest to include HyUI as a dependency. --- au/ellie/hyui/builders/HyUIHud.class | Bin 0 -> 16704 bytes build.gradle | 2 + config.json | 24 + .../KingOfHill/01-overview.md | 51 ++ .../KingOfHill/02-minigame-definition.md | 60 ++ .../KingOfHill/03-join-and-start.md | 86 +++ .../KingOfHill/04-spawn-and-activation.md | 67 ++ .../KingOfHill/05-capture-zones.md | 147 ++++ .../KingOfHill/06-round-signals.md | 112 +++ .../KingOfHill/07-end-and-reset.md | 87 +++ .../KingOfHill/08-full-example-files.md | 135 ++++ .../template/00-Other Example Structures.md | 50 ++ .../template/00-trigger-volume-basics.md | 0 .../template/01-overview.md | 0 .../template/02-minigame-definition-basics.md | 0 .../03-player-queue-and-start-flow.md | 0 ...04-player-activation-and-inventory-flow.md | 0 .../05-maps-arenas-spawns-and-tags.md | 0 .../template/05.1-Futher Specifics.md | 0 .../06-teams-scoring-and-objectives.md | 0 .../template/06.1-Further Specifics.md | 0 ...timers-phases-rounds-and-win-conditions.md | 0 .../08-lives-respawns-and-status-effects.md | 0 .../09-ending-resetting-and-cleanup.md | 0 .../template/10-multimap-and-map-voting.md | 0 docs/events-conditions-effects.md | 3 + docs/examples.md | 15 +- docs/minigame-definition.md | 23 + docs/notes/todo.md | 23 + docs/tags.md | 62 ++ .../kewwbec/minigames/MinigameCorePlugin.java | 88 ++- .../adapter/HytaleServerAdapters.java | 18 + .../command/TriggerVolumeCloneCommand.java | 115 +++ .../TriggerVolumeCloneGroupCommand.java | 211 ++++++ .../net/kewwbec/minigames/model/Enums.java | 2 +- .../minigames/model/MinigameDefinition.java | 72 ++ .../DefaultMinigameRuntimeService.java | 171 ++++- .../runtime/MinigameRuntimeService.java | 1 + .../service/MinigameMapDiscoveryService.java | 4 + .../service/MinigameQueueService.java | 15 +- .../system/MinigamePregameSystem.java | 27 + .../system/MinigameQueueUISystem.java | 27 + .../system/TriggerVolumeDuplicateSystem.java | 216 ++++++ .../minigames/ui/MinigameHudService.java | 463 ++++++++++++ .../minigames/ui/MinigameHudSystem.java | 31 + .../minigames/ui/MinigameMenuOpenSystem.java | 26 + .../minigames/ui/MinigameMenuService.java | 209 ++++++ .../minigames/ui/MinigameQueueUIService.java | 680 ++++++++++++++++++ .../effect/LeaveMinigameQueueEffect.java | 23 +- .../volume/effect/MountPlayerEffect.java | 6 +- .../effect/OpenMinigameQueueUIEffect.java | 50 ++ .../volume/effect/PlaceBlocksEffect.java | 44 +- .../volume/effect/SpawnItemEffect.java | 3 +- .../volume/effect/StartPlayerTimerEffect.java | 45 ++ .../effect/StartQueuedMinigameEffect.java | 24 +- .../volume/effect/StopPlayerTimerEffect.java | 82 +++ .../Server/Languages/en-US/server.lang | 99 +++ src/main/resources/manifest.json | 2 +- 58 files changed, 3664 insertions(+), 37 deletions(-) create mode 100644 au/ellie/hyui/builders/HyUIHud.class create mode 100644 config.json create mode 100644 docs/FullTutorialExamples/KingOfHill/01-overview.md create mode 100644 docs/FullTutorialExamples/KingOfHill/02-minigame-definition.md create mode 100644 docs/FullTutorialExamples/KingOfHill/03-join-and-start.md create mode 100644 docs/FullTutorialExamples/KingOfHill/04-spawn-and-activation.md create mode 100644 docs/FullTutorialExamples/KingOfHill/05-capture-zones.md create mode 100644 docs/FullTutorialExamples/KingOfHill/06-round-signals.md create mode 100644 docs/FullTutorialExamples/KingOfHill/07-end-and-reset.md create mode 100644 docs/FullTutorialExamples/KingOfHill/08-full-example-files.md create mode 100644 docs/FullTutorialExamples/template/00-Other Example Structures.md create mode 100644 docs/FullTutorialExamples/template/00-trigger-volume-basics.md create mode 100644 docs/FullTutorialExamples/template/01-overview.md create mode 100644 docs/FullTutorialExamples/template/02-minigame-definition-basics.md create mode 100644 docs/FullTutorialExamples/template/03-player-queue-and-start-flow.md create mode 100644 docs/FullTutorialExamples/template/04-player-activation-and-inventory-flow.md create mode 100644 docs/FullTutorialExamples/template/05-maps-arenas-spawns-and-tags.md create mode 100644 docs/FullTutorialExamples/template/05.1-Futher Specifics.md create mode 100644 docs/FullTutorialExamples/template/06-teams-scoring-and-objectives.md create mode 100644 docs/FullTutorialExamples/template/06.1-Further Specifics.md create mode 100644 docs/FullTutorialExamples/template/07-timers-phases-rounds-and-win-conditions.md create mode 100644 docs/FullTutorialExamples/template/08-lives-respawns-and-status-effects.md create mode 100644 docs/FullTutorialExamples/template/09-ending-resetting-and-cleanup.md create mode 100644 docs/FullTutorialExamples/template/10-multimap-and-map-voting.md create mode 100644 docs/notes/todo.md create mode 100644 src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneGroupCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/system/MinigamePregameSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/system/MinigameQueueUISystem.java create mode 100644 src/main/java/net/kewwbec/minigames/system/TriggerVolumeDuplicateSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameHudService.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameHudSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameMenuOpenSystem.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameMenuService.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameQueueUIService.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/OpenMinigameQueueUIEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StartPlayerTimerEffect.java create mode 100644 src/main/java/net/kewwbec/minigames/volume/effect/StopPlayerTimerEffect.java diff --git a/au/ellie/hyui/builders/HyUIHud.class b/au/ellie/hyui/builders/HyUIHud.class new file mode 100644 index 0000000000000000000000000000000000000000..b7a7b1c03169c0184dc715146b5748f53e87d55f GIT binary patch literal 16704 zcmdU034B!5)jubh$xDV85)%ax!GK_sNy3NoJDGV&MkX_H79h4N z-Lz7xZMCgpwYy7O>rx0?7rRLJeQ)i)FLwXzrysWcpL^fDc{6!Kz%Jjf{513CzI)F- z`*!bq=5L2TOhg_0mjIPfxj{aY{8Yiz5lN1PMkmH&yR0}qGvT-uN?WNNRw@)prmT>a z$iy-e`XgqgL*w!A1m2BiqoMX}I+Gk5=<3WynJQMr60yu`rq0IhX+UNZF@R57p-l?b zW^1^u`^xZ+a3~&5jD-3!saRs9t!c1|Dyhn#fJrmRWSSq&hOBrzW&z7=EHspj#iM`{ z>YT7K@Fl`y7E|3+SO(2xYB>J-MN0!zO|`<6QrgL1hMUsg~HkE>2g!&?*Rx}&8 zqU*DnY|3hzL38O$gXWnupUz@hq=c#%P%fTDdm4)Ms-xkq8gPtu{XZ z$-ih7HB*a0ttKs@5Ys$Q3j1SY)&Puw$?WOs?(XXAXy4eou8-*~Z~Sxf!M~++o@RQhqG-d0X>hUmFpcz-&Rl5H3VJRSE|%_$ zMWYt@%-aIgMym|E$fVWuJf_nN%}MlbQ#?Bo!`zAyE7P4EVOrSORHUc*X_zo#rP?ZK z4bxn`OGD?tx+M+0$xMT-t8g1j0@P0H4C*jxJw1Pl(j97Xw9FIjSX*1LL8l#3- zx*?X>A;T>V5p7W5NRBxT8CY_|a5AOXCYIW7(f|#D|9Uc$uW?%w#G6 zEf{+QUSyFsxxjxsnGk;-nfepF_yL9YAga#khg)?EMQ?Xgz95xPJX+<(^2EC4HjR!z? z66Rz~JCmt+G=#J(6&|rd9V$0d`vvG0dV@)~(ruc)Ei#emLT~zx16wUbxSZuvW*wP~;p3F%!X-A!-BoU!nBs~s6eII|^|8O2jN z6Ha8{At2{G?{dz0>X4ZE+f8~0y%WA2641U;ly6#B4LdH#boElm{@rwsLHC+;AKgF2 zlqEi!vl}61z`pyC;@Q3tQf6()+|c8tL$` zwI&*^qjI`SUOZ^hL-L{)yiPfe72!fif5fB@&_2Y1w3TrYboBp~py{|$%*=7mqQ!O# zlO`RIs4y244#v{4p;#PxuraYdmP%(j;?@|TN%%Qr(qRcd)pqpA2c{32^db5%0+anl z(tj%-w-*GN06j)&ao3-gnDrT^tEZK4z6wJeEQE%lyQnqu63YcEWD=<0G6?#!3Sj2mtC09TlfEx#%#awNH7q@Yen>wu z=*K4ggno)7CJ$QBk?b0=Bn;1A47tl5hdTY7eqqorP5PB2mM-LSQ(*j~;dHOHD*HRUbAJTAtT#5bgo#m21$bR4SaN&2fn|7+4y z(h*ot0$X@oCv!59a>DvERws(iSOrE7G zIB18k4uenf=_b$NGX&FUEYlv2$A`j^?EqZs!Zc?oe5T3sc)r9CXmsKHdOLFGO-pR? zNp3KCffzwOjG#X`-fiu$;yGi0@$uOvFA|V51f*>wg4a1FKS#u0k&Ug7#bJ_JZZbJ2 z(D)d+8l7C@?WrmkCfo_M<%?xew* z(?Y7dUYHUz!N+g%8k5&@J9Hy9yb1Lrga&nRtK`&WDpqiZ$?F+PRlgHO>SilBZY8AT zSd)g5G41n|gBx3K^>{W!dd1Sbh;)hKHD^4P%p@bpcu3*griy#+GI;}EqN}-qYz#$0 zVelIm=vpU1ihE4%<&7v?QgBt_wB?pD+(3a@t3{e^mzunp`&2Si#Inc#3t3ha3><}b zx~8_agJVzffXRcr1Ut#i#7#){VF}xUK<*N(PWEy%l=#}D;n*%vEiH=nAkjW7SHv&4j+XSzn z+fN{Lm`4mAH95vtqJNhUd#L+!edk=~6I$4iuy*#T1fhR4Wrd?tx}#{Y735(qFjMCC z*?nV!<5OCPh)$?ORicZA`7U^Xs=nArA}oz>rsuh0;uL?fsuY8y{extsZ8bcr+mU(U zs9Wr-f-)C|r1(;BmT>ZFVA21g6VCgbg$^o`o4A2e*?p>{pjcCUa?t zt=uBSLUxpP7f9j@i9y)630hsm^^)pSoKK5#h(-`f607F3IRlxOj-Xmsew}`a6_?XIFKsEM+%SoPt`j+5>-Y=$=&B`vy>o z$N|9SFho}=WuZO}K-Q4Q)b7o?Ml?NophPFE*l`v|TX;2}?1$d_@!;ebboalJLDTPl z0>oTel4H-FL6;mCvlG@AJBXlTT|%bb7@nl&p8$_e(aB-d-Of3?6dCzRP?d_5HJ02V zenn17@VUq<#35mFpV(ks>;$Gw?@e+Y`I2Wf%7#si<3K{X@jzMUCOEP2!r=tUN_f<8 z6rQRIe{(>6ateVgTRw7Q71@LyHT6SoTpBmFd)AF@Nys;dE3eDHkt9$zw%6L~agxfA z&v*s$%=Bn78;|A>VS$`xhn&mhlNQ-_)l_wqTxXSsn!15(%BUNr19N$#RCKqg`5ZfN3X*$4Li^ois4<~>(Vq~8ku&|ze>-yPuKZU?TdfaZl=QymF43WQ#=`tsxle(JEE>GmEBR4)9h26 zMXE-|c`b}7B~`+IW0OacFBHXjhX~!m$-4Bc&i0aeQIm-2scDn2|7d6HU!xi)K)HCN zQ{&;Wp=fxKOK%pPiyo|7bB<%9UL6ngS!|&8xvK!g6&8CR2~;vtxhfL3Z%_vMlG#+m zlIAwHwq52DaZYH4_u{Hjk0nJhlIYsCC_>X2oI3YdnbBl4T}#_*{QO1fv3$wom#J@G zk#Ao$`IEH0lD`hGD+et)`b1YYa3z0JcEK1Thb1F`@NHT19g~mJ_FDP`2EHc)-#7W= z{Nh^vA+F+;Z036LplPtCf`1&~pYTr&{+Y=?=U*U`%X?&Ww36GcP`6?VND1RsBO*5r z(PoUfzkSZ_?d?UH@=jcriCgJK-O1$k?0CuBnX2+BSg4xduGn(oFZGu0t){NEV~TrQ zIZ5fmSza4h@~Z&F>l8wbc9UY z%{*0Zz2dV>E+eW>oYt#PTpClKIHSj!F?r?$ogw#2)i^G^I^#*)7KOv)am=|2Z@&xZ z0emhFwlp6g4mR&6BY1?Ww+371_~!WMRLn6>tDIAHfNJ(r-NTqz&MEwr<0ryZ#0pvm z03Ec5)^i#o!x|W8IE!xptB_}T;vKvb$jcLsw{SN@o_6sW3esXZio?Y+S%I@pz?CPQ zVUnTd;RF(_kQEbrHC6FT@Z8|Qc&P*98Vv)puEi|PitDf|?tg#*m!Oh89x(xcZmCmGX;K=2;7<>htk4ulMDNN6!DDIzH^n4nn zODIN{g38M%j?pn(5lqlk_?yMwmr#m!Q<`p~3~rX~QoIT(Vcl%2$Z5DfD2l;=)6Or4 zCYJ%)Mf?gp`4lHlp^DmL)N0_~rtu6l`FwN1&{y)SYDg zLRgv7Rrx^x(KR6IT7|$YJ>SvMTE3od00Jzx0By6l2y@Dn@y7xO>Z1dh*c6751IPe=WqW5_dAb*}- z>F86r!o>{2&|ap^Ca+7{_#c3mc|SbcgDwu54%H^VnQsS7<~wr6Pj9pJX$5TYRo%^E zzW{&Zqjbw8y&f*-jYO05CYU>v!#GTDk*~@L6>|&7}^SEqph>wGb&@ zitlf8kb-G~sYJ`M{d)wRqjZLRDCfPGX3De8EeERyG#@AF{l%C)4n_VR&BisQg}9fr zlzv3Z=*J2(ZCT4TW{OGW#0!DS+Z|Nyvwcoio<9y#f(Pm0e*(LOD$M;FeESXf_5?8e z9R=z4!0wL-wSS^c`m>9FT@L@&x%gK_Wg=8w8U)twaIjtmsR{#)U^!yJ{=8Ca2l*5f z>uDDU3>!A{JH0^orh)+Q3T96b@A3d45vdppHUOg16U4i{KzLy>6Ci55K*-g5aO5iR zAY?0(AEG)4ErBzTv_|=1+#?c0(t6-`xNr~OYcF06Mv5z{2%fc{4i@nd7mfTpTS3sK zSYbmD=#sICW4MpoZMU`wh={3GNal4Uucb;Rk`nRU60mf(mosaD{vl+2x!7|*_Q8(@ zY>azsa_d0uk)u>CA4qLJQbze5m22Y#Wbi`#LTr(X2X&f)oGqLJ(D<2w4f^X)Rf!3m zbNAAsBlH-;?QYI?1K|7nk+$pfeTj{l?;w4n7@E$=wHo^h z(0ULsa6iG8;4+XVD#&?F)^^bALWdppF+Zf(S0=ZLZ4f_#J@^4)gXYlVhv)}Py{(7n zXOdT+j)z|}?gBrt(l^I9cana;Bh!8^m&bkVI|QFb`nmcLc_Y)IqKmb_VXkV$SH*$Ie?Mf*rA5XkI_t29>=K0;MSaPJqWJh2N*UFj{<9PfRZdt zi+68p%RxSOl9xZqOY=0SppR0~j{?X<=`$16O+8-^8g2ltH^SrXanu|eIc#om=$Oxk zgbviqreb|Qzpe+ALkx=ScW*&{?L`}?IgENvhsXPEnwo^B9@HvZdRrlaRwV)yl+E?L za*|gCTMqL@P!Ya3uOce(L)Pv`h8^HH;fK0!hPvJX({2{j`Wbs5%4z zT<-o5sL?ct|I#A|Y|Kq6dxlPfR3H*q1a9L=c7GKfFM$)1e!(7PKrQHCO!B%r=o)-I zPPQnMp;Mic-0fPo=}~%er9U{wk1Ak}AMg9^;I^_tX3eQ6cZat*6^L&bKUj6!T__rT z$V=kjok}~kp01>s!0_! 0`. | +| `ShowRound` | `true` | Shows current round out of total rounds (e.g. `2 / 3`). Only renders when `TotalRounds > 1`. | + +## How Rounds Work + +You do not need to manually advance rounds. The runtime handles this automatically: + +1. When the game starts, the `$round` timer begins counting down `RoundLengthSeconds` for round 1. +2. When the timer expires, round 2 starts and the timer resets. +3. When round 3's timer expires, `on_game_rounds_complete` is dispatched. + +Your trigger volumes use the `CurrentRound` condition to check which round is active, so each capture zone automatically becomes active or inactive as rounds change. + +## Testing With One Player + +Change `MinPlayers` to `1` while setting up and testing your world. Change it back before going live. diff --git a/docs/FullTutorialExamples/KingOfHill/03-join-and-start.md b/docs/FullTutorialExamples/KingOfHill/03-join-and-start.md new file mode 100644 index 0000000..e6e6c13 --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/03-join-and-start.md @@ -0,0 +1,86 @@ +# King of the Hill — Join Pad & Auto-Start + +This step sets up the volumes that let players join the queue and auto-start the game once enough players are ready. + +--- + +## 1. Arena Boundary Volume + +This volume is not interactive — it exists purely so the runtime knows which map it is running on. Place it as a large box that encompasses the entire arena. + +**Volume type:** Any (Enter trigger is fine, no effects needed) + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` *(replace with your map name)* | +| `MapRole` | `MainArena` | + +No conditions. No effects. + +> The `MapId` value is arbitrary — pick a name that describes your arena (e.g. `Hillside`, `Volcano`, `Castle`). Use the same value on every other volume in this world that belongs to this map. + +--- + +## 2. Queue Pad + +Place this volume at a lobby pad where players gather before the game. When stepped on, it adds the player to the queue and attempts to start the game. The start attempt is a no-op until `MinPlayers` (2) have joined, at which point the runtime is created automatically. + +**Volume type:** Enter trigger + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `JoinMinigameQueue` | `MinigameId = King_Of_The_Hill` | +| `StartQueuedMinigame` | `MinigameId = King_Of_The_Hill` | + +> **How the auto-start works:** `StartQueuedMinigame` checks that no runtime is already running and that the queue has at least `MinPlayers` players before doing anything. With `MinPlayers = 2`: Player 1 steps on the pad — `StartQueuedMinigame` sees only 1 queued player and skips. Player 2 steps on the pad — queue now has 2 players, `StartQueuedMinigame` creates the runtime (phase = `WAITING_FOR_PLAYERS`). The signal volume below immediately advances it to `ACTIVE`. + +Players who step on this pad after the game has already started are queued for the next game — `StartQueuedMinigame` detects the active runtime and skips. + +--- + +## 3. STARTING Phase Volume + +The system runs the `CountdownSeconds` countdown automatically during `WAITING_FOR_PLAYERS`. Once the countdown finishes, the runtime advances to `STARTING` and fires `SignalPhaseStart=STARTING`. Your volume responds by setting the phase to `ACTIVE` to begin the game. + +**Volume type:** Any (Tag Added trigger — fired by the runtime, not physically entered) + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalPhaseStart` | `STARTING` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `SetMinigamePhase` | `MinigameId = King_Of_The_Hill`, `Phase = ACTIVE` | +| `SetRound` | `Operation = Set`, `Amount = 1` | +(anything else you want to happen? Effects, sounds, Starting cinematic? Do before setting to active and starting the round) + +> **Countdown**: with `CountdownSeconds = 10` in the definition, a "Starting in N..." message is sent to all players each second. If a player steps off the queue pad (`LeaveMinigameQueue`) or disconnects during the countdown and the player count drops below `MinPlayers`, the countdown cancels and players are returned to the queue automatically. +> +> **Skip the countdown**: set `CountdownSeconds = 0` in the definition to go from `STARTING` to `ACTIVE` immediately. + +--- + +## Leave Queue (Optional) + +If players want to leave before the game starts, add a separate pad with: + +| Effect | Fields | +|---|---| +| `LeaveMinigameQueue` | `MinigameId = King_Of_The_Hill` | diff --git a/docs/FullTutorialExamples/KingOfHill/04-spawn-and-activation.md b/docs/FullTutorialExamples/KingOfHill/04-spawn-and-activation.md new file mode 100644 index 0000000..fb4f30a --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/04-spawn-and-activation.md @@ -0,0 +1,67 @@ +# King of the Hill — Spawn Zone & Player Activation + +Players are not automatically active when the game starts. They must enter the spawn zone, which triggers the activation lifecycle: inventory save, item giving, game mode change, and HUD display. + +--- + +## What Activation Does + +When `SetPlayerStatus ACTIVE` fires for a player, the runtime: + +1. Saves their current inventory (if `SavePlayerInventory = true`, the default). +2. Gives them their start items or assigned loadout (if any defined in the definition). +3. Applies `RequiredGameMode` (`Adventure` in this tutorial). +4. Shows the HUD (since `ShowHud = true`). + +This only happens once per player per game. If the same player re-enters the spawn zone it is harmless — the runtime ignores duplicate ACTIVE requests. + +--- + +## Spawn Zone Volume + +Place this volume at the area where players begin the round — typically a platform or small room at the edge of the arena that all players pass through when the game starts. + +**Volume type:** Enter trigger + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | + +**Conditions:** + +| Condition | Fields | Purpose | +|---|---|---| +| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | Only fires for players who are actually in the runtime (not random passersby). | +| `MinigamePhase` | `MinigameId = King_Of_The_Hill`, `Phase = ACTIVE` | Prevents accidental activation while players are still in the lobby countdown. | + +**Effects:** + +| Effect | Fields | +|---|---| +| `SetPlayerStatus` | `MinigameId = King_Of_The_Hill`, `Status = ACTIVE` | +| `SetSpawnPoint` | `MinigameId = King_Of_The_Hill`, `DestinationId = 100,64,200` *(replace with your spawn coordinates)* | + +> The `SetSpawnPoint` stores a respawn destination for each player. When a player dies during the game, the runtime automatically respawns them at their stored checkpoint. Replace `100,64,200` with the actual world coordinates of your spawn area center. You can also use the format `world_name,X,Y,Z` if the spawn is in a different world. + +--- + +## How Players Get to the Spawn Zone + +In a typical KOTH setup, players are somewhere in the arena or lobby when the game starts. You have two options: + +**Option A — Players walk there.** The spawn zone is accessible from the lobby area. When the game starts and phase becomes `ACTIVE`, players walk through the spawn zone on their way into the arena. This is the simplest approach and works well when the lobby and arena are adjacent. + +**Option B — Teleport on game start.** Add a `SignalPhaseStart=ACTIVE` signal volume (covered in `06-round-signals.md`) with a `RespawnPlayer` effect. This teleports all active players to their spawn on phase change. + +For this tutorial, Option A is assumed. If using Option B, `RespawnPlayer` resolves the destination using the player's stored checkpoint from `SetSpawnPoint`, so the activation volume still needs to fire first. Consider placing the spawn zone between the lobby exit and the arena entrance so it is always traversed. + +--- + +## Respawn on Death + +Because `SetSpawnPoint` is called at activation, player deaths during the game automatically respawn at those coordinates. No additional setup is required for basic respawning. + +If you want players to respawn with a delay or only when a specific condition is met, add a `RespawnPlayer` effect to a separate signal or death-event volume. diff --git a/docs/FullTutorialExamples/KingOfHill/05-capture-zones.md b/docs/FullTutorialExamples/KingOfHill/05-capture-zones.md new file mode 100644 index 0000000..5d17c5e --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/05-capture-zones.md @@ -0,0 +1,147 @@ +# King of the Hill : Capture Zones + +Each round has one active capture zone. Players standing inside the correct zone for the current round earn +1 point per second. Zones outside the active round do nothing. + +You will create three separate trigger volumes : one per hilltop. + +--- + +## How Scoring Works + +Each capture zone uses: + +- A **TICK** volume type so it fires continuously while players are standing inside it. +- A `CurrentRound` condition that passes only when the correct round number is active. +- A `ModifyPlayerScore` effect that adds 1 point per trigger fire. + +The tick rate on the volume controls how often points are awarded. Set it to **1 second** in the editor so players earn 1 point per second. If the tick fires every 0.5 seconds, players would earn 2 points per second : adjust the amount or tick rate to your preference. +leaving it at zero would be everytick its fired, Giving 30 points +--- + +## Capture Zone A : Round 1 + +Place this volume on Hilltop A, the first round's scoring area. Size it so that players must be clearly on top of the hill. + +**Volume type:** TICK trigger (tick interval: 1 second) + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | + +**Conditions:** + +| Condition | Fields | Purpose | +|---|---|---| +| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | Only scores players in the runtime. | +| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 1` | Only active during Round 1. | + +**Effects:** + +| Effect | Fields | +|---|---| +| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` | + +--- + +## Capture Zone B : Round 2 + +Place this volume on Hilltop B. Same setup as Zone A with only the round number changed. + +**Volume type:** TICK trigger (tick interval: 1 second) + +**Tags:** Same as Zone A (`MinigameId`, `MapId`) + +**Conditions:** + +| Condition | Fields | +|---|---| +| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | +| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 2` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` | + +--- + +## Capture Zone C : Round 3 + +Place this volume on Hilltop C. + +**Volume type:** TICK trigger (tick interval: 1 second) + +**Tags:** Same as above + +**Conditions:** + +| Condition | Fields | +|---|---| +| `PlayerInMinigame` | `MinigameId = King_Of_The_Hill` | +| `CurrentRound` | `MinigameId = King_Of_The_Hill`, `Compare = EQUAL`, `Round = 3` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` | + +--- + +## Notes + +**You may want to add further effects the zones** : +On Capture Zone 1 using `SignalRoundStart=1` volume tag (covered in `06-round-signals.md` and `tags.md`) +you can add the following to play a sound at the start of the round and have a VFX with a duration of the round set + +On Tag Added trigger +| Effect | Fields | +|---|---| +| `PlaySound` | `Your choice of sound`| +| `Play VFX` | `Your choice of VFX` `Duration = round time`| + + + +**Multiple players on the hill at once** : each player in the volume gets the `ModifyPlayerScore` effect independently. If two players stand on Hilltop A during Round 1, both earn +1/second. This is intentional for KOTH : fighting over the hill is part of the game. + +**Zone size** : make your capture zones intentionally tight so there is real competition for the spot. A zone that is too large removes the "king of the hill" tension. + + +**Dymanic Zone Size** +Lets say you have rounds last 60 secounds. You want the Capture Zone for round 2 to get smaller half way. +Create two Capture Zones one the full size and one the half size + +Add on the large Capture Zone 2 using `SignalRoundStart=2` volume tag (covered in `06-round-signals.md` and `tags.md`) +And another tag to each to later identify them +`Round2Capture=Large` +`Round2Capture=Small` + +The larger Capture Zone +On Tag Added trigger +| Effect | Fields | +|---|---| +| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Small`| +| `PlaySound` | `Your choice of sound`| +| `Play VFX` | `Your choice of VFX` `Duration = 30`| +| `EnableVolume` | `EffectDelay=30` `MatchTagKey=Round2Capture` `MatchTagValue=Small`| +| `ModifyTag` | `Operation=Set` `TagKey=SmallRound2Capture` `TagValue=Enabled` `MatchTagKey=Round2Capture` `MatchTagValue=Small`| +| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Large`| + +The Smaller Capture Zone +On Tag Added trigger +| Effect | Fields | +|---|---| +| `PlaySound` | `Your choice of sound`| +| `Play VFX` | `Your choice of VFX` `Duration = 30`| +| `EnableVolume` | `EffectDelay=30` `MatchTagKey=Round2Capture` `MatchTagValue=Large`| +| `ModifyTag` | `Operation=Remove` `TagKey=SmallRound2Capture` `TagValue=Enabled` `MatchTagKey=Round2Capture` `MatchTagValue=Small`| +| `DisableVolume` | `MatchTagKey=Round2Capture` `MatchTagValue=Large`| + + +There are a couple of ways to do this, but this is just one I felt like typing. + +**Score display** : the HUD shows the current score in real time. Players can see how their score compares while standing on the hill. Round and time remaining are also shown because `ShowRound = true` and `ShowTime = true` are set in the definition. diff --git a/docs/FullTutorialExamples/KingOfHill/06-round-signals.md b/docs/FullTutorialExamples/KingOfHill/06-round-signals.md new file mode 100644 index 0000000..5f12790 --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/06-round-signals.md @@ -0,0 +1,112 @@ +# King of the Hill — Round Signals & Game End + +This step wires up announcements for each new round and explains how the game ends automatically when the final round completes. + +--- + +## How Signal Volumes Work + +Signal volumes have special tags (`SignalRoundStart`, `SignalPhaseTick`, `SignalPhaseStart`, etc.) that the runtime writes to at the right moment. When the tag value changes, Hytale fires the volume's `TAG_ADDED` trigger, which runs the attached effects. + +Every signal volume must also have `MinigameId` set so the runtime knows which game's signals to respond to. + +--- + +## Round 2 Announcement + +When Round 2 begins, fire a message to all players announcing the new hill location. + +**Volume type:** Any (Tag Added trigger is conventional for signal volumes — the volume is never physically entered, it is fired by the runtime) + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalRoundStart` | `2` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_2_start`, `Target = ALL` | + +> `MessageKey` references a translation key in your server's language files. If you do not have a dedicated translations setup, you can skip `SendMinigameMessage` for now — the HUD already shows the updated round number automatically. + +--- + +## Round 3 Announcement + +Same structure, fires when Round 3 begins. + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalRoundStart` | `3` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_3_start`, `Target = ALL` | + +--- + +## How the Game Ends Automatically + +When the Round 3 timer expires, the runtime calls `end()` automatically (because `TotalRounds = 3` and neither `OvertimeEnabled` nor `SuddenDeathEnabled` are set). No extra timer volume is needed. + +The end sequence fires in this order: + +1. `SignalPhaseStart=ENDING` fires — use this for a scoreboard display, player freeze, or end announcement. +2. HUDs are removed, inventories restored, rewards distributed. +3. `on_game_end` event fires (with `reason = "rounds_complete"`). +4. Runtime is removed from the active list. +5. `SignalPhaseStart=RESETTING` fires — use this to restore blocks, re-enable the queue pad, etc. +6. `on_arena_reset` event fires. + +You do not need a `StartTimer`/`SignalOnTimerExpire` pair to end the game. + +--- + +## ENDING Phase Volume (Optional) + +To show a final message or freeze players briefly at game end, respond to `SignalPhaseStart=ENDING`: + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalPhaseStart` | `ENDING` | + +**Example effects:** + +| Effect | Fields | +|---|---| +| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = game_over`, `Target = ALL` | + +--- + +## Round 1 Announcement (Optional) + +Round 1 begins immediately when the game goes ACTIVE (there is no `SignalRoundStart=1` in the current round implementation for the initial round). If you want a Round 1 announcement, fire it from the `SignalPhaseStart=ACTIVE` volume set up in step 03: + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalPhaseStart` | `ACTIVE` | + +**Effects:** + +| Effect | Fields | +|---|---| +| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = round_1_start`, `Target = ALL` | diff --git a/docs/FullTutorialExamples/KingOfHill/07-end-and-reset.md b/docs/FullTutorialExamples/KingOfHill/07-end-and-reset.md new file mode 100644 index 0000000..4b6df00 --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/07-end-and-reset.md @@ -0,0 +1,87 @@ +# King of the Hill — Ending & Resetting + +This step covers what happens when the game ends, what signals fire and when, and how to use arena reset to prepare for the next game. + +--- + +## What Happens When the Game Ends + +When the final round timer expires (and no overtime/sudden death is configured), the runtime calls `end("rounds_complete")` automatically. The full sequence: + +1. **HUDs removed** for all players in the runtime. +2. **Phase set to `ENDING`** → `SignalPhaseStart=ENDING` fires. +3. **Active signal timers cancelled.** +4. **Temporary entities cleaned up** (spawned NPCs/items tracked by the runtime). +5. **Player inventories restored** (since `RestorePlayerInventory = true` is the default). +6. **Rewards distributed** (if any `Rewards` are defined in the definition). +7. **`on_game_end` event dispatched** with `reason = "rounds_complete"` and final scores. +8. **Runtime state cleared** — scores, player status, timers, counters all zeroed. +9. **Runtime removed** from the active list. +10. **Phase set to `RESETTING`** → `SignalPhaseStart=RESETTING` fires. +11. **`on_arena_reset` event dispatched** (since `ResetOnEnd = true` is the default). + +You do not need to manually reset scores or clear HUDs. + +--- + +## ENDING Phase Volume (Optional) + +Use `SignalPhaseStart=ENDING` to show a scoreboard, announce the winner, or freeze players briefly before cleanup. This fires while the runtime still holds final scores. + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalPhaseStart` | `ENDING` | + +**Example effects:** + +| Effect | Fields | +|---|---| +| `SendMinigameMessage` | `MinigameId = King_Of_The_Hill`, `MessageKey = game_over`, `Target = ALL` | + +--- + +## Arena Reset Volume + +Use `SignalPhaseStart=RESETTING` to restore any world state changed during the game. This fires after the runtime has been cleaned up, so it is safe to re-enable the queue pad without risk of double-triggering. + +> **Scope note:** `SignalPhaseStart=RESETTING` is scoped by `MinigameId` tag match, NOT by physical containment within the `MapRole=MainArena` boundary. Any volume anywhere in the world with a matching `MinigameId` tag will respond. The arena boundary volume is only used for map *discovery*, not for event routing. + +For this King of the Hill setup there are no block placements or NPC spawns, so the arena reset volume is optional. Add it if you want to re-enable the queue pad, reset visual markers, or restore barriers changed mid-game. + +**Tags:** + +| Tag | Value | +|---|---| +| `MinigameId` | `King_Of_The_Hill` | +| `MapId` | `Hillside` | +| `SignalPhaseStart` | `RESETTING` | + +**Example effects:** + +| Effect | Fields | Purpose | +|---|---|---| +| `PlaceBlocks` | *(coordinates of any changed areas, original block id)* | Restore modified terrain. | + +--- + +## Repeating the Game + +After the runtime ends and `SignalPhaseStart=RESETTING` fires, the minigame is ready to start again. Players can step back onto the queue pad and a new runtime will be created when `StartQueuedMinigame` fires again. + +No state from the previous game persists — scores, player status, timers, and counters are all cleared by the end flow. + +--- + +## Manual Reset + +If you need to reset the game mid-match (for testing or crash recovery), use the admin command: + +``` +/minigame reset King_Of_The_Hill +``` + +This calls `ResetMinigame` directly, which sets phase to `RESETTING`, fires `SignalPhaseStart=RESETTING`, clears runtime state, and dispatches `on_arena_reset` — without going through the normal end flow (no rewards, no inventory restore, no `ENDING` phase). diff --git a/docs/FullTutorialExamples/KingOfHill/08-full-example-files.md b/docs/FullTutorialExamples/KingOfHill/08-full-example-files.md new file mode 100644 index 0000000..b80d0a1 --- /dev/null +++ b/docs/FullTutorialExamples/KingOfHill/08-full-example-files.md @@ -0,0 +1,135 @@ +# King of the Hill — Complete Reference + +All volumes and the definition file in one place. Use this as a checklist when setting up your world. + +--- + +## Definition File + +**Path:** `src/main/resources/Server/Minigames/King_Of_The_Hill.json` + +```json +{ + "Id": "King_Of_The_Hill", + "Name": "King of the Hill", + "Enabled": true, + "MinPlayers": 2, + "MaxPlayers": 16, + "TeamMode": "SOLO", + "WinCondition": "HIGHEST_SCORE", + "RoundLengthSeconds": 120, + "TotalRounds": 3, + "CountdownSeconds": 10, + "AllowPvp": true, + "RequiredGameMode": "Adventure", + "ShowHud": true, + "ShowScore": true, + "ShowTime": true, + "ShowRound": true +} +``` + +--- + +## Volume Checklist + +| # | Volume Name | Type | Tags | Conditions | Effects | +|---|---|---|---|---|---| +| 1 | Arena Boundary | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `MapRole=MainArena` | — | — | +| 2 | Queue Pad | Enter | `MinigameId=King_Of_The_Hill` | — | `JoinMinigameQueue` → `StartQueuedMinigame` | +| 3 | Starting Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=STARTING` | — | `SetMinigamePhase ACTIVE` | +| 4 | Spawn Zone | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `MinigamePhase=ACTIVE` | `SetPlayerStatus ACTIVE` → `SetSpawnPoint` | +| 5 | Capture Zone A | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 1` | `ModifyPlayerScore ADD 1` | +| 6 | Capture Zone B | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 2` | `ModifyPlayerScore ADD 1` | +| 7 | Capture Zone C | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 3` | `ModifyPlayerScore ADD 1` | +| 8 | Round 2 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=2` | — | `SendMinigameMessage round_2_start ALL` | +| 9 | Round 3 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=3` | — | `SendMinigameMessage round_3_start ALL` | +| 10 | End Announcement | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=ENDING` | — | `SendMinigameMessage game_over ALL` *(optional)* | +| 11 | Arena Reset | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=RESETTING` | — | *(block restore effects)* *(optional)* | + +The game ends automatically when Round 3's timer expires — no end-game timer volume is needed. + +--- + +## Volume Detail — Queue Pad (Auto-Start) + +``` +Tags: + MinigameId = King_Of_The_Hill + +Effects: + JoinMinigameQueue MinigameId=King_Of_The_Hill + StartQueuedMinigame MinigameId=King_Of_The_Hill +``` + +`StartQueuedMinigame` is a no-op until `MinPlayers` (2) are in the queue AND no runtime is already running. + +--- + +## Volume Detail — Starting Signal + +``` +Tags: + MinigameId = King_Of_The_Hill + MapId = Hillside + SignalPhaseStart = STARTING + +Effects: + SetMinigamePhase MinigameId=King_Of_The_Hill Phase=ACTIVE +``` + +Fires after the countdown completes. The system runs the `CountdownSeconds` countdown +automatically during `WAITING_FOR_PLAYERS` — no timer volume required. + +--- + +## Volume Detail — Spawn Zone + +``` +Tags: + MinigameId = King_Of_The_Hill + MapId = Hillside + +Conditions: + PlayerInMinigame MinigameId=King_Of_The_Hill + MinigamePhase MinigameId=King_Of_The_Hill Phase=ACTIVE + +Effects: + SetPlayerStatus MinigameId=King_Of_The_Hill Status=ACTIVE + SetSpawnPoint MinigameId=King_Of_The_Hill DestinationId= +``` + +Replace `` with the world coordinates of your spawn area. + +--- + +## Volume Detail — Capture Zone A (copy pattern for B and C) + +``` +Tags: + MinigameId = King_Of_The_Hill + MapId = Hillside + +Conditions: + PlayerInMinigame MinigameId=King_Of_The_Hill + CurrentRound MinigameId=King_Of_The_Hill Compare=EQUAL Round=1 + +Effects: + ModifyPlayerScore MinigameId=King_Of_The_Hill Operation=ADD Amount=1 +``` + +Change `Round=1` to `Round=2` and `Round=3` for Zones B and C. + +--- + +## Common Mistakes + +| Symptom | Likely Cause | +|---|---| +| HUD never appears | `SetPlayerStatus ACTIVE` is not firing — check that the spawn zone has both `PlayerInMinigame` and `MinigamePhase=ACTIVE` conditions. | +| Score never changes | Capture zone tick interval is not set, or the `CurrentRound` condition does not match the active round. Verify the `Round` value matches the round number (1, 2, or 3). | +| Game never starts | Queue pad must have both `JoinMinigameQueue` AND `StartQueuedMinigame`. The starting signal volume must have `SignalPhaseStart=STARTING` with `SetMinigamePhase ACTIVE` effect. | +| Game never ends | Rounds do not auto-end if `OvertimeEnabled` or `SuddenDeathEnabled` are set in the definition. Verify neither is enabled. | +| Volume conditions blocked | Wrong `MinigameId` value — ensure every volume and every condition/effect field uses exactly `King_Of_The_Hill` (case-sensitive, underscores not spaces). | +| Players not activating | Phase was never set to `ACTIVE` — verify the auto-start signal volume (`SignalPhaseStart=WAITING_FOR_PLAYERS`) is present and fires `SetMinigamePhase ACTIVE`. | +| Game starts with 1 player | `MinPlayers` in the definition is set to `1`. Change to `2` if you need two players. | diff --git a/docs/FullTutorialExamples/template/00-Other Example Structures.md b/docs/FullTutorialExamples/template/00-Other Example Structures.md new file mode 100644 index 0000000..7a91c1f --- /dev/null +++ b/docs/FullTutorialExamples/template/00-Other Example Structures.md @@ -0,0 +1,50 @@ +These are just examples of how tutorial files might be depending on their game and format. + solo-brawl/ + 01-overview.md + 02-create-minigame-definition.md + 03-create-start-pad.md + 04-create-arena-and-spawns.md + 05-activate-players.md + 06-enable-pvp-and-damage.md + 07-add-scoring.md + 08-add-timer-and-win-condition.md + 09-end-reset-and-cleanup.md + 10-full-example-files.md + + team-objective-game/ + 01-overview.md + 02-create-minigame-definition.md + 03-create-queue-and-start-flow.md + 04-create-map-tags-and-arena.md + 05-create-team-spawns.md + 06-assign-and-balance-teams.md + 07-activate-players.md + 08-create-objectives.md + 09-add-team-scoring.md + 10-add-win-condition.md + 11-end-reset-and-cleanup.md + 12-full-example-files.md + + parkour-checkpoints/ + 01-overview.md + 02-create-minigame-definition.md + 03-create-start-and-finish-volumes.md + 04-create-checkpoints.md + 05-track-player-progress.md + 06-add-timer.md + 07-handle-falls-and-respawns.md + 08-finish-reset-and-cleanup.md + 09-full-example-files.md + + hot-potato/ + 01-overview.md + 02-create-minigame-definition.md + 03-create-queue-and-start-flow.md + 04-create-arena-and-spawns.md + 05-activate-players.md + 06-spawn-the-potato-item.md + 07-pass-the-potato.md + 08-countdown-and-explosion.md + 09-eliminate-or-score-players.md + 10-end-reset-and-cleanup.md + 11-full-example-files.md \ No newline at end of file diff --git a/docs/FullTutorialExamples/template/00-trigger-volume-basics.md b/docs/FullTutorialExamples/template/00-trigger-volume-basics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/01-overview.md b/docs/FullTutorialExamples/template/01-overview.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/02-minigame-definition-basics.md b/docs/FullTutorialExamples/template/02-minigame-definition-basics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/03-player-queue-and-start-flow.md b/docs/FullTutorialExamples/template/03-player-queue-and-start-flow.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/04-player-activation-and-inventory-flow.md b/docs/FullTutorialExamples/template/04-player-activation-and-inventory-flow.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/05-maps-arenas-spawns-and-tags.md b/docs/FullTutorialExamples/template/05-maps-arenas-spawns-and-tags.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/05.1-Futher Specifics.md b/docs/FullTutorialExamples/template/05.1-Futher Specifics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/06-teams-scoring-and-objectives.md b/docs/FullTutorialExamples/template/06-teams-scoring-and-objectives.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/06.1-Further Specifics.md b/docs/FullTutorialExamples/template/06.1-Further Specifics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/07-timers-phases-rounds-and-win-conditions.md b/docs/FullTutorialExamples/template/07-timers-phases-rounds-and-win-conditions.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/08-lives-respawns-and-status-effects.md b/docs/FullTutorialExamples/template/08-lives-respawns-and-status-effects.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/09-ending-resetting-and-cleanup.md b/docs/FullTutorialExamples/template/09-ending-resetting-and-cleanup.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/FullTutorialExamples/template/10-multimap-and-map-voting.md b/docs/FullTutorialExamples/template/10-multimap-and-map-voting.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/events-conditions-effects.md b/docs/events-conditions-effects.md index ddb0974..85c924e 100644 --- a/docs/events-conditions-effects.md +++ b/docs/events-conditions-effects.md @@ -97,6 +97,9 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In | `ClearInventory` | `MinigameId`, optional `PlayerId` | Clears the player's inventory. | | `SetObjectiveStatus` | `MinigameId`, `ObjectiveId`, `Status` | Creates or updates an objective status and dispatches `on_objective_complete` when complete. | | `ModifyObjectiveProgress` | `MinigameId`, `ObjectiveId`, `Operation`, `Amount` | Sets, adds, or subtracts objective progress and completes an active objective when progress reaches the required threshold. | +| `OpenMinigameQueueUI` | optional `MinigameId`, optional `MapId` | Opens the queue UI page for the triggering player. Mode is inferred: no `MinigameId` → Global browser; `MinigameId` only → Single-game map list; both `MinigameId` and `MapId` → Local arena popup. | +| `StartPlayerTimer` | `MinigameId`, optional `PlayerId` | Starts a per-player stopwatch for the triggering player. Records the start timestamp in player state. Use on race start pads. If `PlayerId` is blank, the trigger context player is used. | +| `StopPlayerTimer` | `MinigameId`, optional `PlayerId`, optional `Operation` | Stops the player's stopwatch and applies the lap time to the player's score. `Operation` controls multi-round behaviour: `SET` (default) replaces the score, `ADD` accumulates lap times for a total time, `BEST` keeps the lowest recorded lap. Ignored if `StartPlayerTimer` was never called for this player. | ### Operation Values diff --git a/docs/examples.md b/docs/examples.md index b0cc09c..99f5cdc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -18,7 +18,8 @@ Definition file: `Server/Minigames/Quick_Brawl.json` "WinCondition": "HIGHEST_SCORE", "RoundLengthSeconds": 120, "CountdownSeconds": 5, - "AllowPvp": true + "AllowPvp": true, + ... } ``` @@ -28,6 +29,18 @@ Editor setup: 2. Add the `StartMinigame` effect. 3. Set `MinigameId` to `Quick_Brawl` using the asset picker. +## Activating Players + +Players must be explicitly set to `ACTIVE` status before the activation lifecycle runs. This is what triggers inventory saving, start item giving, game mode change, and HUD display. Without a `SetPlayerStatus ACTIVE` effect, none of these happen. + +A common pattern is a spawn-room trigger volume that all players enter when the game starts: + +1. Create a trigger volume covering the spawn area. +2. Add `PlayerInMinigame` as a condition (`MinigameId = Quick_Brawl`, leave `PlayerId` blank). +3. Add `SetPlayerStatus` as an effect (`MinigameId = Quick_Brawl`, `Status = ACTIVE`, leave `PlayerId` blank). + +The entering player is resolved from the trigger context, so leaving `PlayerId` blank activates whoever enters the volume. + ## Score Zone Definition file: `Server/Minigames/Hill_Points.json` diff --git a/docs/minigame-definition.md b/docs/minigame-definition.md index 6ccfab0..a810abd 100644 --- a/docs/minigame-definition.md +++ b/docs/minigame-definition.md @@ -65,6 +65,7 @@ Supported values: | `FIRST_TO_SCORE` | First player or team to a target score wins. | | `LAST_ALIVE` | Last active player or team wins. | | `OBJECTIVE_COMPLETE` | Objective completion determines the winner. | +| `LOWEST_TIME` | Fastest completion time wins. Use `StartPlayerTimer` on the start pad and `StopPlayerTimer` on the finish line. Players who have not finished (score = 0) are ranked last. Times display as `M:SS.cc`. | | `CUSTOM` | Trigger logic determines the winner. | ## World Rules @@ -91,6 +92,28 @@ Supported values: | `StartLoadouts` | `LoadoutConfig[]` | No | `[]` | Named item loadouts. A player's assigned loadout (set via `SetPlayerLoadout`) takes priority over `StartItems`. | | `Rewards` | `RewardConfig[]` | No | `[]` | Rewards distributed at game end, before `on_game_end` fires. | +## HUD + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `ShowHud` | Boolean | No | `false` | Whether to show a HUD for each active player. Opt-in per game. | +| `ShowScore` | Boolean | No | `true` | Whether to show the player's current score on the HUD. | +| `ShowLives` | Boolean | No | `true` | Whether to show the player's remaining lives on the HUD. Only visible when `DefaultPlayerLives` is greater than `0`. | +| `ShowPosition` | Boolean | No | `false` | Whether to show the player's current race position (rank among active non-eliminated players). Useful for racing games using `LOWEST_SCORE`. | +| `ShowRound` | Boolean | No | `true` | Whether to show the current round out of total rounds (e.g. `2 / 3`). Only rendered when `TotalRounds` is greater than `1`. | +| `ShowTime` | Boolean | No | `true` | Whether to show the time remaining in the current round (e.g. `1:45`). Only rendered when `RoundLengthSeconds` is greater than `0`. Displays `0:00` once the round timer has expired. | +| `ShowScoreboard` | Boolean | No | `false` | Whether to show a ranked scoreboard panel on the HUD listing player names and scores in order. Capped at 10 rows or `MaxPlayers`, whichever is smaller. | + +The HUD is shown when a player becomes `ACTIVE` and removed when the game ends. It refreshes approximately once per second. Position is ranked by current score respecting `WinCondition` (`LOWEST_SCORE` ranks lower scores ahead). + +## Queue UI + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `UIQueueable` | Boolean | No | `false` | Whether this minigame appears in the global queue UI browser. Must be `true` for the game to show in `GLOBAL` mode. `SINGLE` and `LOCAL` modes work regardless of this flag — they are controlled by the `UIQueue=True` volume tag. | + +When `UIQueueable=true`, the game will appear in the `OpenMinigameQueueUI` global browser alongside the queue count and a clickable join button, provided at least one `MainArena` volume has `UIQueue=True`. + ## ItemStackConfig ```json diff --git a/docs/notes/todo.md b/docs/notes/todo.md new file mode 100644 index 0000000..f1da703 --- /dev/null +++ b/docs/notes/todo.md @@ -0,0 +1,23 @@ +## Tutorial Feedback + +## Tests + +Need to test, expand, and work on load outs, back burner for now. + +## Changes and questions + +Make it in the set spawn point you can type the XYZ for where to spawn the player. (is that likely now, but should be their own boxes) + +the double send message name. Need to change the custom one. + + + +## Bugs + + + +## Completed + +- **HUD top-right positioning** — HUD anchor now uses `setRight(8).setTop(8)` and renders in the top-right corner. +- **Scoreboard HUD panel** (`ShowScoreboard` in definition) — ranked player list with name + score shown below the standard HUD tiles. Capped at 10 rows. IDs are stable for delta refresh. +- **Queue UI system** (`OpenMinigameQueueUI` effect) — three modes: Global (all UIQueueable games), Single (one game's maps), Local (single arena popup). Join auto-attempts game start. Leave queue button shown when already queued. Volume tags: `UIQueue=True` + `UIQueueType=Global|Single|Local` on `MainArena` volumes. Definition flag: `UIQueueable=true`. diff --git a/docs/tags.md b/docs/tags.md index 2b6e16e..76e7353 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -104,6 +104,68 @@ MapId=Castle SignalOnTimerExpire=overtime_warning ``` +## Map Voting Tags + +These tags control which maps and variants appear in the voting UI shown to queued players once `MinPlayers` is reached on a game with `MapSelectionMode: VOTE`. + +| Tag | Required | Values | Used By | +|-----|----------|--------|---------| +| `MapVoteable` | Yes | `True` | Marks a `MainArena` volume's map as a selectable option in the vote UI. | +| `VariantVotable` | No | `True` | Marks a specific variant of a map as a selectable variant in the vote UI. Only meaningful when the volume also has `VariantId` set. | + +Both tags must be on a volume that also has `MinigameId`, `MapId`, and `MapRole=MainArena`. + +Map-only example (no variant choice): +```text +MinigameId=KOTH +MapId=Castle +MapRole=MainArena +MapVoteable=True +``` + +Map with voteable variants: +```text +MinigameId=KOTH +MapId=Castle +VariantId=Night +MapRole=MainArena +MapVoteable=True +VariantVotable=True +``` + +When variants are present the vote UI groups them under the map name. Voting for a variant records a `MapId:VariantId` preference (e.g. `Castle:Night`) that the queue service honours when selecting the starting map. + +If only one total voteable option exists (one map with no variants, or one map with exactly one variant), the vote UI is skipped entirely and the game starts directly. + +## Queue UI Tags + +These tags opt a `MainArena` volume into the joinable queue UI. They are read by `OpenMinigameQueueUI` when a player opens the queue menu from an NPC or interaction pad. + +| Tag | Required | Values | Used By | +|-----|----------|--------|---------| +| `UIQueue` | Yes | `True` | Marks the volume as a queue UI candidate. Must be on a `MapRole=MainArena` volume. | +| `UIQueueType` | No | `Global`, `Single`, `Local` | Controls which display modes include this arena. Defaults to `Global`. | + +`UIQueueType` meanings: + +| Value | Behaviour | +|-------|-----------| +| `Global` | Arena appears in the global game browser (all games) and in single-game menus. | +| `Single` | Arena appears only when browsing that specific minigame. | +| `Local` | Arena is intended for a nearby NPC opened with `OpenMinigameQueueUI` + explicit `MinigameId` + `MapId`. | + +Minimum queue UI candidate (add to an existing `MainArena` volume): + +```text +MinigameId=KOTH +MapId=Castle +MapRole=MainArena +UIQueue=True +UIQueueType=Global +``` + +The minigame's `UIQueueable=true` definition flag must also be set for the game to appear in the global browser. + ## Validation Notes - A volume with `MapId` but no `MinigameId` produces a warning and is ignored for map discovery. diff --git a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java index 1604e3d..cc3bbbe 100644 --- a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java +++ b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java @@ -26,6 +26,15 @@ import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameVolumeSignalService; import net.kewwbec.minigames.system.MinigameDeathRespawnSystem; import net.kewwbec.minigames.system.MinigameKillScoreSystem; +import net.kewwbec.minigames.system.MinigamePregameSystem; +import net.kewwbec.minigames.ui.MinigameHudService; +import net.kewwbec.minigames.ui.MinigameHudSystem; +import net.kewwbec.minigames.ui.MinigameMenuOpenSystem; +import net.kewwbec.minigames.ui.MinigameMenuService; +import net.kewwbec.minigames.ui.MinigameQueueUIService; +import net.kewwbec.minigames.system.MinigameQueueUISystem; +import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; +import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect; import net.kewwbec.minigames.volume.condition.CounterCondition; import net.kewwbec.minigames.volume.condition.CurrentRoundCondition; import net.kewwbec.minigames.volume.condition.MinigamePhaseCondition; @@ -72,16 +81,30 @@ import net.kewwbec.minigames.volume.effect.StartMinigameEffect; import net.kewwbec.minigames.volume.effect.StartQueuedMinigameEffect; import net.kewwbec.minigames.volume.effect.StartTimerEffect; import net.kewwbec.minigames.volume.effect.StopTimerEffect; +import net.kewwbec.minigames.volume.effect.StartPlayerTimerEffect; +import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect; import net.kewwbec.minigames.volume.effect.VoteForMapEffect; +import com.hypixel.hytale.server.core.command.system.AbstractCommand; +import com.hypixel.hytale.server.core.command.system.CommandManager; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.adapter.PacketFilter; +import net.kewwbec.minigames.command.TriggerVolumeCloneCommand; +import net.kewwbec.minigames.command.TriggerVolumeCloneGroupCommand; + import javax.annotation.Nonnull; import javax.annotation.Nullable; +import java.lang.reflect.Field; import java.nio.file.Path; +import java.util.Map; +import java.util.logging.Level; public final class MinigameCorePlugin extends JavaPlugin { private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); private static MinigameCorePlugin instance; private MinigameServices services; + private MinigameQueueUIService queueUIService; + private PacketFilter duplicatePacketFilter; private ComponentType lockMountComponentType; public MinigameCorePlugin(@Nonnull JavaPluginInit init) { @@ -95,6 +118,8 @@ public final class MinigameCorePlugin extends JavaPlugin { ServerManager.get().registerSubPacketHandlers(LockMountPacketHandler::new); getEntityStoreRegistry().registerSystem(new MinigameKillScoreSystem()); getEntityStoreRegistry().registerSystem(new MinigameDeathRespawnSystem()); + TriggerVolumeDuplicateSystem duplicateSystem = new TriggerVolumeDuplicateSystem(); + this.duplicatePacketFilter = duplicateSystem.register(); registerTriggerVolumeEffectTypes(); registerTriggerVolumeConditionTypes(); @@ -116,10 +141,25 @@ public final class MinigameCorePlugin extends JavaPlugin { runtimeService.setPlayerAdapter(adapters); runtimeService.setEntityAdapter(adapters); runtimeService.setInventoryAdapter(adapters); - this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), new MinigameQueueService(), signals); + var menuService = new MinigameMenuService(); + menuService.setPlayerAdapter(adapters); + runtimeService.setMenuService(menuService); + getEntityStoreRegistry().registerSystem(new MinigameMenuOpenSystem(menuService)); + var hudService = new MinigameHudService(); + hudService.setPlayerAdapter(adapters); + runtimeService.setHudService(hudService); + getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService)); + var queueService = new MinigameQueueService(); + runtimeService.setQueueService(queueService); + getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(runtimeService)); + this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), queueService, signals); + this.queueUIService = new MinigameQueueUIService(services); + this.queueUIService.setPlayerAdapter(adapters); + getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService)); registerTriggerVolumeAssetSources(); this.getCommandRegistry().registerCommand(new MinigameCommand(services)); + injectTriggerVolumeCloneCommand(); LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount() + " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects."); } @@ -130,6 +170,10 @@ public final class MinigameCorePlugin extends JavaPlugin { services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown"); services.signals().shutdown(); } + if (duplicatePacketFilter != null) { + PacketAdapters.deregisterInbound(duplicatePacketFilter); + duplicatePacketFilter = null; + } services = null; instance = null; } @@ -139,10 +183,43 @@ public final class MinigameCorePlugin extends JavaPlugin { return instance != null ? instance.services : null; } + @Nullable + public static MinigameQueueUIService getQueueUIService() { + return instance != null ? instance.queueUIService : null; + } + public static ComponentType getLockMountComponentType() { return instance.lockMountComponentType; } + private void injectTriggerVolumeCloneCommand() { + try { + Field commandRegistrationField = CommandManager.class.getDeclaredField("commandRegistration"); + commandRegistrationField.setAccessible(true); + @SuppressWarnings("unchecked") + Map commandRegistration = + (Map) commandRegistrationField.get(CommandManager.get()); + AbstractCommand tvCommand = commandRegistration.get("triggervolume"); + if (tvCommand == null) { + LOGGER.atWarning().log("Cannot inject /triggervolume clone: triggervolume command not registered"); + return; + } + Field hasBeenRegisteredField = AbstractCommand.class.getDeclaredField("hasBeenRegistered"); + hasBeenRegisteredField.setAccessible(true); + hasBeenRegisteredField.set(tvCommand, false); + TriggerVolumeCloneCommand cloneCommand = new TriggerVolumeCloneCommand(); + tvCommand.addSubCommand(cloneCommand); + cloneCommand.completeRegistration(); + TriggerVolumeCloneGroupCommand cloneGroupCommand = new TriggerVolumeCloneGroupCommand(); + tvCommand.addSubCommand(cloneGroupCommand); + cloneGroupCommand.completeRegistration(); + hasBeenRegisteredField.set(tvCommand, true); + LOGGER.atInfo().log("Injected /triggervolume clone and clonegroup commands"); + } catch (Exception e) { + LOGGER.at(Level.WARNING).withCause(e).log("Failed to inject /triggervolume clone command"); + } + } + private void registerTriggerVolumeEffectTypes() { var triggerVolumes = TriggerVolumesPlugin.get(); registerTriggerVolumeEffectType(triggerVolumes, StartMinigameEffect.TYPE_ID, StartMinigameEffect.class, StartMinigameEffect.CODEC); @@ -178,6 +255,9 @@ public final class MinigameCorePlugin extends JavaPlugin { registerTriggerVolumeEffectType(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID, ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect.CODEC); registerTriggerVolumeEffectType(triggerVolumes, AwardKillScoreEffect.TYPE_ID, AwardKillScoreEffect.class, AwardKillScoreEffect.CODEC); registerTriggerVolumeEffectType(triggerVolumes, PlaceBlocksEffect.TYPE_ID, PlaceBlocksEffect.class, PlaceBlocksEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID, OpenMinigameQueueUIEffect.class, OpenMinigameQueueUIEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, StartPlayerTimerEffect.TYPE_ID, StartPlayerTimerEffect.class, StartPlayerTimerEffect.CODEC); + registerTriggerVolumeEffectType(triggerVolumes, StopPlayerTimerEffect.TYPE_ID, StopPlayerTimerEffect.class, StopPlayerTimerEffect.CODEC); } private void registerTriggerVolumeEffectType( @@ -272,6 +352,9 @@ public final class MinigameCorePlugin extends JavaPlugin { registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID); registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID); registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, StartPlayerTimerEffect.TYPE_ID); + registerMinigameIdAssetField(triggerVolumes, StopPlayerTimerEffect.TYPE_ID); registerMinigameIdAssetField(triggerVolumes, MinigamePhaseCondition.TYPE_ID); registerMinigameIdAssetField(triggerVolumes, PlayerInMinigameCondition.TYPE_ID); registerMinigameIdAssetField(triggerVolumes, PlayerStatusCondition.TYPE_ID); @@ -309,7 +392,8 @@ public final class MinigameCorePlugin extends JavaPlugin { || typeId.equals(SendMinigameMessageEffect.TYPE_ID) || typeId.equals(AssignTeamEffect.TYPE_ID) || typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID) || typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.TYPE_ID) - || typeId.equals(PlaceBlocksEffect.TYPE_ID)) { + || typeId.equals(PlaceBlocksEffect.TYPE_ID) + || typeId.equals(OpenMinigameQueueUIEffect.TYPE_ID)) { count++; } } diff --git a/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java index 5ab31f2..5ca89a3 100644 --- a/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java +++ b/src/main/java/net/kewwbec/minigames/adapter/HytaleServerAdapters.java @@ -14,6 +14,7 @@ import com.hypixel.hytale.server.core.asset.type.item.config.Item; import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent; import com.hypixel.hytale.server.core.entity.ItemUtils; import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.protocol.packets.inventory.UpdatePlayerInventory; import com.hypixel.hytale.server.core.inventory.InventoryComponent; import com.hypixel.hytale.server.core.inventory.InventoryUtils; import com.hypixel.hytale.server.core.inventory.ItemStack; @@ -151,6 +152,23 @@ public final class HytaleServerAdapters implements restoreInventory(store, ref, "utility", inventory, InventoryComponent.Utility.getComponentType()); restoreInventory(store, ref, "storage", inventory, InventoryComponent.Storage.getComponentType()); restoreInventory(store, ref, "backpack", inventory, InventoryComponent.Backpack.getComponentType()); + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null && ref.isValid()) { + var armor = store.getComponent(ref, InventoryComponent.Armor.getComponentType()); + var hotbar = store.getComponent(ref, InventoryComponent.Hotbar.getComponentType()); + var utility = store.getComponent(ref, InventoryComponent.Utility.getComponentType()); + var storage = store.getComponent(ref, InventoryComponent.Storage.getComponentType()); + var tool = store.getComponent(ref, InventoryComponent.Tool.getComponentType()); + var backpack = store.getComponent(ref, InventoryComponent.Backpack.getComponentType()); + playerRef.getPacketHandler().writeNoCache(new UpdatePlayerInventory( + storage != null ? storage.getInventory().toPacket() : null, + armor != null ? armor.getInventory().toPacket() : null, + hotbar != null ? hotbar.getInventory().toPacket() : null, + utility != null ? utility.getInventory().toPacket() : null, + tool != null ? tool.getInventory().toPacket() : null, + backpack != null ? backpack.getInventory().toPacket() : null + )); + } return null; }); } diff --git a/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneCommand.java b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneCommand.java new file mode 100644 index 0000000..bd48aa6 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneCommand.java @@ -0,0 +1,115 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import java.util.HashMap; +import java.util.Map; + +public final class TriggerVolumeCloneCommand extends AbstractWorldCommand { + private final RequiredArg nameArg = this.withRequiredArg("name", "server.commands.triggervolume.clone.name.desc", ArgTypes.STRING); + private final OptionalArg newNameArg = this.withOptionalArg("newName", "server.commands.triggervolume.clone.newName.desc", ArgTypes.STRING); + private final OptionalArg updateTagsArg = this.withOptionalArg("updateTags", "server.commands.triggervolume.clone.updateTags.desc", ArgTypes.STRING); + + public TriggerVolumeCloneCommand() { + super("clone", "server.commands.triggervolume.clone.desc"); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + return; + } + + String sourceName = nameArg.get(context); + VolumeEntry source = manager.getVolume(sourceName); + if (source == null) { + context.sendMessage(Message.translation("server.commands.triggervolume.clone.notFound").param("name", sourceName)); + return; + } + + Ref playerRef = context.senderAsPlayerRef(); + if (playerRef == null || !playerRef.isValid()) { + return; + } + + String requestedNewName = newNameArg.get(context); + String newId; + if (requestedNewName != null && !requestedNewName.isBlank()) { + if (manager.hasVolume(requestedNewName)) { + context.sendMessage(Message.translation("server.commands.triggervolume.create.alreadyExists").param("name", requestedNewName)); + return; + } + newId = requestedNewName; + } else { + newId = TriggerVolumeDuplicateSystem.generateCopyId(sourceName, manager); + } + + VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(source, newId); + + TransformComponent transform = store.getComponent(playerRef, TransformComponent.getComponentType()); + if (transform != null) { + copy.setPosition(new Vector3d(transform.getPosition())); + } else { + copy.setPosition(new Vector3d(source.getPosition())); + } + + String updateTagsRaw = updateTagsArg.get(context); + if (updateTagsRaw != null && !updateTagsRaw.isBlank()) { + applyTagUpdates(copy, updateTagsRaw); + } + + manager.register(newId, copy); + manager.markSpatialDirty(); + manager.notifyViewersAdd(copy); + + PlayerRef playerRefComponent = store.getComponent(playerRef, PlayerRef.getComponentType()); + if (playerRefComponent != null) { + manager.setPlayerSelection(playerRefComponent.getUuid(), newId); + } + + context.sendMessage(Message.translation("server.commands.triggervolume.clone.success") + .param("source", sourceName) + .param("name", newId)); + } + + private static void applyTagUpdates(@Nonnull VolumeEntry entry, @Nonnull String raw) { + String stripped = raw.strip(); + if (stripped.startsWith("[") && stripped.endsWith("]")) { + stripped = stripped.substring(1, stripped.length() - 1); + } + Map tags = new HashMap<>(entry.getRawTags()); + for (String pair : stripped.split(",")) { + int eq = pair.indexOf('='); + if (eq > 0) { + String key = pair.substring(0, eq).strip(); + String value = pair.substring(eq + 1).strip(); + if (!key.isEmpty()) { + tags.put(key, value); + } + } + } + entry.setTags(tags); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneGroupCommand.java b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneGroupCommand.java new file mode 100644 index 0000000..9e6e782 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeCloneGroupCommand.java @@ -0,0 +1,211 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.builtin.triggervolumes.EntityTargetType; +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect; +import com.hypixel.hytale.builtin.triggervolumes.manager.GroupEntry; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class TriggerVolumeCloneGroupCommand extends AbstractWorldCommand { + private final RequiredArg groupNameArg = this.withRequiredArg("groupName", "server.commands.triggervolume.clonegroup.groupName.desc", ArgTypes.STRING); + private final RequiredArg newGroupNameArg = this.withRequiredArg("newGroupName", "server.commands.triggervolume.clonegroup.newGroupName.desc", ArgTypes.STRING); + private final RequiredArg findArg = this.withRequiredArg("find", "server.commands.triggervolume.clonegroup.find.desc", ArgTypes.STRING); + private final RequiredArg replaceArg = this.withRequiredArg("replace", "server.commands.triggervolume.clonegroup.replace.desc", ArgTypes.STRING); + private final OptionalArg updateTagsArg = this.withOptionalArg("updateTags", "server.commands.triggervolume.clone.updateTags.desc", ArgTypes.STRING); + + public TriggerVolumeCloneGroupCommand() { + super("clonegroup", "server.commands.triggervolume.clonegroup.desc"); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + return; + } + + String sourceGroupName = groupNameArg.get(context); + String newGroupName = newGroupNameArg.get(context); + String find = findArg.get(context); + String replace = replaceArg.get(context); + + GroupEntry sourceGroup = manager.getGroup(sourceGroupName); + if (sourceGroup == null) { + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.notFound").param("name", sourceGroupName)); + return; + } + if (manager.hasGroup(newGroupName)) { + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.alreadyExists").param("name", newGroupName)); + return; + } + + List members = manager.getGroupMembers(sourceGroupName); + if (members.isEmpty()) { + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.empty").param("name", sourceGroupName)); + return; + } + + Ref playerRef = context.senderAsPlayerRef(); + if (playerRef == null || !playerRef.isValid()) { + return; + } + + String updateTagsRaw = updateTagsArg.get(context); + Map tagOverrides = parseTagOverrides(updateTagsRaw); + + Vector3d newOrigin = new Vector3d(sourceGroup.getOrigin()); + TransformComponent transform = store.getComponent(playerRef, TransformComponent.getComponentType()); + if (transform != null) { + newOrigin.set(transform.getPosition()); + } + + Vector3d originDelta = new Vector3d(newOrigin).sub(sourceGroup.getOrigin()); + + GroupEntry newGroup = duplicateGroup(sourceGroup, newGroupName, world.getName(), newOrigin, tagOverrides); + manager.registerGroup(newGroupName, newGroup); + + String worldName = world.getName().toLowerCase(java.util.Locale.ROOT); + List clonedNames = new ArrayList<>(); + for (VolumeEntry member : members) { + String newVolumeName = renameVolume(member.getId(), find, replace); + if (manager.hasVolume(newVolumeName)) { + newVolumeName = resolveConflict(newVolumeName, manager); + } + + VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(member, newVolumeName); + + Vector3d relativeOffset = new Vector3d(member.getPosition()).sub(sourceGroup.getOrigin()); + copy.setPosition(new Vector3d(newOrigin).add(relativeOffset)); + + copy.setGroupId(newGroupName); + + if (!tagOverrides.isEmpty()) { + Map tags = new HashMap<>(copy.getRawTags()); + tags.putAll(tagOverrides); + copy.setTags(tags); + } + + manager.register(newVolumeName, copy); + newGroup.addMember(newVolumeName); + manager.notifyViewersAdd(copy); + clonedNames.add(newVolumeName); + } + + manager.markSpatialDirty(); + + PlayerRef playerRefComponent = store.getComponent(playerRef, PlayerRef.getComponentType()); + if (playerRefComponent != null && !clonedNames.isEmpty()) { + manager.setPlayerSelection(playerRefComponent.getUuid(), clonedNames.get(0)); + } + + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.success") + .param("source", sourceGroupName) + .param("name", newGroupName) + .param("count", String.valueOf(clonedNames.size()))); + } + + @Nonnull + private static String renameVolume(@Nonnull String originalName, @Nonnull String find, @Nonnull String replace) { + if (find.isEmpty() || !originalName.contains(find)) { + return originalName; + } + return originalName.replace(find, replace); + } + + @Nonnull + private static String resolveConflict(@Nonnull String base, @Nonnull TriggerVolumeManager manager) { + for (int n = 2; n < 1000; n++) { + String candidate = base + n; + if (!manager.hasVolume(candidate)) { + return candidate; + } + } + return TriggerVolumeDuplicateSystem.generateCopyId(base, manager); + } + + @Nonnull + private static GroupEntry duplicateGroup( + @Nonnull GroupEntry source, + @Nonnull String newId, + @Nonnull String worldName, + @Nonnull Vector3d newOrigin, + @Nonnull Map tagOverrides + ) { + EnumSet targetTypes = source.getTargetTypes().isEmpty() + ? EnumSet.of(EntityTargetType.PLAYER) + : EnumSet.copyOf(source.getTargetTypes()); + + GroupEntry entry = new GroupEntry( + newId, + worldName.toLowerCase(java.util.Locale.ROOT), + new Vector3d(newOrigin), + TriggerEffect.deepCopyList(source.getEffects()), + targetTypes, + source.isEnabled(), + source.getColor() + ); + entry.getConditions().addAll(TriggerCondition.deepCopyList(source.getConditions())); + entry.getRejectionEffects().addAll(TriggerEffect.deepCopyList(source.getRejectionEffects())); + entry.setConditionTiming(source.getConditionTiming()); + entry.setRejectionDelayMode(source.getRejectionDelayMode()); + + Map tags = new HashMap<>(source.getRawTags()); + tags.putAll(tagOverrides); + if (!tags.isEmpty()) { + entry.setTags(tags); + } + + return entry; + } + + @Nonnull + private static Map parseTagOverrides(@Nullable String raw) { + Map result = new HashMap<>(); + if (raw == null || raw.isBlank()) { + return result; + } + String stripped = raw.strip(); + if (stripped.startsWith("[") && stripped.endsWith("]")) { + stripped = stripped.substring(1, stripped.length() - 1); + } + for (String pair : stripped.split(",")) { + int eq = pair.indexOf('='); + if (eq > 0) { + String key = pair.substring(0, eq).strip(); + String value = pair.substring(eq + 1).strip(); + if (!key.isEmpty()) { + result.put(key, value); + } + } + } + return result; + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/Enums.java b/src/main/java/net/kewwbec/minigames/model/Enums.java index b3db916..ae46963 100644 --- a/src/main/java/net/kewwbec/minigames/model/Enums.java +++ b/src/main/java/net/kewwbec/minigames/model/Enums.java @@ -17,7 +17,7 @@ public final class Enums { } public enum WinCondition { - HIGHEST_SCORE, LOWEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, CUSTOM + HIGHEST_SCORE, LOWEST_SCORE, FIRST_TO_SCORE, LAST_ALIVE, OBJECTIVE_COMPLETE, LOWEST_TIME, CUSTOM } public enum MapSelectionMode { diff --git a/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java b/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java index 6e0f557..8fb149d 100644 --- a/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java +++ b/src/main/java/net/kewwbec/minigames/model/MinigameDefinition.java @@ -264,6 +264,62 @@ public class MinigameDefinition implements JsonAssetWithMap def.rewards = parent.rewards ) .add() + .appendInherited( + new KeyedCodec<>("ShowHud", Codec.BOOLEAN), + (def, v) -> def.showHud = v, + def -> def.showHud, + (def, parent) -> def.showHud = parent.showHud + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowScore", Codec.BOOLEAN), + (def, v) -> def.showScore = v, + def -> def.showScore, + (def, parent) -> def.showScore = parent.showScore + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowLives", Codec.BOOLEAN), + (def, v) -> def.showLives = v, + def -> def.showLives, + (def, parent) -> def.showLives = parent.showLives + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowPosition", Codec.BOOLEAN), + (def, v) -> def.showPosition = v, + def -> def.showPosition, + (def, parent) -> def.showPosition = parent.showPosition + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowRound", Codec.BOOLEAN), + (def, v) -> def.showRound = v, + def -> def.showRound, + (def, parent) -> def.showRound = parent.showRound + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowTime", Codec.BOOLEAN), + (def, v) -> def.showTime = v, + def -> def.showTime, + (def, parent) -> def.showTime = parent.showTime + ) + .add() + .appendInherited( + new KeyedCodec<>("ShowScoreboard", Codec.BOOLEAN), + (def, v) -> def.showScoreboard = v, + def -> def.showScoreboard, + (def, parent) -> def.showScoreboard = parent.showScoreboard + ) + .add() + .appendInherited( + new KeyedCodec<>("UIQueueable", Codec.BOOLEAN), + (def, v) -> def.uiQueueable = v, + def -> def.uiQueueable, + (def, parent) -> def.uiQueueable = parent.uiQueueable + ) + .add() .build(); public static final ValidatorCache VALIDATOR_CACHE = new ValidatorCache<>(new AssetKeyValidator<>(MinigameDefinition::getAssetStore)); @@ -313,6 +369,14 @@ public class MinigameDefinition implements JsonAssetWithMap(eventPayload(runtime)); + payload.put("seconds_remaining", remainingSecs); + dispatch(new MinigameEvent("on_countdown_tick", runtime.minigameId(), Map.copyOf(payload))); + if (playerAdapter != null && remainingSecs > 0) { + Message msg = Message.translation("minigames.countdown.tick").param("seconds", remainingSecs); + for (String playerId : runtime.players().keySet()) { + playerAdapter.sendMessage(playerId, msg); + } + } + } + + if (remainingMs <= 0) { + runtime.variables().remove("$countdownEnd"); + runtime.variables().remove("$countdownNext"); + advanceToStarting(runtime); + } + } + } + + private void advanceToStarting(MinigameRuntime runtime) { + runtime.phase(MinigamePhase.STARTING); + if (signalService != null) { + signalService.onPhaseChange(runtime, MinigamePhase.STARTING); + } + dispatch(new MinigameEvent("on_game_starting", runtime.minigameId(), eventPayload(runtime))); + } + + private void cancelPregame(MinigameRuntime runtime, String reason) { + LOGGER.atInfo().log("Cancelling pregame for minigame=%s reason=%s players=%d minRequired=%d", + runtime.minigameId(), reason, runtime.players().size(), runtime.definition().getMinPlayers()); + if (playerAdapter != null) { + Message msg = Message.translation("minigames.countdown.cancelled"); + for (String playerId : runtime.players().keySet()) { + playerAdapter.sendMessage(playerId, msg); + } + } + // Re-queue remaining players so they can start again + if (queueService != null) { + for (String playerId : runtime.players().keySet()) { + if (playerAdapter == null || playerAdapter.isOnline(playerId)) { + queueService.join(runtime.minigameId(), playerId); + } + } + } + var payload = new HashMap<>(eventPayload(runtime)); + payload.put("reason", reason); + dispatch(new MinigameEvent("on_game_cancelled", runtime.minigameId(), Map.copyOf(payload))); + clearRuntimeState(runtime); + runtimes.remove(runtime.runtimeId()); + } + + private int countPresentPlayers(MinigameRuntime runtime) { + if (playerAdapter == null) return runtime.players().size(); + int count = 0; + for (String playerId : runtime.players().keySet()) { + if (playerAdapter.isOnline(playerId)) count++; + } + return count; + } + @Override public void end(String minigameId, String reason) { for (MinigameRuntime runtime : matching(minigameId)) { + if (hudService != null) { + hudService.removeAllForRuntime(runtime.runtimeId()); + } runtime.phase(MinigamePhase.ENDING); if (signalService != null) { signalService.onPhaseChange(runtime, MinigamePhase.ENDING); @@ -89,6 +207,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi clearRuntimeState(runtime); runtimes.remove(runtime.runtimeId()); if (runtime.definition().isResetOnEnd()) { + if (signalService != null) { + runtime.phase(MinigamePhase.RESETTING); + signalService.onPhaseChange(runtime, MinigamePhase.RESETTING); + } dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime))); } } @@ -124,8 +246,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi LoadoutConfig loadout = resolveLoadout(runtime, playerState); if (loadout != null) { if (loadout.isPlayerCustomizable()) { - // TODO: show loadout selection UI and apply player's chosen loadout - LOGGER.atInfo().log("Player-customizable loadout UI not yet implemented: player=%s loadout=%s", playerId, loadout.getId()); + if (menuService != null) { + menuService.requestLoadoutSelection(playerId, runtime); + } } else { for (ItemStackConfig item : loadout.getItems()) { if (item != null) { @@ -142,6 +265,11 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi } playerAdapter.setGameMode(playerId, def.getRequiredGameMode()); } + LOGGER.atInfo().log("[MinigameHUD] onPlayerActivated: player=%s minigame=%s showHud=%s hudService=%s", + playerId, runtime.minigameId(), def.isShowHud(), hudService != null ? "set" : "NULL"); + if (def.isShowHud() && hudService != null) { + hudService.requestHudShow(playerId, runtime); + } } @Override @@ -240,6 +368,10 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi var donePayload = new HashMap<>(eventPayload(runtime)); donePayload.put("final_round", current); dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload))); + + if (!runtime.definition().isOvertimeEnabled() && !runtime.definition().isSuddenDeathEnabled()) { + end(runtime.minigameId(), "rounds_complete"); + } } else { int next = current + 1; runtime.roundNumber(next); @@ -314,24 +446,25 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi private void sendEndScores(MinigameRuntime runtime) { if (playerAdapter == null || runtime.players().isEmpty()) return; - boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE; - var scoreOrder = Comparator.comparingInt((PlayerMinigameState player) -> player.score()); - var ranked = runtime.players().entrySet().stream() - .sorted(Map.Entry.comparingByValue( - lowestWins ? scoreOrder : scoreOrder.reversed())) - .toList(); + boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME; + List ranked = buildRankedPlayers(runtime); var recipients = runtime.players().keySet(); for (String recipient : recipients) { playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header")); int rank = 1; - for (var entry : ranked) { - String name = playerAdapter.getDisplayName(entry.getKey()); + for (String playerId : ranked) { + var state = runtime.players().get(playerId); + if (state == null) continue; + String name = playerAdapter.getDisplayName(playerId); + String scoreText = lowestTime + ? StopPlayerTimerEffect.formatPlayerTime(state.score()) + : String.valueOf(state.score()); playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.entry") .param("rank", rank) .param("player", name) - .param("score", entry.getValue().score())); + .param("score", scoreText)); rank++; } } @@ -378,7 +511,21 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi } private List buildRankedPlayers(MinigameRuntime runtime) { - boolean lowestWins = runtime.definition().getWinCondition() == WinCondition.LOWEST_SCORE; + WinCondition wc = runtime.definition().getWinCondition(); + if (wc == WinCondition.LOWEST_TIME) { + return runtime.players().entrySet().stream() + .sorted((a, b) -> { + int sa = a.getValue().score(); + int sb = b.getValue().score(); + if (sa == 0 && sb == 0) return 0; + if (sa == 0) return 1; + if (sb == 0) return -1; + return Integer.compare(sa, sb); + }) + .map(Map.Entry::getKey) + .toList(); + } + boolean lowestWins = wc == WinCondition.LOWEST_SCORE; var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score()); return runtime.players().entrySet().stream() .sorted(Map.Entry.comparingByValue( diff --git a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java index 267cf69..2325704 100644 --- a/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java +++ b/src/main/java/net/kewwbec/minigames/runtime/MinigameRuntimeService.java @@ -20,4 +20,5 @@ public interface MinigameRuntimeService { Collection runtimes(); void shutdownAll(String reason); void dispatch(MinigameRuntime runtime, String eventId, Map payload); + void tickPregame(long nowMs); } diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java index c8d34b0..18d4779 100644 --- a/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java +++ b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java @@ -29,6 +29,10 @@ public final class MinigameMapDiscoveryService { public static final String TAG_MAP_ROLE = "MapRole"; public static final String TAG_SPAWN_ROLE = "SpawnRole"; public static final String TAG_TEAM_ID = "TeamId"; + public static final String TAG_UI_QUEUE = "UIQueue"; + public static final String TAG_UI_QUEUE_TYPE = "UIQueueType"; + public static final String TAG_MAP_VOTEABLE = "MapVoteable"; + public static final String TAG_VARIANT_VOTABLE = "VariantVotable"; public static final String ROLE_MAIN_ARENA = "MainArena"; public static final String ROLE_TEAM_SPAWN = "TeamSpawn"; diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java b/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java index 18e67b8..3d15549 100644 --- a/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java +++ b/src/main/java/net/kewwbec/minigames/service/MinigameQueueService.java @@ -86,7 +86,20 @@ public final class MinigameQueueService { .findFirst(); if (winningMapId.isPresent()) { - List voted = available.stream().filter(candidate -> candidate.mapId().equals(winningMapId.get())).toList(); + String winning = winningMapId.get(); + int colon = winning.indexOf(':'); + String winMap = colon >= 0 ? winning.substring(0, colon) : winning; + String winVariant = colon >= 0 ? winning.substring(colon + 1) : null; + // Try exact map+variant match first + if (winVariant != null) { + final String fv = winVariant; + List exact = available.stream() + .filter(c -> c.mapId().equals(winMap) && fv.equals(c.variantId())) + .toList(); + if (!exact.isEmpty()) return exact.get(random.nextInt(exact.size())); + } + // Fall back to map-only match + List voted = available.stream().filter(c -> c.mapId().equals(winMap)).toList(); if (!voted.isEmpty()) { return voted.get(random.nextInt(voted.size())); } diff --git a/src/main/java/net/kewwbec/minigames/system/MinigamePregameSystem.java b/src/main/java/net/kewwbec/minigames/system/MinigamePregameSystem.java new file mode 100644 index 0000000..bcc0deb --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/system/MinigamePregameSystem.java @@ -0,0 +1,27 @@ +package net.kewwbec.minigames.system; + +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.system.tick.TickingSystem; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; +import net.kewwbec.minigames.runtime.MinigameRuntimeService; + +public final class MinigamePregameSystem extends TickingSystem { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MinigameRuntimeService runtimeService; + + public MinigamePregameSystem(MinigameRuntimeService runtimeService) { + this.runtimeService = runtimeService; + } + + @Override + public void tick(float deltaSeconds, int tick, Store store) { + try { + runtimeService.tickPregame(System.currentTimeMillis()); + } catch (Throwable e) { + LOGGER.atWarning().log("Pregame tick failed: %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/system/MinigameQueueUISystem.java b/src/main/java/net/kewwbec/minigames/system/MinigameQueueUISystem.java new file mode 100644 index 0000000..2b6c611 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/system/MinigameQueueUISystem.java @@ -0,0 +1,27 @@ +package net.kewwbec.minigames.system; + +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.system.tick.TickingSystem; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; +import net.kewwbec.minigames.ui.MinigameQueueUIService; + +public final class MinigameQueueUISystem extends TickingSystem { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MinigameQueueUIService service; + + public MinigameQueueUISystem(MinigameQueueUIService service) { + this.service = service; + } + + @Override + public void tick(float deltaSeconds, int tick, Store store) { + try { + service.openQueued(store); + } catch (Throwable e) { + LOGGER.atWarning().log("Queue UI tick failed: %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/system/TriggerVolumeDuplicateSystem.java b/src/main/java/net/kewwbec/minigames/system/TriggerVolumeDuplicateSystem.java new file mode 100644 index 0000000..6ad84f5 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/system/TriggerVolumeDuplicateSystem.java @@ -0,0 +1,216 @@ +package net.kewwbec.minigames.system; + +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerCondition; +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.GameMode; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.protocol.Packet; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.inventory.InventoryComponent; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.adapter.PacketFilter; +import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; +import org.joml.Vector3f; + +import javax.annotation.Nonnull; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.concurrent.ThreadLocalRandom; + +public final class TriggerVolumeDuplicateSystem { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final String EDITOR_TOOL_ITEM_ID = "EditorTool_TriggerVolume"; + private static final String ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; + + @Nonnull + public PacketFilter register() { + return PacketAdapters.registerInbound((PlayerPacketFilter) this::onPickPacket); + } + + private boolean onPickPacket(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) { + if (!(packet instanceof SyncInteractionChains chains)) { + return false; + } + boolean hasPick = false; + for (SyncInteractionChain chain : chains.updates) { + if (chain.interactionType == InteractionType.Pick) { + hasPick = true; + break; + } + } + if (!hasPick) { + return false; + } + + LOGGER.atInfo().log("[TriggerVolumeDuplicate] Pick packet from player %s", playerRef.getUuid()); + + Ref entityRef = playerRef.getReference(); + if (entityRef == null || !entityRef.isValid()) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: entity ref null/invalid"); + return false; + } + + World world = entityRef.getStore().getExternalData().getWorld(); + if (world == null) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: world null"); + return false; + } + + world.execute(() -> handlePickOnWorldThread(playerRef, entityRef)); + return false; + } + + private void handlePickOnWorldThread(@Nonnull PlayerRef playerRef, @Nonnull Ref entityRef) { + if (!entityRef.isValid()) { + return; + } + Store store = entityRef.getStore(); + + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (player == null) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: no Player component"); + return; + } + + if (player.getGameMode() != GameMode.Creative) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: gameMode=%s (need Creative)", player.getGameMode()); + return; + } + + ItemStack heldItem = InventoryComponent.getItemInHand(store, entityRef); + String heldItemId = heldItem != null ? heldItem.getItemId() : null; + if (!EDITOR_TOOL_ITEM_ID.equals(heldItemId)) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: heldItem=%s (need %s)", heldItemId, EDITOR_TOOL_ITEM_ID); + return; + } + + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: TriggerVolumesPlugin not available"); + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: TriggerVolumeManager not found in store"); + return; + } + + String selectedId = manager.getPlayerSelection(playerRef.getUuid()); + if (selectedId == null || selectedId.isBlank()) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: no volume selected for player %s", playerRef.getUuid()); + return; + } + VolumeEntry source = manager.getVolume(selectedId); + if (source == null) { + LOGGER.atInfo().log("[TriggerVolumeDuplicate] SKIP: selected volume '%s' not found in manager", selectedId); + return; + } + + String newId = generateCopyId(selectedId, manager); + VolumeEntry copy = duplicateEntry(source, newId); + + TransformComponent playerTransform = store.getComponent(entityRef, TransformComponent.getComponentType()); + if (playerTransform != null) { + Vector3d volumePos = source.getPosition(); + Vector3d playerPos = playerTransform.getPosition(); + double dx = playerPos.x() - volumePos.x(); + double dz = playerPos.z() - volumePos.z(); + double len = Math.sqrt(dx * dx + dz * dz); + if (len > 0.001) { + dx /= len; + dz /= len; + } else { + dx = 1.0; + dz = 0.0; + } + copy.setPosition(new Vector3d(volumePos.x() + dx, volumePos.y(), volumePos.z() + dz)); + } + + manager.register(newId, copy); + manager.markSpatialDirty(); + manager.notifyViewersAdd(copy); + manager.setPlayerSelection(playerRef.getUuid(), newId); + + LOGGER.atInfo().log("[TriggerVolumeDuplicate] Duplicated '%s' -> '%s' at %s", selectedId, newId, copy.getPosition()); + } + + @Nonnull + public static String generateCopyId(@Nonnull String sourceId, @Nonnull TriggerVolumeManager manager) { + String base = truncate(sourceId + "_Copy", 64); + if (!manager.hasVolume(base)) { + return base; + } + for (int n = 2; n < 1000; n++) { + String candidate = truncate(base + n, 64); + if (!manager.hasVolume(candidate)) { + return candidate; + } + } + return generateRandomId(manager); + } + + @Nonnull + private static String truncate(@Nonnull String s, int max) { + return s.length() <= max ? s : s.substring(0, max); + } + + @Nonnull + private static String generateRandomId(@Nonnull TriggerVolumeManager manager) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + String id; + do { + StringBuilder sb = new StringBuilder("tv_"); + for (int i = 0; i < 6; i++) { + sb.append(ID_CHARS.charAt(rng.nextInt(ID_CHARS.length()))); + } + id = sb.toString(); + } while (manager.hasVolume(id)); + return id; + } + + @Nonnull + public static VolumeEntry duplicateEntry(@Nonnull VolumeEntry source, @Nonnull String newId) { + VolumeEntry entry = new VolumeEntry( + newId, + source.getWorldName(), + new Vector3d(source.getPosition()), + source.getShape().copy(), + TriggerEffect.deepCopyList(source.getEffects()), + EnumSet.copyOf(source.getTargetTypes()), + source.isEnabled() + ); + entry.getConditions().addAll(TriggerCondition.deepCopyList(source.getConditions())); + entry.getRejectionEffects().addAll(TriggerEffect.deepCopyList(source.getRejectionEffects())); + entry.setConditionTiming(source.getConditionTiming()); + entry.setRejectionDelayMode(source.getRejectionDelayMode()); + entry.setProjectileSource(source.getProjectileSource()); + entry.setEffectAssetRef(source.getEffectAssetRef()); + entry.setKeepLoaded(source.isKeepLoaded()); + entry.setActivationDelay(source.getActivationDelay()); + entry.setCancelDelayedEffectsOnExit(source.isCancelDelayedEffectsOnExit()); + entry.setCooldown(source.getCooldown()); + entry.setCooldownMode(source.getCooldownMode()); + Vector3f color = source.getColor(); + if (color != null) { + entry.setColor(new Vector3f(color)); + } + if (!source.getRawTags().isEmpty()) { + entry.setTags(new HashMap<>(source.getRawTags())); + } + return entry; + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameHudService.java b/src/main/java/net/kewwbec/minigames/ui/MinigameHudService.java new file mode 100644 index 0000000..580da7f --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameHudService.java @@ -0,0 +1,463 @@ +package net.kewwbec.minigames.ui; + +import au.ellie.hyui.builders.Alignment; +import au.ellie.hyui.builders.GroupBuilder; +import au.ellie.hyui.builders.HudBuilder; +import au.ellie.hyui.builders.HyUIAnchor; +import au.ellie.hyui.builders.HyUIPadding; +import au.ellie.hyui.builders.HyUIPatchStyle; +import au.ellie.hyui.builders.HyUIStyle; +import au.ellie.hyui.builders.HyUIHud; +import au.ellie.hyui.builders.LabelBuilder; +import net.kewwbec.minigames.adapter.HytaleAdapters; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nullable; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static net.kewwbec.minigames.model.Enums.WinCondition; + +public final class MinigameHudService { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final long OPEN_REQUEST_TTL_MILLIS = 10_000; + private static final int HUD_WIDTH = 192; + private static final int SB_MAX_ROWS = 10; + private static final int SB_ROW_HEIGHT = 16; + private static final int SB_SCORE_WIDTH = 44; + + private final ConcurrentHashMap activeHuds = new ConcurrentHashMap<>(); + private final ConcurrentHashMap pendingOpens = new ConcurrentHashMap<>(); + + @Nullable + private HytaleAdapters.PlayerAdapter playerAdapter; + + public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) { + this.playerAdapter = playerAdapter; + } + + public void requestHudShow(String playerId, MinigameRuntime runtime) { + UUID uuid; + try { + uuid = UUID.fromString(playerId); + } catch (IllegalArgumentException e) { + LOGGER.atWarning().log("[MinigameHUD] requestHudShow: playerId is not a UUID: '%s'", playerId); + return; + } + LOGGER.atInfo().log("[MinigameHUD] requestHudShow: queued uuid=%s minigame=%s", uuid, runtime.minigameId()); + pendingOpens.put(uuid, new PendingHudOpen(uuid, playerId, runtime, System.currentTimeMillis())); + } + + public void openQueued(Store store) { + long now = System.currentTimeMillis(); + int pendingCount = pendingOpens.size(); + if (pendingCount > 0) { + LOGGER.atInfo().log("[MinigameHUD] openQueued: processing %d pending HUD open(s)", pendingCount); + } + for (PendingHudOpen pending : pendingOpens.values()) { + if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) { + LOGGER.atWarning().log("[MinigameHUD] openQueued: TTL expired for uuid=%s, dropping", + pending.playerUuid()); + pendingOpens.remove(pending.playerUuid(), pending); + continue; + } + Ref ref = store.getExternalData().getRefFromUUID(pending.playerUuid()); + if (ref == null) { + LOGGER.atInfo().log("[MinigameHUD] openQueued: ref not found yet for uuid=%s (will retry)", + pending.playerUuid()); + continue; + } + if (!ref.isValid()) { + LOGGER.atWarning().log("[MinigameHUD] openQueued: ref invalid for uuid=%s", pending.playerUuid()); + continue; + } + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player == null) { + LOGGER.atWarning().log("[MinigameHUD] openQueued: PlayerRef component null for uuid=%s", + pending.playerUuid()); + continue; + } + if (pendingOpens.remove(pending.playerUuid(), pending)) { + LOGGER.atInfo().log("[MinigameHUD] openQueued: opening HUD for uuid=%s", pending.playerUuid()); + showHud(player, pending.playerId(), pending.runtime()); + } + } + } + + public void refreshAll() { + for (Map.Entry entry : activeHuds.entrySet()) { + String playerId = entry.getKey(); + HudEntry hudEntry = entry.getValue(); + var runtime = hudEntry.runtime(); + + if (!runtime.players().containsKey(playerId)) { + activeHuds.remove(playerId, hudEntry); + try { + hudEntry.hud().remove(); + } catch (Throwable ignored) { + } + continue; + } + + try { + updateValues(hudEntry.content(), playerId, runtime); + hudEntry.hud().refreshOrRerender(false, false); + } catch (Throwable e) { + LOGGER.atWarning().log("HUD refresh failed for player=%s: %s", playerId, e.getMessage()); + } + } + } + + public void removeAllForRuntime(String runtimeId) { + for (Map.Entry entry : activeHuds.entrySet()) { + if (runtimeId.equals(entry.getValue().runtimeId())) { + activeHuds.remove(entry.getKey(), entry.getValue()); + try { + entry.getValue().hud().remove(); + } catch (Throwable ignored) { + } + } + } + pendingOpens.values().removeIf(p -> runtimeId.equals(p.runtime().runtimeId())); + } + + private void showHud(PlayerRef player, String playerId, MinigameRuntime runtime) { + HudEntry existing = activeHuds.get(playerId); + if (existing != null) { + try { + existing.hud().remove(); + } catch (Throwable ignored) { + } + } + try { + HudContent content = buildHudContent(runtime.definition()); + updateValues(content, playerId, runtime); + HyUIHud hud = HudBuilder.hudForPlayer(player) + .addElement(content.root) + .show(player); + activeHuds.put(playerId, new HudEntry(hud, runtime.runtimeId(), runtime, content)); + } catch (Throwable e) { + LOGGER.atWarning().log("Failed to show HUD for player=%s minigame=%s: %s", + playerId, runtime.minigameId(), e.getMessage()); + } + } + + // Builds the static HUD structure once — label builders are reused across + // refreshes. IDs assigned by HyUI's counter are fixed at creation time, so + // delta updates can reference the same selectors on subsequent refreshOrRerender calls. + private static HudContent buildHudContent(MinigameDefinition def) { + boolean showScore = def.isShowScore(); + boolean showLives = def.isShowLives() && def.getDefaultPlayerLives() > 0; + boolean showPosition = def.isShowPosition(); + boolean showRound = def.isShowRound() && def.getTotalRounds() > 1; + boolean showTime = def.isShowTime() && def.getRoundLengthSeconds() > 0; + + int statCount = (showScore ? 1 : 0) + (showLives ? 1 : 0) + (showPosition ? 1 : 0) + + (showRound ? 1 : 0) + (showTime ? 1 : 0); + if (statCount == 0) + statCount = 1; + int statWidth = (HUD_WIDTH - 8) / statCount; + + GroupBuilder statsRow = GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH).setHeight(44)); + + LabelBuilder scoreLabel = null; + LabelBuilder livesLabel = null; + LabelBuilder posLabel = null; + LabelBuilder roundLabel = null; + LabelBuilder timeLabel = null; + + if (showScore) { + scoreLabel = valueLabel(statWidth); + statsRow.addChild(statTile("SCORE", scoreLabel, statWidth)); + } + if (showLives) { + livesLabel = valueLabel(statWidth); + statsRow.addChild(statTile("LIVES", livesLabel, statWidth)); + } + if (showPosition) { + posLabel = valueLabel(statWidth); + statsRow.addChild(statTile("POS", posLabel, statWidth)); + } + if (showRound) { + roundLabel = valueLabel(statWidth); + statsRow.addChild(statTile("ROUND", roundLabel, statWidth)); + } + if (showTime) { + timeLabel = valueLabel(statWidth); + statsRow.addChild(statTile("TIME", timeLabel, statWidth)); + } + + // Scoreboard rows — built once, text mutated each refresh (stable IDs). + int sbRowCount = def.isShowScoreboard() ? Math.min(def.getMaxPlayers(), SB_MAX_ROWS) : 0; + LabelBuilder[] sbNameLabels = new LabelBuilder[sbRowCount]; + LabelBuilder[] sbScoreLabels = new LabelBuilder[sbRowCount]; + + int sbSectionHeight = sbRowCount > 0 ? (4 + 16 + sbRowCount * SB_ROW_HEIGHT) : 0; + int rootHeight = 76 + sbSectionHeight; + + GroupBuilder root = GroupBuilder.group() + .withLayoutMode("Top") + .withAnchor(new HyUIAnchor().setRight(8).setTop(8).setWidth(HUD_WIDTH).setHeight(rootHeight)) + .withBackground(patch("#0c1520")) + .withOutlineColor("#1e3050") + .withOutlineSize(1.0f) + .withPadding(HyUIPadding.symmetric(4, 4)) + .addChild(kicker(def.getName(), HUD_WIDTH - 8)) + .addChild(spacer(HUD_WIDTH, 4)) + .addChild(statsRow); + + if (sbRowCount > 0) { + root.addChild(spacer(HUD_WIDTH, 4)); + root.addChild(kicker("SCOREBOARD", HUD_WIDTH - 8)); + for (int i = 0; i < sbRowCount; i++) { + LabelBuilder nameLabel = sbNameLabel(); + LabelBuilder scoreCol = sbScoreLabel(); + sbNameLabels[i] = nameLabel; + sbScoreLabels[i] = scoreCol; + root.addChild(GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH).setHeight(SB_ROW_HEIGHT)) + .addChild(nameLabel) + .addChild(scoreCol)); + } + } + + return new HudContent(root, scoreLabel, livesLabel, posLabel, roundLabel, timeLabel, + sbNameLabels, sbScoreLabels); + } + + // Mutates the label builders in place — withText() returns `this`, ID unchanged. + private void updateValues(HudContent content, String playerId, MinigameRuntime runtime) { + var playerState = runtime.players().get(playerId); + if (content.scoreLabel != null) { + int score = playerState != null ? playerState.score() : 0; + content.scoreLabel.withText(String.valueOf(score)); + } + if (content.livesLabel != null) { + int lives = playerState != null ? playerState.lives() : 0; + content.livesLabel.withText(String.valueOf(lives)); + } + if (content.posLabel != null) { + content.posLabel.withText(positionText(playerId, runtime)); + } + if (content.roundLabel != null) { + content.roundLabel.withText(roundText(runtime)); + } + if (content.timeLabel != null) { + content.timeLabel.withText(timeText(runtime)); + } + + if (content.sbNameLabels.length > 0) { + WinCondition wc = runtime.definition().getWinCondition(); + boolean lowestTime = wc == WinCondition.LOWEST_TIME; + List> ranked; + if (lowestTime) { + ranked = runtime.players().entrySet().stream() + .filter(e -> !runtime.eliminatedPlayers().contains(e.getKey())) + .sorted((a, b) -> { + int sa = a.getValue().score(); + int sb = b.getValue().score(); + if (sa == 0 && sb == 0) return 0; + if (sa == 0) return 1; + if (sb == 0) return -1; + return Integer.compare(sa, sb); + }) + .toList(); + } else { + boolean lowestWins = wc == WinCondition.LOWEST_SCORE; + var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score()); + ranked = runtime.players().entrySet().stream() + .filter(e -> !runtime.eliminatedPlayers().contains(e.getKey())) + .sorted(Map.Entry.comparingByValue( + lowestWins ? scoreOrder : scoreOrder.reversed())) + .toList(); + } + + for (int i = 0; i < content.sbNameLabels.length; i++) { + if (i < ranked.size()) { + String pid = ranked.get(i).getKey(); + String name = playerAdapter != null ? playerAdapter.getDisplayName(pid) : shortId(pid); + int score = ranked.get(i).getValue().score(); + String scoreText = lowestTime + ? StopPlayerTimerEffect.formatPlayerTime(score) + : String.valueOf(score); + content.sbNameLabels[i].withText((i + 1) + ". " + name); + content.sbScoreLabels[i].withText(scoreText); + } else { + content.sbNameLabels[i].withText(""); + content.sbScoreLabels[i].withText(""); + } + } + } + } + + private static String shortId(String playerId) { + return playerId.length() > 8 ? playerId.substring(0, 8) : playerId; + } + + private static LabelBuilder valueLabel(int width) { + return LabelBuilder.label() + .withText("") + .withAnchor(new HyUIAnchor().setWidth(width - 8).setHeight(22)) + .withStyle(new HyUIStyle().setFontSize(18f).setTextColor("#ffffff") + .setRenderBold(true).setWrap(false) + .setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(10f)); + } + + private static GroupBuilder statTile(String label, LabelBuilder valueLabel, int width) { + return GroupBuilder.group() + .withLayoutMode("Top") + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(44)) + .withPadding(HyUIPadding.symmetric(2, 4)) + .addChild(LabelBuilder.label() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(width - 8).setHeight(16)) + .withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#7cc7ff") + .setRenderBold(true).setRenderUppercase(true).setWrap(false) + .setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f))) + .addChild(valueLabel); + } + + private static LabelBuilder sbNameLabel() { + return LabelBuilder.label() + .withText("") + .withAnchor(new HyUIAnchor().setWidth(HUD_WIDTH - 8 - SB_SCORE_WIDTH).setHeight(14)) + .withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#c8ddf0") + .setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f) + .setHorizontalAlignment(Alignment.Start)); + } + + private static LabelBuilder sbScoreLabel() { + return LabelBuilder.label() + .withText("") + .withAnchor(new HyUIAnchor().setWidth(SB_SCORE_WIDTH).setHeight(14)) + .withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#ffffff") + .setRenderBold(true).setWrap(false).setShrinkTextToFit(true) + .setMinShrinkTextToFitFontSize(7f) + .setHorizontalAlignment(Alignment.End)); + } + + private static LabelBuilder kicker(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(16)) + .withStyle(new HyUIStyle().setFontSize(9f).setTextColor("#4a7aa8") + .setRenderBold(true).setRenderUppercase(true).setWrap(false) + .setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(7f)); + } + + private static LabelBuilder spacer(int width, int height) { + return LabelBuilder.label() + .withText(" ") + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .withStyle(new HyUIStyle().setFontSize(1f).setTextColor("#0c1520")); + } + + private static HyUIPatchStyle patch(String color) { + return new HyUIPatchStyle().setColor(color); + } + + private static String roundText(MinigameRuntime runtime) { + int total = runtime.definition().getTotalRounds(); + return runtime.roundNumber() + " / " + total; + } + + private static String timeText(MinigameRuntime runtime) { + Long expiry = runtime.timers().get("$round"); + long remainingMs = expiry != null ? expiry - System.currentTimeMillis() : 0; + if (remainingMs < 0) + remainingMs = 0; + long totalSeconds = remainingMs / 1000; + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + return minutes + ":" + String.format("%02d", seconds); + } + + private static String positionText(String playerId, MinigameRuntime runtime) { + WinCondition wc = runtime.definition().getWinCondition(); + List ranked; + if (wc == WinCondition.LOWEST_TIME) { + ranked = runtime.players().entrySet().stream() + .filter(e -> !runtime.eliminatedPlayers().contains(e.getKey())) + .sorted((a, b) -> { + int sa = a.getValue().score(); + int sb = b.getValue().score(); + if (sa == 0 && sb == 0) return 0; + if (sa == 0) return 1; + if (sb == 0) return -1; + return Integer.compare(sa, sb); + }) + .map(Map.Entry::getKey) + .toList(); + } else { + boolean lowestWins = wc == WinCondition.LOWEST_SCORE; + var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score()); + ranked = runtime.players().entrySet().stream() + .filter(e -> !runtime.eliminatedPlayers().contains(e.getKey())) + .sorted(Map.Entry.comparingByValue( + lowestWins ? scoreOrder : scoreOrder.reversed())) + .map(Map.Entry::getKey) + .toList(); + } + + int activeCount = ranked.size(); + if (activeCount == 0) + return "-"; + int pos = ranked.indexOf(playerId); + return (pos >= 0 ? pos + 1 : activeCount + 1) + " / " + activeCount; + } + + private static class HudContent { + final GroupBuilder root; + @Nullable + final LabelBuilder scoreLabel; + @Nullable + final LabelBuilder livesLabel; + @Nullable + final LabelBuilder posLabel; + @Nullable + final LabelBuilder roundLabel; + @Nullable + final LabelBuilder timeLabel; + final LabelBuilder[] sbNameLabels; + final LabelBuilder[] sbScoreLabels; + + HudContent(GroupBuilder root, + @Nullable LabelBuilder scoreLabel, + @Nullable LabelBuilder livesLabel, + @Nullable LabelBuilder posLabel, + @Nullable LabelBuilder roundLabel, + @Nullable LabelBuilder timeLabel, + LabelBuilder[] sbNameLabels, + LabelBuilder[] sbScoreLabels) { + this.root = root; + this.scoreLabel = scoreLabel; + this.livesLabel = livesLabel; + this.posLabel = posLabel; + this.roundLabel = roundLabel; + this.timeLabel = timeLabel; + this.sbNameLabels = sbNameLabels; + this.sbScoreLabels = sbScoreLabels; + } + } + + private record HudEntry(HyUIHud hud, String runtimeId, MinigameRuntime runtime, HudContent content) { + } + + private record PendingHudOpen(UUID playerUuid, String playerId, MinigameRuntime runtime, long createdAtMillis) { + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameHudSystem.java b/src/main/java/net/kewwbec/minigames/ui/MinigameHudSystem.java new file mode 100644 index 0000000..f652116 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameHudSystem.java @@ -0,0 +1,31 @@ +package net.kewwbec.minigames.ui; + +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.system.tick.TickingSystem; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; + +public final class MinigameHudSystem extends TickingSystem { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final int REFRESH_EVERY_TICKS = 20; + + private final MinigameHudService hudService; + private int tickCount = 0; + + public MinigameHudSystem(MinigameHudService hudService) { + this.hudService = hudService; + } + + @Override + public void tick(float deltaSeconds, int tick, Store store) { + try { + hudService.openQueued(store); + if (tickCount++ % REFRESH_EVERY_TICKS == 0) { + hudService.refreshAll(); + } + } catch (Throwable e) { + LOGGER.atWarning().log("HUD system tick failed: %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameMenuOpenSystem.java b/src/main/java/net/kewwbec/minigames/ui/MinigameMenuOpenSystem.java new file mode 100644 index 0000000..9b5aa86 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameMenuOpenSystem.java @@ -0,0 +1,26 @@ +package net.kewwbec.minigames.ui; + +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.system.tick.TickingSystem; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; + +public final class MinigameMenuOpenSystem extends TickingSystem { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MinigameMenuService menuService; + + public MinigameMenuOpenSystem(MinigameMenuService menuService) { + this.menuService = menuService; + } + + @Override + public void tick(float deltaSeconds, int tick, Store store) { + try { + menuService.openQueued(store); + } catch (Throwable e) { + LOGGER.atWarning().log("Minigame menu open tick failed: %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameMenuService.java b/src/main/java/net/kewwbec/minigames/ui/MinigameMenuService.java new file mode 100644 index 0000000..2d6314a --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameMenuService.java @@ -0,0 +1,209 @@ +package net.kewwbec.minigames.ui; + +import au.ellie.hyui.builders.ButtonBuilder; +import au.ellie.hyui.builders.ContainerBuilder; +import au.ellie.hyui.builders.GroupBuilder; +import au.ellie.hyui.builders.HyUIAnchor; +import au.ellie.hyui.builders.HyUIPadding; +import au.ellie.hyui.builders.HyUIPatchStyle; +import au.ellie.hyui.builders.HyUIStyle; +import au.ellie.hyui.builders.LabelBuilder; +import au.ellie.hyui.builders.PageBuilder; +import au.ellie.hyui.builders.UIElementBuilder; +import net.kewwbec.minigames.adapter.HytaleAdapters; +import net.kewwbec.minigames.model.ItemStackConfig; +import net.kewwbec.minigames.model.LoadoutConfig; +import net.kewwbec.minigames.runtime.MinigameRuntime; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nullable; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class MinigameMenuService { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final long OPEN_REQUEST_TTL_MILLIS = 10_000; + + private final ConcurrentHashMap pendingOpens = new ConcurrentHashMap<>(); + @Nullable + private HytaleAdapters.PlayerAdapter playerAdapter; + + public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) { + this.playerAdapter = playerAdapter; + } + + public void requestLoadoutSelection(String playerId, MinigameRuntime runtime) { + UUID uuid; + try { + uuid = UUID.fromString(playerId); + } catch (IllegalArgumentException ignored) { + return; + } + pendingOpens.put(uuid, new PendingLoadoutOpen(uuid, playerId, runtime, System.currentTimeMillis())); + } + + public void openQueued(Store store) { + long now = System.currentTimeMillis(); + for (PendingLoadoutOpen pending : pendingOpens.values()) { + if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) { + pendingOpens.remove(pending.playerUuid(), pending); + continue; + } + Ref ref = store.getExternalData().getRefFromUUID(pending.playerUuid()); + if (ref == null || !ref.isValid()) { + continue; + } + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player == null) { + continue; + } + if (pendingOpens.remove(pending.playerUuid(), pending)) { + openLoadoutSelection(player, pending.playerId(), pending.runtime(), store); + } + } + } + + private void openLoadoutSelection(PlayerRef player, String playerId, MinigameRuntime runtime, Store store) { + LoadoutConfig[] loadouts = runtime.definition().getStartLoadouts(); + if (loadouts == null || loadouts.length == 0) { + return; + } + + try { + ContainerBuilder container = compactDecoratedContainer("Choose Loadout", 560, Math.min(80 + loadouts.length * 52, 480)) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(loadoutHeader(runtime.definition().getName())) + .addContentChild(loadoutList(playerId, runtime, loadouts)); + + PageBuilder.pageForPlayer(player) + .addElement(container) + .open(player, store); + } catch (Throwable e) { + LOGGER.atWarning().log("Failed to open loadout selection for player=%s minigame=%s: %s", + playerId, runtime.minigameId(), e.getMessage()); + } + } + + private static GroupBuilder loadoutHeader(String gameName) { + return GroupBuilder.group() + .withLayoutMode("Top") + .withAnchor(new HyUIAnchor().setWidth(510).setHeight(68)) + .withPadding(HyUIPadding.symmetric(8, 6)) + .addChild(kicker(gameName, 488)) + .addChild(titleLabel("Select Your Loadout", 488)) + .addChild(caption("Pick a loadout. It takes effect when you become active.", 488)); + } + + private GroupBuilder loadoutList(String playerId, MinigameRuntime runtime, LoadoutConfig[] loadouts) { + int height = loadouts.length * 52; + GroupBuilder group = GroupBuilder.group() + .withLayoutMode("Top") + .withAnchor(new HyUIAnchor().setWidth(510).setHeight(height)); + + for (LoadoutConfig loadout : loadouts) { + if (loadout == null) continue; + + String display = loadout.getDisplayName() != null && !loadout.getDisplayName().isBlank() + ? loadout.getDisplayName() + : loadout.getId(); + + int itemCount = loadout.getItems() != null ? loadout.getItems().length : 0; + String subtitle = itemCount == 1 ? "1 item" : itemCount + " items"; + + group.addChild(inset( + actionButton(display, subtitle, 462, () -> applyLoadout(playerId, runtime, loadout)), + 510, 462, 42 + )); + group.addChild(spacer(510, 8)); + } + + return group; + } + + private void applyLoadout(String playerId, MinigameRuntime runtime, LoadoutConfig loadout) { + var playerState = runtime.players().get(playerId); + if (playerState == null) { + return; + } + playerState.loadoutId(loadout.getId()); + if (playerAdapter != null && loadout.getItems() != null) { + for (ItemStackConfig item : loadout.getItems()) { + if (item != null) { + playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount()); + } + } + } + } + + // --- UI helpers --- + + private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) { + return ContainerBuilder.decoratedContainer() + .withTitleText(title) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .editElementAfter((commands, selector) -> commands.setObject( + selector + " #Content.Anchor", + new HyUIAnchor().setTop(0).toHytaleAnchor() + )); + } + + private static ButtonBuilder actionButton(String text, String tooltip, int width, Runnable action) { + return ButtonBuilder.secondaryTextButton() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(38)) + .withTooltipText(tooltip) + .onClick(action); + } + + private static GroupBuilder inset(UIElementBuilder child, int outerWidth, int childWidth, int height) { + return GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height)) + .addChild(spacer(22, height)) + .addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height))); + } + + private static LabelBuilder spacer(int width, int height) { + return LabelBuilder.label() + .withText(" ") + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .withStyle(new HyUIStyle().setFontSize(1f).setTextColor("#101722")); + } + + private static LabelBuilder kicker(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(18)) + .withStyle(new HyUIStyle().setFontSize(11f).setTextColor("#7cc7ff").setRenderBold(true) + .setRenderUppercase(true).setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8f)); + } + + private static LabelBuilder titleLabel(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(32)) + .withStyle(new HyUIStyle().setFontSize(22f).setTextColor("#ffffff").setRenderBold(true) + .setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(12f)); + } + + private static LabelBuilder caption(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(24)) + .withStyle(new HyUIStyle().setFontSize(11f).setTextColor("#9eb4cf").setWrap(true) + .setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8f)); + } + + private static HyUIPatchStyle patch(String color) { + return new HyUIPatchStyle().setColor(color); + } + + private record PendingLoadoutOpen(UUID playerUuid, String playerId, MinigameRuntime runtime, long createdAtMillis) {} +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameQueueUIService.java b/src/main/java/net/kewwbec/minigames/ui/MinigameQueueUIService.java new file mode 100644 index 0000000..0ac1733 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameQueueUIService.java @@ -0,0 +1,680 @@ +package net.kewwbec.minigames.ui; + +import au.ellie.hyui.builders.ButtonBuilder; +import au.ellie.hyui.builders.ContainerBuilder; +import au.ellie.hyui.builders.GroupBuilder; +import au.ellie.hyui.builders.HyUIAnchor; +import au.ellie.hyui.builders.HyUIPadding; +import au.ellie.hyui.builders.HyUIPatchStyle; +import au.ellie.hyui.builders.HyUIStyle; +import au.ellie.hyui.builders.LabelBuilder; +import au.ellie.hyui.builders.PageBuilder; +import au.ellie.hyui.builders.UIElementBuilder; +import net.kewwbec.minigames.adapter.HytaleAdapters; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.service.MinigameMapDiscoveryService; +import net.kewwbec.minigames.service.MinigameServices; + +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static net.kewwbec.minigames.model.Enums.MapSelectionMode; + +public final class MinigameQueueUIService { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final long TTL_MS = 10_000; + private static final int SECTION_INSET = 22; + + private final MinigameServices services; + @Nullable + private HytaleAdapters.PlayerAdapter playerAdapter; + private final ConcurrentHashMap pendingOpens = new ConcurrentHashMap<>(); + + public enum UIQueueMode { GLOBAL, SINGLE, LOCAL, VOTE } + + public MinigameQueueUIService(MinigameServices services) { + this.services = services; + } + + public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) { + this.playerAdapter = playerAdapter; + } + + public void requestOpen(PlayerRef player, UIQueueMode mode, String minigameId, String mapId) { + pendingOpens.put(player.getUuid(), + new PendingOpen(player.getUuid(), mode, + minigameId != null ? minigameId : "", + mapId != null ? mapId : "", + System.currentTimeMillis())); + } + + public void openQueued(Store store) { + long now = System.currentTimeMillis(); + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) return; + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) return; + + for (PendingOpen pending : pendingOpens.values()) { + if (now - pending.createdAtMillis() > TTL_MS) { + pendingOpens.remove(pending.uuid(), pending); + continue; + } + Ref ref = store.getExternalData().getRefFromUUID(pending.uuid()); + if (ref == null || !ref.isValid()) continue; + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player == null) continue; + if (pendingOpens.remove(pending.uuid(), pending)) { + try { + switch (pending.mode()) { + case GLOBAL -> openGlobal(player, store, manager); + case SINGLE -> openSingle(player, pending.minigameId(), false, store, manager); + case LOCAL -> openLocal(player, pending.minigameId(), pending.mapId(), store, manager); + case VOTE -> openVote(player, pending.minigameId(), store, manager); + } + } catch (Throwable e) { + LOGGER.atWarning().log("Queue UI open failed player=%s mode=%s: %s: %s", + player.getUuid(), pending.mode(), e.getClass().getSimpleName(), e.getMessage()); + } + } + } + } + + // ── GLOBAL ─────────────────────────────────────────────────────────────── + + private void openGlobal(PlayerRef player, Store store, TriggerVolumeManager manager) { + String playerId = player.getUuid().toString(); + sendDebugInfo(player, manager); + + List gameIds = scanArenas(manager, null, null).stream() + .map(ArenaEntry::minigameId) + .distinct() + .filter(id -> services.minigames().definition(id) + .map(MinigameDefinition::isUIQueueable) + .orElse(false)) + .sorted() + .toList(); + + // Left: game list + GroupBuilder gameList = surface("GAME QUEUE", "Select a game to join.", 414, 368); + if (gameIds.isEmpty()) { + gameList.addChild(inset(caption("No games are currently available.", 352), 414, 352, 24)); + } else { + for (String gameId : gameIds) { + var defOpt = services.minigames().definition(gameId); + if (defOpt.isEmpty()) continue; + var def = defOpt.get(); + int queued = services.queue().players(gameId).size(); + gameList.addChild(spacer(414, 6)); + String label = def.getName() + " | " + queued + " queued"; + String tooltip = def.getDescription().isBlank() ? def.getName() : def.getDescription(); + gameList.addChild(inset( + ButtonBuilder.secondaryTextButton() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(352).setHeight(46)) + .withTooltipText(tooltip) + .onClick((ignored, ui) -> openSingle(player, gameId, true, store, manager)), + 414, 352, 46)); + } + } + + // Right: queue status + String activeGameId = findActiveQueueFor(playerId); + String statusSubtitle = activeGameId == null ? "Not in a queue." : "Currently queued."; + GroupBuilder statusPanel = surface("YOUR QUEUE", statusSubtitle, 384, 368); + if (activeGameId != null) { + var defOpt = services.minigames().definition(activeGameId); + String gameName = defOpt.map(MinigameDefinition::getName).orElse(activeGameId); + int queued = services.queue().players(activeGameId).size(); + int needed = defOpt.map(d -> Math.max(0, d.getMinPlayers() - queued)).orElse(0); + String status = needed > 0 ? "Need " + needed + " more to start" : "Enough players — starting soon!"; + statusPanel.addChild(spacer(384, 4)); + statusPanel.addChild(inset(kicker(gameName, 322), 384, 322, 18)); + statusPanel.addChild(inset(bodyLabel(queued + " queued • " + status, 322), 384, 322, 24)); + statusPanel.addChild(spacer(384, 10)); + final String finalActiveGameId = activeGameId; + statusPanel.addChild(inset( + dangerButton("Leave Queue", "Leave the " + gameName + " queue", 322, () -> { + leaveQueue(player, finalActiveGameId); + openGlobal(player, store, manager); + }), + 384, 322, 38)); + } + + ContainerBuilder container = compactDecoratedContainer("Game Queue", 860, 480) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(832).setHeight(398)) + .addChild(gameList) + .addChild(spacer(16, 398)) + .addChild(statusPanel)); + + PageBuilder.pageForPlayer(player).addElement(container).open(player, store); + } + + // ── SINGLE ─────────────────────────────────────────────────────────────── + + private void openSingle(PlayerRef player, String minigameId, boolean showBack, + Store store, TriggerVolumeManager manager) { + var defOpt = services.minigames().definition(minigameId); + if (defOpt.isEmpty()) return; + var def = defOpt.get(); + + String playerId = player.getUuid().toString(); + int totalQueued = services.queue().players(minigameId).size(); + boolean inQueue = services.queue().players(minigameId).contains(playerId); + + List mapIds = scanArenas(manager, minigameId, null).stream() + .map(ArenaEntry::mapId) + .filter(m -> !m.isBlank()) + .distinct() + .sorted() + .toList(); + + // Calculate content height: header rows + back button + map buttons + join-any + leave + int rows = mapIds.isEmpty() ? 1 : mapIds.size(); + int contentHeight = 66 // surface header (kicker + caption + spacers) + + (showBack ? 50 : 0) // back button + + rows * 53 // map buttons + spacers + + (mapIds.size() > 1 ? 50 : 0) // join-any button + + (inQueue ? 54 : 0); // leave queue button + contentHeight = Math.max(160, Math.min(420, contentHeight)); + + GroupBuilder mapPanel = surface(def.getName(), + def.getMinPlayers() + " - " + def.getMaxPlayers() + " players • " + totalQueued + " queued", + 654, contentHeight); + + if (showBack) { + mapPanel.addChild(inset( + actionButton("← All Games", "Back to game browser", 592, + () -> openGlobal(player, store, manager)), + 654, 592, 38)); + mapPanel.addChild(spacer(654, 6)); + } + + if (mapIds.isEmpty()) { + mapPanel.addChild(inset(caption("No maps found for this game.", 592), 654, 592, 24)); + } else { + for (String mapId : mapIds) { + String label = prettify(mapId) + " | " + totalQueued + " queued"; + mapPanel.addChild(spacer(654, 6)); + mapPanel.addChild(inset( + ButtonBuilder.secondaryTextButton() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(592).setHeight(46)) + .withTooltipText("Join queue — vote for " + prettify(mapId)) + .onClick((ignored, ui) -> { + joinQueue(player, minigameId, mapId, store); + openSingle(player, minigameId, showBack, store, manager); + }), + 654, 592, 46)); + } + } + + if (mapIds.size() > 1) { + mapPanel.addChild(spacer(654, 6)); + mapPanel.addChild(inset( + actionButton("Join Any Map | " + totalQueued + " queued", "Join without a map preference", 592, + () -> { + joinQueue(player, minigameId, "", store); + openSingle(player, minigameId, showBack, store, manager); + }), + 654, 592, 38)); + } + + if (inQueue) { + mapPanel.addChild(spacer(654, 10)); + mapPanel.addChild(inset( + dangerButton("Leave Queue", "Leave the " + def.getName() + " queue", 592, () -> { + leaveQueue(player, minigameId); + openSingle(player, minigameId, showBack, store, manager); + }), + 654, 592, 38)); + } + + int containerHeight = Math.max(280, Math.min(500, contentHeight + 82)); + ContainerBuilder container = compactDecoratedContainer(def.getName(), 700, containerHeight) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(mapPanel); + + PageBuilder.pageForPlayer(player).addElement(container).open(player, store); + } + + // ── LOCAL ───────────────────────────────────────────────────────────────── + + private void openLocal(PlayerRef player, String minigameId, String mapId, + Store store, TriggerVolumeManager manager) { + var defOpt = services.minigames().definition(minigameId); + if (defOpt.isEmpty()) return; + var def = defOpt.get(); + + String playerId = player.getUuid().toString(); + int queued = services.queue().players(minigameId).size(); + boolean inQueue = services.queue().players(minigameId).contains(playerId); + int needed = Math.max(0, def.getMinPlayers() - queued); + String mapDisplay = mapId.isBlank() ? "Arena" : prettify(mapId); + String statusText = needed > 0 ? "Need " + needed + " more" : "Ready to start!"; + + GroupBuilder arenaPanel = surface(def.getName(), mapDisplay, 554, 148); + arenaPanel.addChild(inset( + bodyLabel(def.getMinPlayers() + " - " + def.getMaxPlayers() + " players • " + + queued + " queued • " + statusText, 492), + 554, 492, 24)); + arenaPanel.addChild(spacer(554, 10)); + + GroupBuilder buttons = GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(554).setHeight(38)) + .addChild(spacer(SECTION_INSET, 38)); + + if (!inQueue) { + buttons.addChild(ButtonBuilder.secondaryTextButton() + .withText("Join Queue") + .withAnchor(new HyUIAnchor().setWidth(238).setHeight(38)) + .onClick((ignored, ui) -> { + joinQueue(player, minigameId, mapId, store); + openLocal(player, minigameId, mapId, store, manager); + })); + } else { + buttons.addChild(ButtonBuilder.cancelTextButton() + .withText("Leave Queue") + .withAnchor(new HyUIAnchor().setWidth(238).setHeight(38)) + .onClick((ignored, ui) -> { + leaveQueue(player, minigameId); + openLocal(player, minigameId, mapId, store, manager); + })); + } + arenaPanel.addChild(buttons); + + ContainerBuilder container = compactDecoratedContainer(def.getName(), 600, 240) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(arenaPanel); + + PageBuilder.pageForPlayer(player).addElement(container).open(player, store); + } + + // ── VOTE ────────────────────────────────────────────────────────────────── + + private void openVote(PlayerRef player, String minigameId, Store store, TriggerVolumeManager manager) { + var defOpt = services.minigames().definition(minigameId); + if (defOpt.isEmpty()) return; + var def = defOpt.get(); + + String playerId = player.getUuid().toString(); + int totalQueued = services.queue().players(minigameId).size(); + Map allVotes = services.queue().votes(minigameId); + String currentVote = allVotes.get(playerId); + + Map voteCounts = allVotes.values().stream() + .collect(Collectors.groupingBy(v -> v, Collectors.counting())); + + List voteableMaps = scanVoteableMaps(manager, minigameId); + + int contentHeight = 66 + + voteableMaps.stream().mapToInt(m -> m.variants().isEmpty() ? 52 : 22 + m.variants().size() * 44).sum() + + 10; + contentHeight = Math.max(160, Math.min(520, contentHeight)); + + String subtitle = totalQueued + " / " + def.getMaxPlayers() + " players — vote for a map!"; + GroupBuilder votePanel = surface(def.getName(), subtitle, 572, contentHeight); + + if (voteableMaps.isEmpty()) { + votePanel.addChild(inset(caption("No voteable maps found. Add MapVoteable=True to MainArena volumes.", 510), 572, 510, 24)); + } else { + for (VoteMapEntry map : voteableMaps) { + long mapVotes = voteCounts.getOrDefault(map.mapId(), 0L) + + map.variants().stream().mapToLong(v -> voteCounts.getOrDefault(map.mapId() + ":" + v, 0L)).sum(); + + votePanel.addChild(spacer(572, 6)); + if (map.variants().isEmpty()) { + boolean voted = map.mapId().equals(currentVote); + String label = (voted ? "► " : "") + prettify(map.mapId()) + " | " + mapVotes + " votes"; + votePanel.addChild(inset( + ButtonBuilder.secondaryTextButton() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(510).setHeight(46)) + .withTooltipText("Vote for " + prettify(map.mapId())) + .onClick((ignored, ui) -> { + services.queue().vote(minigameId, playerId, map.mapId()); + openVote(player, minigameId, store, manager); + }), + 572, 510, 46)); + } else { + votePanel.addChild(inset(kicker(prettify(map.mapId()) + " | " + mapVotes + " votes", 510), 572, 510, 18)); + for (String variant : map.variants()) { + String voteKey = map.mapId() + ":" + variant; + long variantVotes = voteCounts.getOrDefault(voteKey, 0L); + boolean voted = voteKey.equals(currentVote); + String label = (voted ? "► " : "") + prettify(variant) + " | " + variantVotes + " votes"; + votePanel.addChild(inset( + ButtonBuilder.secondaryTextButton() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(510).setHeight(38)) + .withTooltipText("Vote for " + prettify(map.mapId()) + " — " + prettify(variant)) + .onClick((ignored, ui) -> { + services.queue().vote(minigameId, playerId, voteKey); + openVote(player, minigameId, store, manager); + }), + 572, 510, 38)); + votePanel.addChild(spacer(572, 4)); + } + } + } + } + + int containerHeight = Math.max(240, Math.min(560, contentHeight + 82)); + ContainerBuilder container = compactDecoratedContainer(def.getName() + " — Vote", 640, containerHeight) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(votePanel); + + PageBuilder.pageForPlayer(player).addElement(container).open(player, store); + } + + private List scanVoteableMaps(TriggerVolumeManager manager, String minigameId) { + Map> mapVariants = new LinkedHashMap<>(); + manager.getVolumes().stream() + .filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) + .filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) + .filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_VOTEABLE))) + .filter(v -> !v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "").isBlank()) + .forEach(v -> { + String mapId = v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ID); + String variantId = v.getRawTags().get(MinigameMapDiscoveryService.TAG_VARIANT_ID); + boolean variantVotable = "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_VARIANT_VOTABLE)); + List variants = mapVariants.computeIfAbsent(mapId, k -> new ArrayList<>()); + if (variantId != null && !variantId.isBlank() && variantVotable && !variants.contains(variantId)) { + variants.add(variantId); + } + }); + return mapVariants.entrySet().stream() + .map(e -> new VoteMapEntry(e.getKey(), List.copyOf(e.getValue()))) + .toList(); + } + + private record VoteMapEntry(String mapId, List variants) {} + + private boolean hasMultipleVoteChoices(Store store, String minigameId) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) return false; + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) return false; + List maps = scanVoteableMaps(manager, minigameId); + int total = maps.stream().mapToInt(m -> m.variants().isEmpty() ? 1 : m.variants().size()).sum(); + return total > 1; + } + + // ── ACTIONS ─────────────────────────────────────────────────────────────── + + private void joinQueue(PlayerRef player, String minigameId, String mapId, Store store) { + String playerId = player.getUuid().toString(); + services.queue().join(minigameId, playerId); + if (!mapId.isBlank()) { + services.queue().vote(minigameId, playerId, mapId); + } + services.minigames().definition(minigameId).ifPresent(def -> { + Set queued = services.queue().players(minigameId); + if (services.runtime().runtimes(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) { + if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) { + // Show vote UI — to all players when threshold is first hit, otherwise just the new joiner + Set targets = queued.size() == def.getMinPlayers() + ? queued.stream().map(id -> { try { return UUID.fromString(id); } catch (IllegalArgumentException e) { return null; } }).filter(u -> u != null).collect(Collectors.toSet()) + : Set.of(player.getUuid()); + long now = System.currentTimeMillis(); + targets.forEach(uuid -> pendingOpens.put(uuid, new PendingOpen(uuid, UIQueueMode.VOTE, minigameId, "", now))); + } else { + var discovered = services.maps().discover(store, minigameId); + services.queue().startQueued(def, discovered.candidates(), services.runtime()); + } + } + }); + } + + private void leaveQueue(PlayerRef player, String minigameId) { + services.queue().leave(minigameId, player.getUuid().toString()); + } + + // ── HELPERS ─────────────────────────────────────────────────────────────── + + @Nullable + private String findActiveQueueFor(String playerId) { + return services.minigames().definitions().stream() + .filter(def -> services.queue().players(def.getId()).contains(playerId)) + .map(MinigameDefinition::getId) + .findFirst() + .orElse(null); + } + + private List scanArenas(TriggerVolumeManager manager, + @Nullable String filterMinigameId, + @Nullable String filterMapId) { + return manager.getVolumes().stream() + .filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_UI_QUEUE)) + && MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals( + v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)) + && v.getRawTags().containsKey(MinigameMapDiscoveryService.TAG_MINIGAME_ID)) + .filter(v -> filterMinigameId == null + || filterMinigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) + .filter(v -> filterMapId == null + || filterMapId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ID))) + .map(v -> new ArenaEntry( + v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), + v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, ""), + v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_UI_QUEUE_TYPE, "Global"))) + .distinct() + .toList(); + } + + private static String prettify(String id) { + if (id == null || id.isBlank()) return "Unknown"; + return Arrays.stream(id.replace('_', ' ').split("\\s+")) + .map(w -> w.isEmpty() ? w : Character.toUpperCase(w.charAt(0)) + w.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); + } + + // ── DEBUG ───────────────────────────────────────────────────────────────── + + private void sendDebugInfo(PlayerRef player, TriggerVolumeManager manager) { + if (playerAdapter == null) return; + String playerId = player.getUuid().toString(); + + for (MinigameDefinition def : services.minigames().definitions()) { + if (!def.isDebug()) continue; + String p = "[QueueUI:" + def.getId() + "] "; + + debug(playerId, p + def.getName() + " — debug report"); + debug(playerId, p + " UIQueueable: " + def.isUIQueueable() + + (def.isUIQueueable() ? " (visible in global browser if volumes are tagged)" : " — add UIQueueable:true to show in global browser")); + + List ownedVolumes = manager.getVolumes().stream() + .filter(v -> def.getId().equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) + .toList(); + + if (ownedVolumes.isEmpty()) { + debug(playerId, p + " volumes: none found with MinigameId=" + def.getId()); + continue; + } + + List queueVolumes = ownedVolumes.stream() + .filter(v -> "True".equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_UI_QUEUE))) + .toList(); + + if (queueVolumes.isEmpty()) { + List mainArenaVolumes = ownedVolumes.stream() + .filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals( + v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) + .toList(); + if (mainArenaVolumes.isEmpty()) { + debug(playerId, p + " volumes: found " + ownedVolumes.size() + " volume(s) for this game, none have MapRole=MainArena or UIQueue=True"); + debug(playerId, p + " fix: add MapRole=MainArena and UIQueue=True to the arena volume(s)"); + } else { + debug(playerId, p + " volumes: found " + mainArenaVolumes.size() + " MainArena volume(s), none have UIQueue=True"); + debug(playerId, p + " fix: add UIQueue=True to the MainArena volume(s)"); + for (VolumeEntry v : mainArenaVolumes) { + String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)"); + debug(playerId, p + " volume MapId=" + mapId + " MapRole=MainArena UIQueue=(missing)"); + } + } + continue; + } + + List arenaVolumes = queueVolumes.stream() + .filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals( + v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) + .toList(); + + if (arenaVolumes.isEmpty()) { + debug(playerId, p + " volumes: found " + queueVolumes.size() + " volume(s) with UIQueue=True, none have MapRole=MainArena"); + debug(playerId, p + " fix: add MapRole=MainArena to the arena volume(s)"); + for (VolumeEntry v : queueVolumes) { + String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)"); + String role = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ROLE, "(missing)"); + debug(playerId, p + " volume MapId=" + mapId + " MapRole=" + role); + } + continue; + } + + for (VolumeEntry v : arenaVolumes) { + String mapId = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "(no MapId)"); + String queueType = v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_UI_QUEUE_TYPE, "Global (default)"); + debug(playerId, p + " volume OK — MapId=" + mapId + " UIQueueType=" + queueType); + } + + if (def.isUIQueueable()) { + debug(playerId, p + " RESULT: visible in global browser — " + arenaVolumes.size() + " map(s)"); + } else { + debug(playerId, p + " RESULT: hidden — volumes are tagged correctly but UIQueueable=false"); + debug(playerId, p + " fix: add \"UIQueueable\": true to the definition"); + } + } + } + + private void debug(String playerId, String text) { + if (playerAdapter != null) { + playerAdapter.sendMessage(playerId, Message.raw(text)); + } + } + + // ── UI BUILDERS ─────────────────────────────────────────────────────────── + // All styles mirror DuelMenuService for visual consistency. + + private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) { + return ContainerBuilder.decoratedContainer() + .withTitleText(title) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .editElementAfter((commands, selector) -> commands.setObject( + selector + " #Content.Anchor", + new HyUIAnchor().setTop(0).toHytaleAnchor() + )); + } + + private static GroupBuilder surface(String title, String subtitle, int width, int height) { + return GroupBuilder.group() + .withLayoutMode("Top") + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .withBackground(patch("#172131")) + .withOutlineColor("#2b405d") + .withOutlineSize(1.0f) + .addChild(spacer(width, 8)) + .addChild(inset(kicker(title, Math.max(80, width - 44)), width, Math.max(80, width - 44), 18)) + .addChild(inset(caption(subtitle, Math.max(80, width - 44)), width, Math.max(80, width - 44), 24)) + .addChild(spacer(width, 8)); + } + + private static GroupBuilder inset(UIElementBuilder child, int outerWidth, int childWidth, int height) { + return GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height)) + .addChild(spacer(SECTION_INSET, height)) + .addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height))); + } + + private static ButtonBuilder actionButton(String text, String tooltip, int width, Runnable action) { + return ButtonBuilder.secondaryTextButton() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(38)) + .withTooltipText(tooltip) + .onClick(action); + } + + private static ButtonBuilder dangerButton(String text, String tooltip, int width, Runnable action) { + return ButtonBuilder.cancelTextButton() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(38)) + .withTooltipText(tooltip) + .onClick(action); + } + + private static LabelBuilder kicker(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(18)) + .withStyle(textStyle(11, "#7cc7ff", true) + .setRenderUppercase(true).setWrap(false) + .setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8)); + } + + private static LabelBuilder bodyLabel(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(20)) + .withStyle(textStyle(13, "#dbe7f6", false) + .setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(10)); + } + + private static LabelBuilder caption(String text, int width) { + return LabelBuilder.label() + .withText(text) + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(24)) + .withStyle(textStyle(11, "#9eb4cf", false) + .setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8)); + } + + private static LabelBuilder spacer(int width, int height) { + return LabelBuilder.label() + .withText(" ") + .withAnchor(new HyUIAnchor().setWidth(width).setHeight(height)) + .withStyle(textStyle(1, "#172131", false)); + } + + private static HyUIStyle textStyle(float size, String color, boolean bold) { + return new HyUIStyle() + .setFontSize(size) + .setTextColor(color) + .setRenderBold(bold) + .setLetterSpacing(0); + } + + private static HyUIPatchStyle patch(String color) { + return new HyUIPatchStyle().setColor(color); + } + + private record PendingOpen(UUID uuid, UIQueueMode mode, String minigameId, String mapId, long createdAtMillis) {} + private record ArenaEntry(String minigameId, String mapId, String queueType) {} +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java index 35e9486..d09e411 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/LeaveMinigameQueueEffect.java @@ -18,12 +18,27 @@ public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect { var services = services(); String playerId = resolvePlayerId(context, null); String id = resolvedMinigameId(context); - if (services != null && playerId != null && !id.isBlank()) { - services.queue().leave(id, playerId); - debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'"); - } else { + if (services == null || playerId == null || id.isBlank()) { debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')"); + return; } + + // Remove from pre-start queue session + services.queue().leave(id, playerId); + + // Also remove from the runtime if the game is still in the pregame window. + // This triggers the min-player check on the next pregame tick. + services.runtime().runtimes(id).forEach(runtime -> { + var phase = runtime.phase(); + if (phase == net.kewwbec.minigames.model.Enums.MinigamePhase.WAITING_FOR_PLAYERS + || phase == net.kewwbec.minigames.model.Enums.MinigamePhase.COUNTDOWN + || phase == net.kewwbec.minigames.model.Enums.MinigamePhase.STARTING) { + runtime.players().remove(playerId); + debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'"); + } + }); + + debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'"); } } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/MountPlayerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/MountPlayerEffect.java index 7b246b3..c96c6d9 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/MountPlayerEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/MountPlayerEffect.java @@ -11,8 +11,8 @@ import com.hypixel.hytale.server.core.entity.UUIDComponent; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementConfig; import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager; -import com.hypixel.hytale.server.core.entity.EntityUtils; import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.physics.component.PhysicsValues; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.npc.NPCPlugin; @@ -165,7 +165,9 @@ public final class MountPlayerEffect extends MinigameRuntimeEffect { return; } - movementManager.setDefaultSettings(config.toPacket(), EntityUtils.getPhysicsValues(playerRef, context.getStore()), playerComponent.getGameMode()); + PhysicsValues physicsValues = context.getStore().getComponent(playerRef, PhysicsValues.getComponentType()); + if (physicsValues == null) physicsValues = PhysicsValues.getDefault(); + movementManager.setDefaultSettings(config.toPacket(), physicsValues, playerComponent.getGameMode()); movementManager.applyDefaultSettings(); movementManager.update(playerRefComponent.getPacketHandler()); } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/OpenMinigameQueueUIEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/OpenMinigameQueueUIEffect.java new file mode 100644 index 0000000..8319820 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/OpenMinigameQueueUIEffect.java @@ -0,0 +1,50 @@ +package net.kewwbec.minigames.volume.effect; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.ui.MinigameQueueUIService; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public final class OpenMinigameQueueUIEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "OpenMinigameQueueUI"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(OpenMinigameQueueUIEffect.class, OpenMinigameQueueUIEffect::new, MINIGAME_BASE_CODEC) + .append( + new KeyedCodec<>("MapId", Codec.STRING, false), + (effect, value) -> effect.mapId = value, + effect -> effect.mapId + ) + .add() + .build(); + + @Nullable + private String mapId; + + @Override + public void execute(@Nonnull TriggerContext context) { + MinigameQueueUIService queueUIService = MinigameCorePlugin.getQueueUIService(); + PlayerRef player = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); + if (queueUIService == null || player == null) { + return; + } + + String resolvedMinigameId = resolvedMinigameId(context); + String resolvedMapId = mapId != null ? mapId.trim() : ""; + + MinigameQueueUIService.UIQueueMode mode = resolvedMinigameId.isBlank() + ? MinigameQueueUIService.UIQueueMode.GLOBAL + : resolvedMapId.isBlank() + ? MinigameQueueUIService.UIQueueMode.SINGLE + : MinigameQueueUIService.UIQueueMode.LOCAL; + + queueUIService.requestOpen(player, mode, resolvedMinigameId, resolvedMapId); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/PlaceBlocksEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/PlaceBlocksEffect.java index 5875803..1f3a910 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/PlaceBlocksEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/PlaceBlocksEffect.java @@ -1,6 +1,7 @@ package net.kewwbec.minigames.volume.effect; import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; import com.hypixel.hytale.codec.Codec; import com.hypixel.hytale.codec.KeyedCodec; import com.hypixel.hytale.codec.builder.BuilderCodec; @@ -8,6 +9,7 @@ import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.math.util.ChunkUtil; import com.hypixel.hytale.server.core.asset.type.blocktype.config.BlockType; import com.hypixel.hytale.server.core.universe.world.World; +import org.joml.Vector3d; import javax.annotation.Nonnull; import java.util.ArrayList; @@ -41,6 +43,10 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { .add() .append(new KeyedCodec<>("MaxBlocks", Codec.INTEGER, false), (effect, value) -> effect.maxBlocks = value, effect -> effect.maxBlocks) .add() + .append(new KeyedCodec<>("FillVolume", Codec.BOOLEAN, false), (effect, value) -> effect.fillVolume = value, effect -> effect.fillVolume) + .add() + .append(new KeyedCodec<>("Hollow", Codec.BOOLEAN, false), (effect, value) -> effect.hollow = value, effect -> effect.hollow) + .add() .build(); private String blockId = ""; @@ -51,6 +57,8 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { private int y2 = 0; private int z2 = 0; private int maxBlocks = DEFAULT_MAX_BLOCKS; + private boolean fillVolume = false; + private boolean hollow = false; @Override public void execute(@Nonnull TriggerContext context) { @@ -66,12 +74,30 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { return; } - int minX = Math.min(x1, x2); - int maxX = Math.max(x1, x2); - int minY = Math.max(0, Math.min(y1, y2)); - int maxY = Math.min(319, Math.max(y1, y2)); - int minZ = Math.min(z1, z2); - int maxZ = Math.max(z1, z2); + int minX, maxX, minY, maxY, minZ, maxZ; + if (fillVolume) { + VolumeEntry volume = context.getVolume(); + if (volume == null) { + debugMessage(context, TYPE_ID, "SKIPPED FillVolume=true but no volume in context"); + return; + } + Vector3d wMin = new Vector3d(); + Vector3d wMax = new Vector3d(); + volume.getShape().getWorldAABB(volume.getPosition(), wMin, wMax); + minX = (int) Math.floor(Math.min(wMin.x(), wMax.x())); + maxX = (int) Math.floor(Math.max(wMin.x(), wMax.x())); + minZ = (int) Math.floor(Math.min(wMin.z(), wMax.z())); + maxZ = (int) Math.floor(Math.max(wMin.z(), wMax.z())); + minY = Math.max(0, (int) Math.floor(Math.min(wMin.y(), wMax.y()))); + maxY = Math.min(319, (int) Math.floor(Math.max(wMin.y(), wMax.y()))); + } else { + minX = Math.min(x1, x2); + maxX = Math.max(x1, x2); + minY = Math.max(0, Math.min(y1, y2)); + maxY = Math.min(319, Math.max(y1, y2)); + minZ = Math.min(z1, z2); + maxZ = Math.max(z1, z2); + } long totalBlocks = blockCount(minX, maxX, minY, maxY, minZ, maxZ); int limit = maxBlocks > 0 ? maxBlocks : DEFAULT_MAX_BLOCKS; @@ -84,6 +110,7 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { return; } + final int fMinX = minX, fMaxX = maxX, fMinY = minY, fMaxY = maxY, fMinZ = minZ, fMaxZ = maxZ; World world = context.getStore().getExternalData().getWorld(); Map> positionsByChunk = new HashMap<>(); for (int x = minX; x <= maxX; x++) { @@ -91,6 +118,9 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { long chunkIndex = ChunkUtil.indexChunkFromBlock(x, z); List positions = positionsByChunk.computeIfAbsent(chunkIndex, ignored -> new ArrayList<>()); for (int y = minY; y <= maxY; y++) { + if (hollow && x != fMinX && x != fMaxX && y != fMinY && y != fMaxY && z != fMinZ && z != fMaxZ) { + continue; + } positions.add(new BlockPosition(x, y, z)); } } @@ -110,7 +140,7 @@ public final class PlaceBlocksEffect extends MinigameRuntimeEffect { }) ); - debugMessage(context, TYPE_ID, "FIRED block='" + blockId + "' count=" + totalBlocks); + debugMessage(context, TYPE_ID, "FIRED block='" + blockId + "' count=" + totalBlocks + (hollow ? " hollow" : "") + (fillVolume ? " fillVolume" : "")); } private static long blockCount(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java index 16a2501..a245c36 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/SpawnItemEffect.java @@ -26,7 +26,8 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect { @Nonnull public static final BuilderCodec CODEC = BuilderCodec.builder(SpawnItemEffect.class, SpawnItemEffect::new, MINIGAME_BASE_CODEC) - .append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId) + //.append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId) + .append(new KeyedCodec<>("ItemId", Codec.STRING), (effect, value) -> effect.itemId = value, effect -> effect.itemId) .add() .append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount) .add() diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StartPlayerTimerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StartPlayerTimerEffect.java new file mode 100644 index 0000000..7731c25 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StartPlayerTimerEffect.java @@ -0,0 +1,45 @@ +package net.kewwbec.minigames.volume.effect; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; + +import javax.annotation.Nonnull; + +public final class StartPlayerTimerEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StartPlayerTimer"; + static final String VAR_TIMER_START = "$timer_start_ms"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartPlayerTimerEffect.class, StartPlayerTimerEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (effect, value) -> effect.playerId = value, + effect -> effect.playerId) + .add() + .build(); + + private String playerId; + + @Override + public void execute(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + player.variables().put(VAR_TIMER_START, System.currentTimeMillis()); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' timer started"); + } +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java index e90a977..a226d5f 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StartQueuedMinigameEffect.java @@ -25,12 +25,26 @@ public final class StartQueuedMinigameEffect extends MinigameRuntimeEffect { } var defOpt = services.minigames().definition(id); - if (defOpt.isPresent()) { - var discovered = services.maps().discover(context, id); - services.queue().startQueued(defOpt.get(), discovered.candidates(), services.runtime()); - debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size()); - } else { + if (defOpt.isEmpty()) { debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + return; } + + var def = defOpt.get(); + + if (!services.runtime().runtimes(id).isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (runtime already active for id='" + id + "')"); + return; + } + + int queued = services.queue().players(id).size(); + if (queued < def.getMinPlayers()) { + debugMessage(context, TYPE_ID, "SKIPPED (queue=" + queued + " < minPlayers=" + def.getMinPlayers() + ")"); + return; + } + + var discovered = services.maps().discover(context, id); + services.queue().startQueued(def, discovered.candidates(), services.runtime()); + debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' candidates=" + discovered.candidates().size()); } } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/StopPlayerTimerEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/StopPlayerTimerEffect.java new file mode 100644 index 0000000..d59a686 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/volume/effect/StopPlayerTimerEffect.java @@ -0,0 +1,82 @@ +package net.kewwbec.minigames.volume.effect; + +import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.codec.codecs.EnumCodec; + +import javax.annotation.Nonnull; + +public final class StopPlayerTimerEffect extends MinigameRuntimeEffect { + public static final String TYPE_ID = "StopPlayerTimer"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StopPlayerTimerEffect.class, StopPlayerTimerEffect::new, MINIGAME_BASE_CODEC) + .append(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (effect, value) -> effect.playerId = value, + effect -> effect.playerId) + .add() + .append(new KeyedCodec<>("Operation", new EnumCodec<>(Operation.class), false), + (effect, value) -> effect.operation = value, + effect -> effect.operation) + .add() + .build(); + + private String playerId; + private Operation operation = Operation.SET; + + @Override + public void execute(@Nonnull TriggerContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + Object startObj = player.variables().get(StartPlayerTimerEffect.VAR_TIMER_START); + if (!(startObj instanceof Long startMs)) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' timer was never started)"); + return; + } + long elapsedMs = System.currentTimeMillis() - startMs; + int lap = (int) Math.min(elapsedMs / 10L, Integer.MAX_VALUE); + player.variables().remove(StartPlayerTimerEffect.VAR_TIMER_START); + + Operation op = operation != null ? operation : Operation.SET; + int prev = player.score(); + int next = switch (op) { + case SET -> lap; + case ADD -> prev + lap; + case BEST -> (prev <= 0 || lap < prev) ? lap : prev; + }; + player.score(next); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' op=" + op + + " lap=" + formatPlayerTime(lap) + " score=" + formatPlayerTime(next)); + } + + public enum Operation { + SET, + ADD, + BEST + } + + // Centiseconds → M:SS.cc (0 = DNF / no time) + public static String formatPlayerTime(int centiseconds) { + if (centiseconds <= 0) return "--:--"; + int minutes = centiseconds / 6000; + int seconds = (centiseconds % 6000) / 100; + int hundredths = centiseconds % 100; + return minutes + ":" + String.format("%02d", seconds) + "." + String.format("%02d", hundredths); + } +} diff --git a/src/main/resources/Server/Languages/en-US/server.lang b/src/main/resources/Server/Languages/en-US/server.lang index 4a496fb..9369dcb 100644 --- a/src/main/resources/Server/Languages/en-US/server.lang +++ b/src/main/resources/Server/Languages/en-US/server.lang @@ -215,3 +215,102 @@ customUI.triggerVolumeEffectEditor.effectType.StartQueuedMinigame = Start Queued customUI.triggerVolumeEffectEditor.effectType.SpawnItem = Spawn Item customUI.triggerVolumeEffectEditor.effectType.SpawnNpc = Spawn NPC customUI.triggerVolumeEffectEditor.effectType.MountPlayer = Mount Player +customUI.triggerVolumeEffectEditor.effectType.DismountPlayer = Dismount Player +customUI.triggerVolumeEffectEditor.effectType.OpenMinigameQueueUI = Open Queue UI +customUI.triggerVolumeEffectEditor.effectType.PlaceBlocks = Place Blocks +customUI.triggerVolumeEffectEditor.effectType.SetPlayerLoadout = Set Player Loadout +customUI.triggerVolumeEffectEditor.effectType.StartPlayerTimer = Start Player Timer +customUI.triggerVolumeEffectEditor.effectType.StopPlayerTimer = Stop Player Timer +customUI.triggerVolumeEffectEditor.effectType.StartMinigame = Start Minigame +customUI.triggerVolumeEffectEditor.effectType.EndMinigame = End Minigame +customUI.triggerVolumeEffectEditor.effectType.ResetMinigame = Reset Minigame +customUI.triggerVolumeEffectEditor.effectType.SetMinigamePhase = Set Phase +customUI.triggerVolumeEffectEditor.effectType.ResetScores = Reset Scores +customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerScore = Modify Player Score +customUI.triggerVolumeEffectEditor.effectType.ModifyTeamScore = Modify Team Score +customUI.triggerVolumeEffectEditor.effectType.ModifyCounter = Modify Counter +customUI.triggerVolumeEffectEditor.effectType.ModifyPlayerLives = Modify Player Lives +customUI.triggerVolumeEffectEditor.effectType.SetPlayerStatus = Set Player Status + +customUI.triggerVolumeEffectEditor.conditionType.MinigamePhase = Minigame Phase +customUI.triggerVolumeEffectEditor.conditionType.PlayerInMinigame = Player In Minigame +customUI.triggerVolumeEffectEditor.conditionType.PlayerStatus = Player Status +customUI.triggerVolumeEffectEditor.conditionType.PlayerScore = Player Score +customUI.triggerVolumeEffectEditor.conditionType.TeamScore = Team Score +customUI.triggerVolumeEffectEditor.conditionType.Counter = Counter + +customUI.triggerVolumeEffectEditor.field.common.NpcId = NPC UUID +customUI.triggerVolumeEffectEditor.field.common.NpcId.tooltip = Optional exact NPC UUID to target. Leave empty to use the nearest matching NPC. +customUI.triggerVolumeEffectEditor.field.common.NpcRoleId = NPC Role +customUI.triggerVolumeEffectEditor.field.common.NpcRoleId.tooltip = Optional NPC role filter. Leave empty to match any NPC. +customUI.triggerVolumeEffectEditor.field.common.RemoveNpc = Remove NPC After Dismount +customUI.triggerVolumeEffectEditor.field.common.RemoveNpc.tooltip = If true, the NPC is removed from the world after the player dismounts. +customUI.triggerVolumeEffectEditor.field.common.BalanceTeams = Balance Teams +customUI.triggerVolumeEffectEditor.field.common.BalanceTeams.tooltip = If true, evenly distribute all runtime players across available teams instead of assigning a specific team. +customUI.triggerVolumeEffectEditor.field.common.PreventManualDismount = Lock Mount +customUI.triggerVolumeEffectEditor.field.common.PreventManualDismount.tooltip = If true, the player cannot manually dismount. +customUI.triggerVolumeEffectEditor.field.common.LoadoutId = Loadout +customUI.triggerVolumeEffectEditor.field.common.LoadoutId.tooltip = The loadout id from the minigame definition to apply to the player. +customUI.triggerVolumeEffectEditor.field.common.BlockId = Block +customUI.triggerVolumeEffectEditor.field.common.BlockId.tooltip = The block type asset id to place. +customUI.triggerVolumeEffectEditor.field.common.X1 = X1 +customUI.triggerVolumeEffectEditor.field.common.X1.tooltip = First corner X coordinate. +customUI.triggerVolumeEffectEditor.field.common.Y1 = Y1 +customUI.triggerVolumeEffectEditor.field.common.Y1.tooltip = First corner Y coordinate. +customUI.triggerVolumeEffectEditor.field.common.Z1 = Z1 +customUI.triggerVolumeEffectEditor.field.common.Z1.tooltip = First corner Z coordinate. +customUI.triggerVolumeEffectEditor.field.common.X2 = X2 +customUI.triggerVolumeEffectEditor.field.common.X2.tooltip = Second corner X coordinate. +customUI.triggerVolumeEffectEditor.field.common.Y2 = Y2 +customUI.triggerVolumeEffectEditor.field.common.Y2.tooltip = Second corner Y coordinate. +customUI.triggerVolumeEffectEditor.field.common.Z2 = Z2 +customUI.triggerVolumeEffectEditor.field.common.Z2.tooltip = Second corner Z coordinate. +customUI.triggerVolumeEffectEditor.field.common.MaxBlocks = Max Blocks +customUI.triggerVolumeEffectEditor.field.common.MaxBlocks.tooltip = Maximum block count allowed for this operation. Defaults to 32768. Increase with care. +customUI.triggerVolumeEffectEditor.field.common.FillVolume = Fill Volume Shape +customUI.triggerVolumeEffectEditor.field.common.FillVolume.tooltip = If true, use this trigger volume's bounding box as the fill region instead of the X1/Y1/Z1 - X2/Y2/Z2 coordinates. +customUI.triggerVolumeEffectEditor.field.common.Hollow = Hollow +customUI.triggerVolumeEffectEditor.field.common.Hollow.tooltip = If true, only place blocks on the outer shell of the region. Interior blocks are left unchanged. +customUI.triggerVolumeEffectEditor.field.common.Operation.option.BEST = Best + +customUI.triggerVolumeEffectEditor.field.MountPlayer.PreventManualDismount.tooltip = Prevents the player from manually dismounting while this component is active. +customUI.triggerVolumeEffectEditor.field.AssignTeam.BalanceTeams.tooltip = Distribute all runtime players evenly across teams. TeamId is ignored when this is enabled. +customUI.triggerVolumeEffectEditor.field.DismountPlayer.PlayerId.tooltip = Optional player UUID. Leave empty to dismount the triggering player. +customUI.triggerVolumeEffectEditor.field.DismountPlayer.NpcRoleId.tooltip = Optional NPC role filter. Only dismounts if the player is mounted on an NPC with this role. +customUI.triggerVolumeEffectEditor.field.DismountPlayer.RemoveNpc.tooltip = Remove the NPC from the world after the player is dismounted. +customUI.triggerVolumeEffectEditor.field.OpenMinigameQueueUI.MinigameId.tooltip = Minigame to show. Leave empty to open the global game browser. +customUI.triggerVolumeEffectEditor.field.OpenMinigameQueueUI.MapId.tooltip = Specific map to show as a compact join popup. Requires MinigameId to also be set. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.BlockId.tooltip = The block type asset id to place throughout the region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.X1.tooltip = X coordinate of the first corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Y1.tooltip = Y coordinate of the first corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Z1.tooltip = Z coordinate of the first corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.X2.tooltip = X coordinate of the second corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Y2.tooltip = Y coordinate of the second corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Z2.tooltip = Z coordinate of the second corner of the fill region. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.MaxBlocks.tooltip = Maximum number of blocks this effect is allowed to place. Protects against oversized regions. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.FillVolume.tooltip = Use this trigger volume's own bounding box as the fill region. Overrides the X1/Y1/Z1 - X2/Y2/Z2 fields. +customUI.triggerVolumeEffectEditor.field.PlaceBlocks.Hollow.tooltip = Only place blocks on the outer faces of the region. Interior is left untouched. +customUI.triggerVolumeEffectEditor.field.SetPlayerLoadout.PlayerId.tooltip = Optional player UUID. Leave empty to apply the loadout to the triggering player. +customUI.triggerVolumeEffectEditor.field.SetPlayerLoadout.LoadoutId.tooltip = The loadout id defined in the minigame definition to give the player. +customUI.triggerVolumeEffectEditor.field.StartPlayerTimer.PlayerId.tooltip = Optional player UUID. Leave empty to start the timer for the triggering player. +customUI.triggerVolumeEffectEditor.field.StopPlayerTimer.PlayerId.tooltip = Optional player UUID. Leave empty to stop the timer for the triggering player. +customUI.triggerVolumeEffectEditor.field.StopPlayerTimer.Operation.tooltip = How to apply the elapsed time to the player's score. SET replaces, ADD accumulates, BEST keeps the lowest non-zero time. + +# /triggervolume clone +server.commands.triggervolume.clone.desc = Clone a trigger volume and place it at your position +server.commands.triggervolume.clone.name.desc = Name of the trigger volume to clone +server.commands.triggervolume.clone.newName.desc = Optional name for the clone (defaults to _Copy) +server.commands.triggervolume.clone.updateTags.desc = Comma-separated tag overrides applied to the clone, e.g. MinigameId=Fishing,MapId=Cold +server.commands.triggervolume.clone.notFound = No trigger volume found with name '{name}' +server.commands.triggervolume.clone.success = Cloned '{source}' to '{name}' at your position + +# /triggervolume clonegroup +server.commands.triggervolume.clonegroup.desc = Clone a trigger volume group, renaming member volumes via find/replace +server.commands.triggervolume.clonegroup.groupName.desc = Name of the group to clone +server.commands.triggervolume.clonegroup.newGroupName.desc = Name for the new group +server.commands.triggervolume.clonegroup.find.desc = Phrase to replace in member volume names +server.commands.triggervolume.clonegroup.replace.desc = Replacement phrase for member volume names +server.commands.triggervolume.clonegroup.notFound = No trigger volume group found with name '{name}' +server.commands.triggervolume.clonegroup.alreadyExists = A trigger volume group already exists with name '{name}' +server.commands.triggervolume.clonegroup.empty = Group '{name}' has no member volumes +server.commands.triggervolume.clonegroup.success = Cloned group '{source}' to '{name}' with {count} volumes diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json index 76ef4ec..58c3863 100644 --- a/src/main/resources/manifest.json +++ b/src/main/resources/manifest.json @@ -11,7 +11,7 @@ "Website": "https://kewwbec.net", "ServerVersion": "^0.5.3", "Dependencies": { - + "Ellie:HyUI": "*" }, "OptionalDependencies": {