DecorDef.cs

Defines DecorDef and DecorPiece for aquarium decor. DecorDef is a data class with many predefined static entries (props, backdrops, substrates), plus catalog and lookup helpers. DecorPiece represents an instance placed in a tank with position, velocity, held state and computed shortcuts to its Def and IsMoving state.

Reflection
namespace NoChillquarium;

/// <summary>
/// Placeable tank ornaments. Sizes are tank pixels — props should dwarf fish
/// (goldfish ≈ 52×36). GroundSink buries the sprite base into gravel so plants
/// don't look like floating islands.
/// </summary>
public sealed class DecorDef
{
	public string Id { get; init; }
	public string Name { get; init; }
	public string Note { get; init; }
	public double Cost { get; init; }
	public float Width { get; init; }
	public float Height { get; init; }
	public string CssClass { get; init; }
	public string SpritePath { get; init; }
	public float ComfortBonus { get; init; }
	public float ChaosOnPlace { get; init; }
	/// <summary>How many px of the sprite bottom sink into the gravel band.</summary>
	public float GroundSink { get; init; }
	/// <summary>Full-bleed tank back-glass poster. One per tank, not a gravel prop.</summary>
	public bool IsBackdrop { get; init; }
	/// <summary>Tank-floor gravel / sand skin. One per tank, not a prop.</summary>
	public bool IsSubstrate { get; init; }

	public bool HasSprite => SpriteCatalog.Has( Id );
	public string SpriteId => Id;

	public static readonly DecorDef Plant = new()
	{
		Id = "plant_green",
		Name = "Plastic Plant",
		Note = "chill greenery",
		Cost = 10,
		Width = 140,
		Height = 320,
		CssClass = "decor-plant",
		SpritePath = "plant_green",
		ComfortBonus = 0.05f,
		// Dirt mound in the art must sit inside the gravel strip
		GroundSink = 48f
	};

	public static readonly DecorDef Treasure = new()
	{
		Id = "treasure_chest",
		Name = "Treasure Chest",
		Note = "bubbles optional",
		Cost = 45,
		// Art ~91×96 — keep a solid grab box without a huge empty frame.
		Width = 140,
		Height = 120,
		CssClass = "decor-chest",
		SpritePath = "treasure_chest",
		ComfortBonus = 0.06f,
		GroundSink = 10f
	};

	public static readonly DecorDef BubbleWand = new()
	{
		Id = "bubble_wand",
		Name = "Bubble Wand",
		Note = "party mode",
		Cost = 18,
		Width = 90,
		Height = 340,
		CssClass = "decor-bubbler",
		SpritePath = "bubble_wand",
		ComfortBonus = 0.04f,
		GroundSink = 16f
	};

	public static readonly DecorDef FortyOz = new()
	{
		Id = "forty_oz",
		Name = "40oz Bottle",
		Note = "no-chill centerpiece",
		Cost = 22,
		// Art is 96×96 — tall-ish tank prop, not a skyscraper hitbox.
		Width = 96,
		Height = 160,
		CssClass = "decor-forty",
		SpritePath = "forty_oz",
		ComfortBonus = 0.03f,
		ChaosOnPlace = 2f,
		GroundSink = 12f
	};

	public static readonly DecorDef Handcuffs = new()
	{
		Id = "handcuffs_skeleton",
		Name = "Cuffs + Bone Hand",
		Note = "don't ask",
		Cost = 35,
		Width = 170,
		Height = 120,
		CssClass = "decor-cuffs",
		SpritePath = "handcuffs_skeleton",
		ComfortBonus = 0.02f,
		ChaosOnPlace = 4f,
		GroundSink = 12f
	};

	public static readonly DecorDef Boot = new()
	{
		Id = "old_boot",
		Name = "Old Boot",
		Note = "classic dumpster chic",
		Cost = 10,
		// Source art is 96×96 — keep hitbox tight so grab/throw feels right.
		Width = 128,
		Height = 128,
		CssClass = "decor-boot",
		SpritePath = "old_boot",
		ComfortBonus = 0.02f,
		ChaosOnPlace = 1f,
		GroundSink = 10f
	};

	public static readonly DecorDef Meds = new()
	{
		Id = "medicine_bottles",
		Name = "Medicine Bottles",
		Note = "vibes only (for now)",
		Cost = 16,
		Width = 120,
		Height = 120,
		CssClass = "decor-meds",
		SpritePath = "medicine_bottles",
		ComfortBonus = 0.06f,
		GroundSink = 10f
	};

	public static readonly DecorDef DogeStatue = new()
	{
		Id = "doge_statue",
		Name = "Doge Statue",
		Note = "much worship",
		Cost = 150,
		Width = 180,
		Height = 240,
		CssClass = "decor-doge",
		SpritePath = "doge_statue",
		ComfortBonus = 0.12f,
		GroundSink = 14f
	};

	public static readonly DecorDef M80Crate = new()
	{
		Id = "m80_crate",
		Name = "M80 Crate",
		Note = "decorative. mostly.",
		Cost = 30,
		Width = 180,
		Height = 140,
		CssClass = "decor-m80crate",
		SpritePath = "m80_crate",
		ComfortBonus = 0.01f,
		ChaosOnPlace = 3f,
		GroundSink = 12f
	};

	// CSS-only props (no sprite) — variety for tank flex / hybrids later
	public static readonly DecorDef Rock = new()
	{
		Id = "rock_pile",
		Name = "Rock Pile",
		Note = "boring on purpose",
		Cost = 8,
		Width = 110,
		Height = 70,
		CssClass = "decor-rock",
		SpritePath = "rock_pile",
		ComfortBonus = 0.02f,
		GroundSink = 14f
	};

	public static readonly DecorDef Castle = new()
	{
		Id = "plastic_castle",
		Name = "Plastic Castle",
		Note = "every tank's lie",
		Cost = 28,
		Width = 130,
		Height = 160,
		CssClass = "decor-castle",
		SpritePath = "plastic_castle",
		ComfortBonus = 0.05f,
		GroundSink = 12f
	};

	public static readonly DecorDef Anchor = new()
	{
		Id = "rusty_anchor",
		Name = "Rusty Anchor",
		Note = "salt flex",
		Cost = 38,
		Width = 90,
		Height = 140,
		CssClass = "decor-anchor",
		SpritePath = "rusty_anchor",
		ComfortBonus = 0.04f,
		ChaosOnPlace = 1f,
		GroundSink = 10f
	};

	public static readonly DecorDef RoadCone = new()
	{
		Id = "road_cone",
		Name = "Road Cone",
		Note = "construction chic",
		Cost = 14,
		Width = 70,
		Height = 100,
		CssClass = "decor-cone",
		SpritePath = "road_cone",
		ComfortBonus = 0.02f,
		ChaosOnPlace = 1.5f,
		GroundSink = 8f
	};

	public static readonly DecorDef LavaLamp = new()
	{
		Id = "lava_lamp",
		Name = "Lava Lamp",
		Note = "vibes only",
		Cost = 55,
		Width = 70,
		Height = 150,
		CssClass = "decor-lava",
		SpritePath = "lava_lamp",
		ComfortBonus = 0.07f,
		GroundSink = 10f
	};

	public static readonly DecorDef PizzaBox = new()
	{
		Id = "pizza_box",
		Name = "Pizza Box",
		Note = "ecosystem",
		Cost = 12,
		Width = 120,
		Height = 40,
		CssClass = "decor-pizza",
		SpritePath = "pizza_box",
		ComfortBonus = 0.01f,
		ChaosOnPlace = 2f,
		GroundSink = 6f
	};

	public static readonly DecorDef Trophy = new()
	{
		Id = "cheap_trophy",
		Name = "Cheap Trophy",
		Note = "participation",
		Cost = 70,
		Width = 80,
		Height = 110,
		CssClass = "decor-trophy",
		SpritePath = "cheap_trophy",
		ComfortBonus = 0.08f,
		GroundSink = 8f
	};

	public static readonly DecorDef FakeCoral = new()
	{
		Id = "fake_coral",
		Name = "Fake Coral",
		Note = "salt starter kit",
		Cost = 32,
		Width = 120,
		Height = 100,
		CssClass = "decor-coral",
		SpritePath = "fake_coral",
		ComfortBonus = 0.05f,
		GroundSink = 12f
	};

	public static readonly DecorDef BackdropNone = new()
	{
		Id = "bg_none",
		Name = "Plain Glass",
		Note = "peel the poster",
		Cost = 0,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_none",
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropNewspaper = new()
	{
		Id = "bg_newspaper",
		Name = "Newspaper",
		Note = "lot sale this weekend",
		Cost = 8,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_newspaper",
		ComfortBonus = 0.02f,
		ChaosOnPlace = 0.5f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropMagazines = new()
	{
		Id = "bg_magazines",
		Name = "Pinup Mags",
		Note = "adults only · tanked babes",
		Cost = 18,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_magazines",
		ComfortBonus = 0.03f,
		ChaosOnPlace = 2.5f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropAqua = new()
	{
		Id = "bg_aqua_poster",
		Name = "Castle Poster",
		Note = "pet store $3.99",
		Cost = 16,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_aqua_poster",
		ComfortBonus = 0.06f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropReef = new()
	{
		Id = "bg_reef_poster",
		Name = "Reef Poster",
		Note = "generic paradise",
		Cost = 22,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_reef_poster",
		ComfortBonus = 0.07f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropValueMenu = new()
	{
		Id = "bg_value_menu",
		Name = "Value Meal Sign",
		Note = "$1 · tax not included",
		Cost = 14,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_value_menu",
		ComfortBonus = 0.03f,
		ChaosOnPlace = 1.5f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropGreek = new()
	{
		Id = "bg_greek_ruins",
		Name = "Greek Ruins",
		Note = "acropolis aquarium",
		Cost = 28,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_greek_ruins",
		ComfortBonus = 0.08f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropWood = new()
	{
		Id = "bg_wood_panel",
		Name = "Wood Paneling",
		Note = "trailer chic",
		Cost = 12,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_wood_panel",
		ComfortBonus = 0.04f,
		IsBackdrop = true
	};

	public static readonly DecorDef BackdropLeopard = new()
	{
		Id = "bg_leopard",
		Name = "Leopard Towel",
		Note = "motel souvenir",
		Cost = 20,
		Width = 1280,
		Height = 620,
		CssClass = "decor-backdrop",
		SpritePath = "bg_leopard",
		ComfortBonus = 0.04f,
		ChaosOnPlace = 1.2f,
		IsBackdrop = true
	};

	public static readonly DecorDef GravelNone = new()
	{
		Id = "gravel_none",
		Name = "Lot Dirt",
		Note = "stock brown",
		Cost = 0,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_none",
		IsSubstrate = true
	};

	public static readonly DecorDef GravelBrown = new()
	{
		Id = "gravel_brown",
		Name = "River Gravel",
		Note = "pet-store classic",
		Cost = 6,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_brown",
		ComfortBonus = 0.02f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelNeon = new()
	{
		Id = "gravel_neon",
		Name = "Hot Pink Gravel",
		Note = "mall neon",
		Cost = 14,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_neon",
		ComfortBonus = 0.03f,
		ChaosOnPlace = 1.2f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelBlack = new()
	{
		Id = "gravel_black",
		Name = "Onyx Gravel",
		Note = "funeral chic",
		Cost = 12,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_black",
		ComfortBonus = 0.03f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelBlue = new()
	{
		Id = "gravel_blue",
		Name = "Caribbean Sand",
		Note = "crushed glass",
		Cost = 16,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_blue",
		ComfortBonus = 0.04f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelGold = new()
	{
		Id = "gravel_gold",
		Name = "Gold Gravel",
		Note = "much wow floor",
		Cost = 22,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_gold",
		ComfortBonus = 0.05f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelWhite = new()
	{
		Id = "gravel_white",
		Name = "Sugar Sand",
		Note = "bingo-hall pale",
		Cost = 10,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_white",
		ComfortBonus = 0.03f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelRainbow = new()
	{
		Id = "gravel_rainbow",
		Name = "Rainbow Gravel",
		Note = "party mix",
		Cost = 18,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_rainbow",
		ComfortBonus = 0.04f,
		ChaosOnPlace = 0.8f,
		IsSubstrate = true
	};

	public static readonly DecorDef GravelSlime = new()
	{
		Id = "gravel_slime",
		Name = "Slime Gravel",
		Note = "pipe runoff",
		Cost = 15,
		Width = 1280,
		Height = 42,
		CssClass = "decor-gravel",
		SpritePath = "gravel_slime",
		ComfortBonus = 0.02f,
		ChaosOnPlace = 1.4f,
		IsSubstrate = true
	};

	public static IReadOnlyList<DecorDef> Catalog { get; } = new[]
	{
		Rock,
		Boot,
		Plant,
		PizzaBox,
		RoadCone,
		BubbleWand,
		Meds,
		FortyOz,
		Castle,
		FakeCoral,
		Treasure,
		M80Crate,
		Anchor,
		Handcuffs,
		LavaLamp,
		Trophy,
		DogeStatue,
		BackdropNone,
		BackdropNewspaper,
		BackdropMagazines,
		BackdropAqua,
		BackdropReef,
		BackdropValueMenu,
		BackdropGreek,
		BackdropWood,
		BackdropLeopard,
		GravelNone,
		GravelBrown,
		GravelNeon,
		GravelBlack,
		GravelBlue,
		GravelGold,
		GravelWhite,
		GravelRainbow,
		GravelSlime
	};

	public static IEnumerable<DecorDef> Props =>
		Catalog.Where( d => !d.IsBackdrop && !d.IsSubstrate );

	public static IEnumerable<DecorDef> Backdrops =>
		Catalog.Where( d => d.IsBackdrop );

	public static IEnumerable<DecorDef> Substrates =>
		Catalog.Where( d => d.IsSubstrate );

	public static DecorDef Find( string id ) =>
		Catalog.FirstOrDefault( d => d.Id == id ) ?? Plant;

	public static DecorDef FindBackdrop( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || id == BackdropNone.Id )
			return null;
		return Catalog.FirstOrDefault( d => d.IsBackdrop && d.Id == id );
	}

	public static DecorDef FindGravel( string id )
	{
		if ( string.IsNullOrWhiteSpace( id ) || id == GravelNone.Id )
			return null;
		return Catalog.FirstOrDefault( d => d.IsSubstrate && d.Id == id );
	}
}

public sealed class DecorPiece
{
	public string DefId { get; set; }
	public float X { get; set; }
	public float Y { get; set; }
	/// <summary>Throw / tumble velocity (tank px/s). Zeroed when resting on gravel.</summary>
	public float Vx { get; set; }
	public float Vy { get; set; }
	/// <summary>True while the player is dragging this piece.</summary>
	public bool Held { get; set; }
	/// <summary>Seconds after release where floor stick is relaxed so throws launch.</summary>
	public float ThrowGrace { get; set; }

	public DecorDef Def => DecorDef.Find( DefId );

	public bool IsMoving =>
		Held || ThrowGrace > 0f || MathF.Abs( Vx ) > 2f || MathF.Abs( Vy ) > 2f;
}