C#:
using System.Collections.Generic;
using System;
using UnityEngine;
using UnityEngine.Networking;
using Oxide.Core;
using Oxide.Core.Configuration;
using Oxide.Game.Rust.Cui;
using System.Linq;
using Random = UnityEngine.Random;
namespace Oxide.Plugins
{
[Info("Falling Tiles", "Vic", "34.1.0")]
public class FallingTiles : RustPlugin
{
// --- CONFIGURATION (Persists in oxide/config/FallingTiles.json) ---
private ConfigData _config;
// --- PLAYER DATA PERSISTENCE (Persists in oxide/data/FallingTiles_PlayerData.json) ---
private DynamicConfigFile _playerDataFile;
private Dictionary<string, StoredPlayerData> _storedPlayerData = new Dictionary<string, StoredPlayerData>();
private class StoredPlayerData
{
public float BestSurvivalTime { get; set; }
public int TotalTilesTouched { get; set; }
public int TotalGamesPlayed { get; set; }
public int TotalWins { get; set; }
public string LastKnownName { get; set; }
}
private class GameModeConfig
{
public string Name { get; set; } = "Default";
public string Description { get; set; } = "Default game mode";
public Vector3 ArenaSpawn { get; set; } = Vector3.zero;
public Vector3 TeleportSpherePos { get; set; } = Vector3.zero;
public float SphereRadius { get; set; } = 2f;
public string ArenaShape { get; set; } = "square"; // square, circle, hexagon, star, heart
public string ArenaPattern { get; set; } = "solid"; // solid, checkerboard, stripes, rings, maze, spiral, cross, diamond
public string ArenaSpacing { get; set; } = "normal"; // tight, normal, wide, random
public string TileType { get; set; } = "square"; // square, triangle
public int ArenaSize { get; set; } = 6;
public int ArenaLayers { get; set; } = 3; // 1-10 layers
public ulong TileSkinId { get; set; } = 0; // Default skin for TopTier grade
public BuildingGrade.Enum TileGrade { get; set; } = BuildingGrade.Enum.TopTier; // Starting grade
// --- GAMEPLAY PARAMETERS (Differentiate modes) ---
public float TouchCheckInterval { get; set; } = 0.25f; // How often to check player touches (lower = faster response)
public int DegradeSteps { get; set; } = 1; // How many grades to degrade per touch (1=normal, 2=faster)
public float RandomTileDropInterval { get; set; } = 0f; // Seconds between random tile drops (0=disabled)
public bool InstantKillOnTouch { get; set; } = false; // If true, tiles instantly break on touch
public float TileDropChanceOnBass { get; set; } = 1f; // Multiplier for bass drop tile count (1=normal, 2=double)
public bool RandomDegradation { get; set; } = false; // If true, random tiles degrade by themselves
public bool TouchDegrades { get; set; } = true; // If true, tiles degrade when stepped on (ONLY for Touch/Classic mode)
public float VocalDropMultiplier { get; set; } = 1f; // Multiplier for drops during vocal sections
public bool ShowLyricsOnTiles { get; set; } = false; // Show lyrics on tiles about to break
// --- MUSIC INTEGRATION ---
public bool MusicSynced { get; set; } = true; // Whether mode responds to music events
public bool BPMScaling { get; set; } = false; // Scale tile drops with song BPM
public bool BeatSyncEnabled { get; set; } = false; // Visual pulse on bass beats
public bool VocalSynced { get; set; } = false; // React to vocal sections (faster drops)
public bool AutoBPM { get; set; } = false; // Auto-detect BPM from audio analysis
// --- ADVANCED FEATURES ---
public float ShrinkingArenaInterval { get; set; } = 0f; // Seconds between arena shrinks (0=disabled)
public bool GrowingHolesEnabled { get; set; } = false; // Random holes appear and expand
// Grade-specific skins - different visual for each degradation level
public ulong SkinTopTier { get; set; } = 0; // Skin for TopTier (HQM)
public ulong SkinMetal { get; set; } = 0; // Skin for Metal grade
public ulong SkinStone { get; set; } = 0; // Skin for Stone grade
public ulong SkinWood { get; set; } = 0; // Skin for Wood grade
public ulong SkinTwigs { get; set; } = 0; // Skin for Twigs grade (before destruction)
// Get the prefab path based on tile type
public string GetTilePrefab()
{
return TileType?.ToLower() == "triangle"
? "assets/prefabs/building core/floor.triangle/floor.triangle.prefab"
: "assets/prefabs/building core/floor/floor.prefab";
}
// Get spacing value based on setting
public float GetSpacingValue()
{
switch (ArenaSpacing?.ToLower())
{
case "tight": return 2.0f;
case "wide": return 4.0f;
case "random": return Random.Range(2.0f, 4.0f);
default: return 3.0f; // normal
}
}
// Get skin for a specific grade
public ulong GetSkinForGrade(BuildingGrade.Enum grade)
{
switch (grade)
{
case BuildingGrade.Enum.TopTier: return SkinTopTier > 0 ? SkinTopTier : TileSkinId;
case BuildingGrade.Enum.Metal: return SkinMetal > 0 ? SkinMetal : TileSkinId;
case BuildingGrade.Enum.Stone: return SkinStone > 0 ? SkinStone : TileSkinId;
case BuildingGrade.Enum.Wood: return SkinWood > 0 ? SkinWood : TileSkinId;
case BuildingGrade.Enum.Twigs: return SkinTwigs > 0 ? SkinTwigs : TileSkinId;
default: return TileSkinId;
}
}
}
private class ConfigData
{
public string NgrokLink { get; set; } = "https://gael-supersolar-elenora.ngrok-free.dev";
public float TileSpacing { get; set; } = 3f;
public float LayerHeight { get; set; } = 3.5f;
public int CountdownSeconds { get; set; } = 5;
public Vector3 LobbySpawn { get; set; } = Vector3.zero;
public Vector3 SpectatorSpawn { get; set; } = Vector3.zero;
public bool PreSpawnArenas { get; set; } = true; // Spawn all arenas on server start
public bool TeleportToLobbyOnJoin { get; set; } = true; // Teleport players to lobby when they join server
public bool SpawnWithRandomGun { get; set; } = true; // Give players a double barrel shotgun when joining
public int GunAmmoAmount { get; set; } = 100; // Amount of ammo to give
public string SpawnGun { get; set; } = "shotgun.double"; // Double barrel by default
public List<string> RandomGuns { get; set; } = new List<string> {
"shotgun.double", "pistol.semiauto", "pistol.python", "shotgun.pump"
};
public Dictionary<string, GameModeConfig> GameModes { get; set; } = new Dictionary<string, GameModeConfig>
{
{ "classic", new GameModeConfig {
Name = "🎮 Classic (Touch)",
Description = "TOUCH MODE - Tiles degrade when YOU step on them!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "square",
ArenaPattern = "solid",
ArenaSize = 6,
ArenaLayers = 3,
TouchCheckInterval = 0.25f,
DegradeSteps = 1,
RandomTileDropInterval = 0f,
TileDropChanceOnBass = 0.5f, // Low bass drops - mainly touch based
TouchDegrades = true, // THIS is the key difference!
MusicSynced = true,
BeatSyncEnabled = false,
BPMScaling = false
} },
{ "speed", new GameModeConfig {
Name = "⚡ Speed Mode",
Description = "BASS DROPS! Fast touch + heavy bass reactions!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "circle",
ArenaPattern = "checkerboard",
ArenaSize = 5,
ArenaLayers = 2,
TouchCheckInterval = 0.08f,
DegradeSteps = 2,
RandomTileDropInterval = 0f,
TileDropChanceOnBass = 3f, // Heavy bass drops!
TouchDegrades = true, // Also has touch
MusicSynced = true,
BPMScaling = true, // Faster songs = more chaos
BeatSyncEnabled = true,
VocalSynced = true,
VocalDropMultiplier = 2f,
AutoBPM = true
} },
{ "chaos", new GameModeConfig {
Name = "🌪️ Chaos Mode",
Description = "MUSIC CHAOS! No touch - ONLY music controls tiles!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "hexagon",
ArenaPattern = "spiral",
ArenaSize = 7,
ArenaLayers = 3,
TouchCheckInterval = 0.5f, // Slower touch check (doesn't matter - disabled)
DegradeSteps = 1,
RandomTileDropInterval = 2f, // Tiles randomly fall
TileDropChanceOnBass = 4f, // MASSIVE bass drops!
TouchDegrades = false, // NO TOUCH DEGRADATION!
RandomDegradation = true,
MusicSynced = true,
GrowingHolesEnabled = true,
BeatSyncEnabled = true,
VocalSynced = true,
VocalDropMultiplier = 3f, // Vocals cause chaos
ShowLyricsOnTiles = true, // Show lyrics on breaking tiles!
AutoBPM = true
} },
{ "zen", new GameModeConfig {
Name = "🧘 Zen Mode",
Description = "PURE MUSIC! Tiles ONLY fall on bass beats - no touch!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "circle",
ArenaPattern = "rings",
ArenaSize = 10, // Larger arena
ArenaLayers = 2,
TouchCheckInterval = 2.0f, // Very slow (doesn't matter)
DegradeSteps = 1,
RandomTileDropInterval = 0f, // NO random drops
TileDropChanceOnBass = 1f, // Normal bass drops
TouchDegrades = false, // NO TOUCH! Pure music mode
BeatSyncEnabled = true, // Nice visual pulse
MusicSynced = true,
BPMScaling = false, // No BPM scaling - always calm
VocalSynced = false, // No vocal reactions (calm)
ShowLyricsOnTiles = true, // Show lyrics floating
AutoBPM = true
} },
{ "hardcore", new GameModeConfig {
Name = "💀 Hardcore Mode",
Description = "SURVIVAL! Touch = INSTANT DEATH + heavy music!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "star",
ArenaPattern = "solid",
ArenaSize = 5,
ArenaLayers = 2,
TouchCheckInterval = 0.03f, // Ultra-fast touch detection
DegradeSteps = 1,
InstantKillOnTouch = true, // INSTANT tile death
TileDropChanceOnBass = 2f,
TouchDegrades = true, // Touch KILLS tiles
MusicSynced = true,
BPMScaling = true,
BeatSyncEnabled = true,
VocalSynced = true,
VocalDropMultiplier = 1.5f,
AutoBPM = true
} },
{ "tower", new GameModeConfig {
Name = "🗼 Tower Mode",
Description = "CLIMB! 10 layers - survive as lower floors collapse!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "square",
ArenaPattern = "solid",
ArenaSize = 4, // Smaller footprint
ArenaLayers = 10, // 10 LAYERS HIGH!
TouchCheckInterval = 0.3f,
DegradeSteps = 1,
TileDropChanceOnBass = 2f,
RandomTileDropInterval = 3f, // Tiles fall on their own too
TouchDegrades = false, // NO touch - only music/random
MusicSynced = true,
BeatSyncEnabled = true,
VocalSynced = true,
VocalDropMultiplier = 1.5f,
AutoBPM = true
} },
{ "tournament", new GameModeConfig {
Name = "🏆 Tournament Mode",
Description = "COMPETE! Arena shrinks + music + light touch!",
TileGrade = BuildingGrade.Enum.TopTier,
ArenaShape = "circle",
ArenaPattern = "solid",
ArenaSize = 10, // Larger start
ArenaLayers = 2,
TouchCheckInterval = 0.5f,
DegradeSteps = 1,
ShrinkingArenaInterval = 30f, // Arena shrinks!
TileDropChanceOnBass = 1.5f,
TouchDegrades = true, // Light touch effect
MusicSynced = true,
BeatSyncEnabled = true,
VocalSynced = true,
VocalDropMultiplier = 1.2f,
AutoBPM = true
} }
};
}
protected override void LoadDefaultConfig()
{
_config = new ConfigData();
SaveConfig();
Puts("Created new config file with default settings.");
}
protected override void LoadConfig()
{
base.LoadConfig();
try {
_config = Config.ReadObject<ConfigData>();
// CRITICAL: Ensure all game modes have correct TouchDegrades values
// Old configs may not have this property or may have it set incorrectly
EnsureCorrectModeSettings();
Puts("Config loaded successfully.");
}
catch (Exception ex) {
PrintWarning($"Failed to load config: {ex.Message}. Loading defaults.");
LoadDefaultConfig();
}
}
// Ensure game modes have correct settings (especially for existing configs)
private void EnsureCorrectModeSettings()
{
// Define which modes should NOT have touch degradation
var musicOnlyModes = new HashSet<string> { "chaos", "zen", "tower" };
foreach (var kvp in _config.GameModes)
{
string modeKey = kvp.Key.ToLower();
var mode = kvp.Value;
// Force correct TouchDegrades setting based on mode type
if (musicOnlyModes.Contains(modeKey))
{
if (mode.TouchDegrades)
{
Puts($"[FallingTiles] Fixing mode '{modeKey}': Setting TouchDegrades=false (music-only mode)");
mode.TouchDegrades = false;
}
}
// Log the mode settings for debugging
Puts($"[FallingTiles] Mode '{modeKey}': TouchDegrades={mode.TouchDegrades}, MusicSynced={mode.MusicSynced}");
}
// Save the corrected config
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config);
private void LoadPlayerData()
{
_playerDataFile = Interface.Oxide.DataFileSystem.GetFile(PlayerDataFileName);
try {
_storedPlayerData = _playerDataFile.ReadObject<Dictionary<string, StoredPlayerData>>() ?? new Dictionary<string, StoredPlayerData>();
Puts($"Loaded {_storedPlayerData.Count} player records from data file.");
}
catch {
_storedPlayerData = new Dictionary<string, StoredPlayerData>();
Puts("Created new player data file.");
}
}
private void SavePlayerData()
{
_playerDataFile.WriteObject(_storedPlayerData);
}
// --- GAME STATE ---
private Dictionary<string, HashSet<BuildingBlock>> _arenasByMode = new Dictionary<string, HashSet<BuildingBlock>>(); // Tiles per game mode
private bool _gameActive = false;
private string _currentSong = "Waiting for Request...";
private string _lastLyric = "FALLING TILES";
private Timer _touchTimer;
private Timer _gameTimer;
private Timer _hudUpdateTimer;
private Timer _sphereCheckTimer;
private Timer _randomTileDropTimer; // For chaos mode random tile drops
private Timer _shrinkingArenaTimer; // For shrinking arena effect
private Timer _growingHolesTimer; // For growing holes effect
private Timer _ddrawRefreshTimer; // For refreshing DDRAW text above spheres
private Timer _songDecayTimer; // For song-based tile decay
private float _gameStartTime;
private string _currentGameMode = "classic";
private float _currentBPM = 120f; // Current song BPM for scaling
private float _songDuration = 180f; // Song duration in seconds (default 3 min)
private float _songStartTime = 0f; // When the current song started
private int _initialTileCount = 0; // Tiles at song start for decay calculation
private HashSet<Vector3> _holePositions = new HashSet<Vector3>(); // Track growing holes
// --- LAYER ACTIVATION SYSTEM ---
// Layers only become active (affected by music/random drops) once a player has touched them
private Dictionary<string, HashSet<int>> _activatedLayers = new Dictionary<string, HashSet<int>>(); // mode -> set of activated layer indices
// --- TELEPORT SPHERES ---
private Dictionary<string, SphereEntity> _teleportSpheres = new Dictionary<string, SphereEntity>();
private HashSet<ulong> _playersInSphere = new HashSet<ulong>();
// --- PLAYER DATA (Session) ---
private Dictionary<ulong, PlayerData> _playerData = new Dictionary<ulong, PlayerData>();
private Dictionary<ulong, string> _playerGameMode = new Dictionary<ulong, string>(); // Track which mode each player is in
private HashSet<ulong> _activePlayers = new HashSet<ulong>();
private HashSet<ulong> _spectators = new HashSet<ulong>();
private List<PlayerScore> _leaderboard = new List<PlayerScore>();
private class PlayerData
{
public float SurvivalTime { get; set; }
public int TilesTouched { get; set; }
public int GamesPlayed { get; set; }
public int Wins { get; set; }
public float JoinTime { get; set; }
public string GameMode { get; set; } = "classic"; // Track player's game mode
}
private class PlayerScore
{
public string Name { get; set; }
public float SurvivalTime { get; set; }
public int TilesTouched { get; set; }
}
private const string MetalFloorPrefab = "assets/prefabs/building core/floor/floor.prefab";
private const string TriangleFloorPrefab = "assets/prefabs/building core/floor.triangle/floor.triangle.prefab";
private const string ExplodeEffect = "assets/content/effects/explosions/explosion piercing.prefab";
private const string SpherePrefab = "assets/prefabs/visualization/sphere.prefab";
private const string PlayerDataFileName = "FallingTiles_PlayerData";
void Init() {
permission.RegisterPermission("fallingtiles.admin", this);
LoadPlayerData();
}
void OnServerInitialized()
{
Puts("=== FALLING TILES v29.0.0 ===");
Puts($"Lobby Spawn: {(_config.LobbySpawn != Vector3.zero ? _config.LobbySpawn.ToString() : "Not Set")}");
Puts($"Spectator Spawn: {(_config.SpectatorSpawn != Vector3.zero ? _config.SpectatorSpawn.ToString() : "Not Set")}");
Puts($"Spawn With Random Gun: {_config.SpawnWithRandomGun}");
// Log and spawn teleport spheres for each game mode
foreach (var mode in _config.GameModes)
{
string sphereStatus = mode.Value.TeleportSpherePos != Vector3.zero ? $"Spawning at {mode.Value.TeleportSpherePos}" : "Not Set";
string arenaStatus = mode.Value.ArenaSpawn != Vector3.zero ? mode.Value.ArenaSpawn.ToString() : "Not Set";
string shapeInfo = $"{mode.Value.ArenaShape}/{mode.Value.ArenaPattern}/{mode.Value.ArenaSpacing}";
Puts($" [{mode.Value.Name}] Shape: {shapeInfo} | Sphere: {sphereStatus} | Arena: {arenaStatus}");
}
SpawnAllTeleportSpheres();
// Pre-spawn all arenas if configured
if (_config.PreSpawnArenas)
{
SpawnAllArenas();
}
// Start sphere detection timer
_sphereCheckTimer = timer.Every(0.5f, CheckPlayersInSpheres);
// Start DDRAW text refresh timer (text expires after ~5 seconds, so refresh every 4)
_ddrawRefreshTimer = timer.Every(4f, DrawSphereLabels);
// Initial draw
DrawSphereLabels();
Puts("=== FALLING TILES READY ===");
}
// Draw floating text labels above teleport spheres
private void DrawSphereLabels()
{
foreach (var kvp in _config.GameModes)
{
if (kvp.Value.TeleportSpherePos == Vector3.zero) continue;
// Position text above the sphere
Vector3 textPos = kvp.Value.TeleportSpherePos + new Vector3(0, kvp.Value.SphereRadius + 1f, 0);
// Draw text for all active players
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null || !player.IsConnected) continue;
// Draw mode name in green
player.SendConsoleCommand("ddraw.text", 5f, Color.green, textPos, $"<size=20>{kvp.Value.Name}</size>");
// Draw description below in white
Vector3 descPos = textPos - new Vector3(0, 0.3f, 0);
player.SendConsoleCommand("ddraw.text", 5f, Color.white, descPos, $"<size=12>{kvp.Value.Description}</size>");
}
}
}
void OnServerSave()
{
// Save player data on server save
SavePlayerData();
}
// Teleport new players to lobby when they connect
void OnPlayerConnected(BasePlayer player)
{
if (player == null) return;
// Delay the teleport slightly to ensure player is fully loaded
timer.Once(2f, () => {
if (player != null && player.IsConnected && _config.TeleportToLobbyOnJoin)
{
TeleportToLobby(player);
player.ChatMessage("<color=#1DB954>[FALLING TILES]</color> Welcome! Walk into a teleport sphere to join a game mode. Use /fthelp for commands.");
}
});
}
// Also teleport to lobby on respawn if they die outside of a game
void OnPlayerRespawned(BasePlayer player)
{
if (player == null) return;
// If player is not in an active game, send them to lobby
if (!_activePlayers.Contains(player.userID) && !_spectators.Contains(player.userID) && _config.TeleportToLobbyOnJoin)
{
timer.Once(0.5f, () => {
if (player != null && player.IsConnected)
{
TeleportToLobby(player);
}
});
}
}
private void SpawnAllArenas()
{
Puts("Pre-spawning all arenas...");
foreach (var mode in _config.GameModes)
{
if (mode.Value.ArenaSpawn != Vector3.zero)
{
BuildArenaForMode(mode.Key);
Puts($" Spawned arena for {mode.Value.Name} at {mode.Value.ArenaSpawn}");
}
}
}
private void SpawnAllTeleportSpheres()
{
DestroyAllTeleportSpheres();
foreach (var mode in _config.GameModes)
{
if (mode.Value.TeleportSpherePos != Vector3.zero)
{
SpawnTeleportSphere(mode.Key, mode.Value);
}
}
}
private void SpawnTeleportSphere(string modeKey, GameModeConfig mode)
{
var sphere = GameManager.server.CreateEntity(SpherePrefab, mode.TeleportSpherePos) as SphereEntity;
if (sphere == null) return;
sphere.currentRadius = mode.SphereRadius;
sphere.lerpRadius = mode.SphereRadius;
sphere.Spawn();
_teleportSpheres[modeKey] = sphere;
Puts($"Spawned teleport sphere for {mode.Name} at {mode.TeleportSpherePos}");
}
private void DestroyAllTeleportSpheres()
{
foreach (var sphere in _teleportSpheres.Values)
{
if (sphere != null && !sphere.IsDestroyed)
sphere.Kill();
}
_teleportSpheres.Clear();
}
private void CheckPlayersInSpheres()
{
foreach (var player in BasePlayer.activePlayerList)
{
bool foundInSphere = false;
string foundModeKey = null;
GameModeConfig foundMode = null;
// Check all spheres once per player
foreach (var kvp in _config.GameModes)
{
if (kvp.Value.TeleportSpherePos == Vector3.zero) continue;
float distance = Vector3.Distance(player.transform.position, kvp.Value.TeleportSpherePos);
if (distance <= kvp.Value.SphereRadius)
{
foundInSphere = true;
foundModeKey = kvp.Key;
foundMode = kvp.Value;
break; // Found a sphere, no need to check more
}
}
if (foundInSphere)
{
// Player is in a sphere
if (!_playersInSphere.Contains(player.userID))
{
_playersInSphere.Add(player.userID);
ShowJoinConfirmUI(player, foundModeKey, foundMode);
}
}
else if (_playersInSphere.Contains(player.userID))
{
// Player left all spheres
_playersInSphere.Remove(player.userID);
CuiHelper.DestroyUi(player, "FT_JoinConfirm");
}
}
}
private void ShowJoinConfirmUI(BasePlayer player, string modeKey, GameModeConfig mode)
{
CuiHelper.DestroyUi(player, "FT_JoinConfirm");
var c = new CuiElementContainer();
// Main panel - slightly larger to fit more info
c.Add(new CuiPanel {
Image = { Color = "0.05 0.05 0.05 0.95" },
RectTransform = { AnchorMin = "0.32 0.26", AnchorMax = "0.68 0.64" },
CursorEnabled = true
}, "Overlay", "FT_JoinConfirm");
// Header
c.Add(new CuiPanel {
Image = { Color = "0.1 0.4 0.1 1" },
RectTransform = { AnchorMin = "0 0.85", AnchorMax = "1 1" }
}, "FT_JoinConfirm", "FT_JC_Header");
c.Add(new CuiLabel {
Text = { Text = $"{mode.Name.ToUpper()}", FontSize = 20, Align = TextAnchor.MiddleCenter, Color = "0.1 1 0.3 1" },
RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }
}, "FT_JC_Header");
// Description
c.Add(new CuiLabel {
Text = { Text = mode.Description, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "0.9 0.9 0.9 1" },
RectTransform = { AnchorMin = "0.05 0.72", AnchorMax = "0.95 0.83" }
}, "FT_JoinConfirm");
// TOUCH MODE indicator (MOST IMPORTANT DIFFERENCE)
string touchIndicator = mode.TouchDegrades
? "👟 TOUCH MODE: Tiles break when you step on them!"
: "🎵 MUSIC MODE: Tiles ONLY break from music - no touch!";
string touchColor = mode.TouchDegrades ? "1 0.5 0.2 1" : "0.2 0.8 1 1";
c.Add(new CuiLabel {
Text = { Text = touchIndicator, FontSize = 10, Align = TextAnchor.MiddleCenter, Color = touchColor },
RectTransform = { AnchorMin = "0.05 0.62", AnchorMax = "0.95 0.71" }
}, "FT_JoinConfirm");
// KEY DIFFERENCES - Show what makes this mode unique
List<string> keyFeatures = new List<string>();
if (mode.InstantKillOnTouch && mode.TouchDegrades) keyFeatures.Add("💥 INSTANT KILL ON TOUCH");
if (mode.VocalSynced) keyFeatures.Add($"🎤 Vocals speed up drops ({mode.VocalDropMultiplier}x)");
if (mode.BPMScaling) keyFeatures.Add("🎵 Faster songs = more drops");
if (mode.TileDropChanceOnBass >= 2f) keyFeatures.Add($"🔥 {mode.TileDropChanceOnBass}x drops on bass beats");
if (mode.RandomTileDropInterval > 0) keyFeatures.Add($"🎲 Random drops every {mode.RandomTileDropInterval}s");
if (mode.ShrinkingArenaInterval > 0) keyFeatures.Add($"📉 Arena shrinks every {mode.ShrinkingArenaInterval}s");
if (mode.GrowingHolesEnabled) keyFeatures.Add("🕳️ Holes expand over time");
if (mode.ShowLyricsOnTiles) keyFeatures.Add("📝 Lyrics appear on breaking tiles");
string featuresText = keyFeatures.Count > 0 ? string.Join("\n", keyFeatures.Take(4)) : "Standard gameplay";
c.Add(new CuiLabel {
Text = { Text = featuresText, FontSize = 9, Align = TextAnchor.MiddleCenter, Color = "1 0.8 0.2 1" },
RectTransform = { AnchorMin = "0.05 0.38", AnchorMax = "0.95 0.61" }
}, "FT_JoinConfirm");
c.Add(new CuiLabel {
Text = { Text = $"Arena: {mode.ArenaShape} {mode.ArenaSize}x{mode.ArenaSize} | {mode.ArenaLayers} Layers | {mode.ArenaPattern}", FontSize = 9, Align = TextAnchor.MiddleCenter, Color = "0.6 0.6 0.6 1" },
RectTransform = { AnchorMin = "0.05 0.28", AnchorMax = "0.95 0.37" }
}, "FT_JoinConfirm");
// Gun spawn info
if (_config.SpawnWithRandomGun)
{
c.Add(new CuiLabel {
Text = { Text = "🔫 Double Barrel Shotgun provided!", FontSize = 9, Align = TextAnchor.MiddleCenter, Color = "0.5 0.8 1 1" },
RectTransform = { AnchorMin = "0.05 0.18", AnchorMax = "0.95 0.27" }
}, "FT_JoinConfirm");
}
// Join button
c.Add(new CuiButton {
Button = { Command = $"ft.confirmjoin {modeKey}", Color = "0.1 0.5 0.1 1" },
RectTransform = { AnchorMin = "0.1 0.04", AnchorMax = "0.45 0.16" },
Text = { Text = "✓ JOIN", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }
}, "FT_JoinConfirm");
// Cancel button
c.Add(new CuiButton {
Button = { Command = "ft.canceljoin", Color = "0.5 0.1 0.1 1" },
RectTransform = { AnchorMin = "0.55 0.04", AnchorMax = "0.9 0.16" },
Text = { Text = "✕ CANCEL", FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }
}, "FT_JoinConfirm");
CuiHelper.AddUi(player, c);
}
[ConsoleCommand("ft.confirmjoin")]
private void ConsoleConfirmJoin(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player == null || arg.Args == null || arg.Args.Length == 0) return;
string modeKey = arg.Args[0];
CuiHelper.DestroyUi(player, "FT_JoinConfirm");
_playersInSphere.Remove(player.userID);
if (!_config.GameModes.ContainsKey(modeKey))
{
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> Invalid game mode!");
return;
}
var mode = _config.GameModes[modeKey];
// Check if arena spawn is set
if (mode.ArenaSpawn == Vector3.zero)
{
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> Arena not configured! Admin needs to use /ftsetarena first.");
return;
}
// Check if arena for this mode exists, rebuild if needed
if (!_arenasByMode.ContainsKey(modeKey) || _arenasByMode[modeKey].Count == 0)
{
BuildArenaForMode(modeKey);
}
// Check if player is already in a game
if (_activePlayers.Contains(player.userID))
{
player.ChatMessage("<color=#FFD700>[FALLING TILES]</color> You're already in a game! Use /ftleave first.");
return;
}
// Check if game is active in this mode - join as spectator
if (_gameActive && _currentGameMode == modeKey)
{
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> Game in progress! Added as spectator.");
AddSpectator(player);
return;
}
// Add player to the game
_activePlayers.Add(player.userID);
_playerGameMode[player.userID] = modeKey;
// Activate the top layer (layer 0) so game can start dropping tiles there
ActivateLayer(modeKey, 0);
if (!_playerData.ContainsKey(player.userID))
_playerData[player.userID] = new PlayerData();
_playerData[player.userID].GameMode = modeKey;
// Set current game mode to this mode (first player sets the mode)
_currentGameMode = modeKey;
// Teleport player to arena
TeleportToArena(player, modeKey);
player.ChatMessage($"<color=#1DB954>[FALLING TILES]</color> You joined {mode.Name}!");
player.ChatMessage($"<color=#FFD700>[FALLING TILES]</color> Request a song with /sr <song name> to start the game!");
UpdateHUD();
}
private void BuildArenaForMode(string modeKey)
{
if (!_config.GameModes.ContainsKey(modeKey)) return;
// Clear only this mode's arena, not all arenas
ClearArenaForMode(modeKey);
var mode = _config.GameModes[modeKey];
if (mode.ArenaSpawn == Vector3.zero) return;
// Initialize the tiles HashSet for this mode
if (!_arenasByMode.ContainsKey(modeKey))
_arenasByMode[modeKey] = new HashSet<BuildingBlock>();
int halfSize = mode.ArenaSize / 2;
// Create arena based on shape
switch (mode.ArenaShape?.ToLower())
{
case "circle":
CreateCircleArena(modeKey, mode.ArenaSpawn, halfSize, mode.ArenaLayers);
break;
case "hexagon":
CreateHexagonArena(modeKey, mode.ArenaSpawn, halfSize, mode.ArenaLayers);
break;
case "star":
CreateStarArena(modeKey, mode.ArenaSpawn, halfSize, mode.ArenaLayers);
break;
case "heart":
CreateHeartArena(modeKey, mode.ArenaSpawn, halfSize, mode.ArenaLayers);
break;
default: // square
CreateSquareArena(modeKey, mode.ArenaSpawn, halfSize, mode.ArenaLayers);
break;
}
Puts($"Arena built for {mode.Name} with {_arenasByMode[modeKey].Count} tiles ({mode.ArenaShape} shape, {mode.ArenaPattern} pattern)");
}
private void ClearArenaForMode(string modeKey)
{
if (_arenasByMode.ContainsKey(modeKey))
{
foreach (var tile in _arenasByMode[modeKey].Where(x => x != null))
tile.Kill();
_arenasByMode[modeKey].Clear();
}
}
// Helper to get current mode's tiles
private HashSet<BuildingBlock> GetCurrentTiles()
{
if (_arenasByMode.ContainsKey(_currentGameMode))
return _arenasByMode[_currentGameMode];
return new HashSet<BuildingBlock>();
}
// --- LAYER ACTIVATION HELPERS ---
// Calculate which layer index a tile is on based on its Y position relative to arena spawn
private int GetTileLayerIndex(string modeKey, BuildingBlock tile)
{
if (!_config.GameModes.ContainsKey(modeKey)) return 0;
var mode = _config.GameModes[modeKey];
if (mode.ArenaSpawn == Vector3.zero) return 0;
float yOffset = mode.ArenaSpawn.y - tile.transform.position.y;
int layerIndex = Mathf.RoundToInt(yOffset / _config.LayerHeight);
return Mathf.Clamp(layerIndex, 0, mode.ArenaLayers - 1);
}
// Check if a layer is activated (a player has been on it)
private bool IsLayerActivated(string modeKey, int layerIndex)
{
if (!_activatedLayers.ContainsKey(modeKey)) return false;
return _activatedLayers[modeKey].Contains(layerIndex);
}
// Activate a layer (called when player touches it)
private void ActivateLayer(string modeKey, int layerIndex)
{
if (!_activatedLayers.ContainsKey(modeKey))
_activatedLayers[modeKey] = new HashSet<int>();
if (!_activatedLayers[modeKey].Contains(layerIndex))
{
_activatedLayers[modeKey].Add(layerIndex);
Puts($"[LAYER] Activated layer {layerIndex} in {modeKey} mode");
}
}
// Reset all activated layers for a mode (called when game ends/resets)
private void ResetActivatedLayers(string modeKey)
{
if (_activatedLayers.ContainsKey(modeKey))
_activatedLayers[modeKey].Clear();
}
// Get tiles only from activated layers
private HashSet<BuildingBlock> GetActivatedLayerTiles()
{
var allTiles = GetCurrentTiles();
var result = new HashSet<BuildingBlock>();
foreach (var tile in allTiles)
{
if (tile == null) continue;
int layerIndex = GetTileLayerIndex(_currentGameMode, tile);
if (IsLayerActivated(_currentGameMode, layerIndex))
{
result.Add(tile);
}
}
return result;
}
// Get tiles only from the top layer (layer 0) - fallback when no layers activated
private HashSet<BuildingBlock> GetTopLayerTiles()
{
var allTiles = GetCurrentTiles();
var result = new HashSet<BuildingBlock>();
foreach (var tile in allTiles)
{
if (tile == null) continue;
int layerIndex = GetTileLayerIndex(_currentGameMode, tile);
if (layerIndex == 0)
{
result.Add(tile);
}
}
return result;
}
[ConsoleCommand("ft.canceljoin")]
private void ConsoleCancelJoin(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player == null) return;
CuiHelper.DestroyUi(player, "FT_JoinConfirm");
_playersInSphere.Remove(player.userID);
TeleportToLobby(player);
}
void OnPlayerDisconnected(BasePlayer player, string reason)
{
// Clean up UI for disconnected players
CuiHelper.DestroyUi(player, "FT_Menu");
CuiHelper.DestroyUi(player, "FT_HUD");
CuiHelper.DestroyUi(player, "FT_LYR");
CuiHelper.DestroyUi(player, "FT_Leaderboard");
CuiHelper.DestroyUi(player, "FT_Countdown");
CuiHelper.DestroyUi(player, "FT_JoinConfirm");
// Remove from sphere check
_playersInSphere.Remove(player.userID);
// Remove from active players if in game
if (_activePlayers.Contains(player.userID))
{
_activePlayers.Remove(player.userID);
_playerGameMode.Remove(player.userID);
UpdateHUD();
}
// Remove from spectators
if (_spectators.Contains(player.userID))
{
_spectators.Remove(player.userID);
_playerGameMode.Remove(player.userID);
}
}
// --- COMMANDS ---
[ChatCommand("sr")]
private void CmdSongRequest(BasePlayer player, string cmd, string[] args) {
if (args.Length == 0) { player.ChatMessage("<color=#1DB954>[FALLING TILES]</color> Usage: /sr <song name>"); return; }
// Check if arena is ready
if (GetCurrentTiles().Count == 0) {
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> No arena ready! Join a game mode first via the teleport spheres.");
return;
}
// Check if there are players waiting
if (_activePlayers.Count == 0) {
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> No players in the arena! Join a game mode first.");
return;
}
string query = UnityWebRequest.EscapeURL(string.Join(" ", args));
webrequest.Enqueue($"{_config.NgrokLink}/request-song?q={query}", null, (code, res) => {
if (code != 200) {
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> Failed to request song!");
} else {
player.ChatMessage("<color=#1DB954>[FALLING TILES]</color> Song requested!");
// Start the game if not already active (arena and players already validated above)
if (!_gameActive) {
StartCountdown();
}
}
}, this);
}
[ChatCommand("srl")]
private void CmdLink(BasePlayer player) {
Item n = ItemManager.CreateByName("note", 1);
if (n == null) return;
n.name = "Falling Tiles Access Pass";
n.text = _config.NgrokLink;
if (!n.MoveToContainer(player.inventory.containerBelt)) n.MoveToContainer(player.inventory.containerMain);
player.ChatMessage("<color=#1DB954>[FALLING TILES]</color> Link forced to your hotbar!");
}
[ChatCommand("ftjoin")]
private void CmdJoin(BasePlayer player, string cmd, string[] args) {
if (_gameActive) {
player.ChatMessage("<color=#FF0000>[FALLING TILES]</color> Game already in progress! You'll be added as spectator.");
AddSpectator(player);
return;
}
if (!_activePlayers.Contains(player.userID)) {
_activePlayers.Add(player.userID);
if (!_playerData.ContainsKey(player.userID))
_playerData[player.userID] = new PlayerData();
_playerGameMode[player.userID] = _currentGameMode;
player.ChatMessage("<color=#1DB954>[FALLING TILES]</color> You joined the game!");
UpdateHUD();
}
}
[ChatCommand("ftleave")]
private void CmdLeave(BasePlayer player, string cmd, string[] args) {
bool wasInGame = false;
if (_activePlayers.Contains(player.userID)) {
_activePlayers.Remove(player.use
// ... [Code preview truncated due to length. Full source in attached file] ...