Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9178202c57 | ||
|
|
6184cdba0a | ||
|
|
0fb223cdee | ||
|
|
471dc33f0e | ||
|
|
1996b9c83e | ||
|
|
94052573b3 |
@@ -11,7 +11,7 @@ PATH_BASE=
|
|||||||
# Comma separated origins allowed to connect to the hub. Empty = allow any origin.
|
# Comma separated origins allowed to connect to the hub. Empty = allow any origin.
|
||||||
ALLOWED_ORIGINS=https://game.example.com
|
ALLOWED_ORIGINS=https://game.example.com
|
||||||
|
|
||||||
# Optional gameplay tuning, passed to the server as Game__<Setting> (see GameSettings.cs).
|
# Gameplay tuning (item chance, how items split it up, item durations, other rules) lives in
|
||||||
# Add these under the server's "environment:" in docker-compose.yml to use them.
|
# config/game.json, which the server container mounts. Edits apply from the next match on.
|
||||||
# Game__ItemSpawnChance=0.1
|
# Single values can still be overridden as Game__<Setting> env vars under the server's
|
||||||
# Game__CountdownSeconds=3
|
# "environment:" in docker-compose.yml, e.g. Game__CountdownSeconds=3.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Online player-vs-player falling-block game with items.
|
|||||||
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
|
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
|
||||||
- You never see who targets you, but every opponent's items are shown beneath their mini field.
|
- You never see who targets you, but every opponent's items are shown beneath their mini field.
|
||||||
- Item names flash on top of the field they hit.
|
- Item names flash on top of the field they hit.
|
||||||
|
- Retro sound effects and a background music playlist, all synthesized in the browser. Any sound can be swapped for an audio file. The speaker button in the upper right corner opens volume sliders and mute buttons for effects and music. **M** mutes everything, **B** toggles the music, **N** skips the track.
|
||||||
|
|
||||||
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
|
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
|
||||||
|
|
||||||
@@ -52,7 +53,10 @@ The reverse proxy (attached to both networks) should route:
|
|||||||
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
|
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
|
||||||
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
|
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
|
||||||
| `PATH_BASE` | server | Prefix to accept when the proxy forwards a sub-path unstripped, e.g. `/server`. |
|
| `PATH_BASE` | server | Prefix to accept when the proxy forwards a sub-path unstripped, e.g. `/server`. |
|
||||||
| `Game__*` | server | Overrides any `GameSettings` value, e.g. `Game__ItemSpawnChance=0.2`. |
|
| `GAME_CONFIG` | server | Path of the gameplay config file. Set to `/app/config/game.json` in the compose file, which mounts `./config`. |
|
||||||
|
| `Game__*` | server | Overrides any `GameSettings` value from the config file, e.g. `Game__ItemSpawnChance=0.2`. |
|
||||||
|
|
||||||
|
Gameplay tuning lives in **`config/game.json`**: the item chance per block, each item's weight (its share of that chance) and the duration of the timed items. The server picks up edits for the next match without a restart. See [Deployment](doc/deployment.md#game-config).
|
||||||
|
|
||||||
## Extending
|
## Extending
|
||||||
|
|
||||||
@@ -74,3 +78,4 @@ The reverse proxy (attached to both networks) should route:
|
|||||||
- Block colors are in `src/theme/colors.ts`.
|
- Block colors are in `src/theme/colors.ts`.
|
||||||
- Block drawing style is in `src/render/draw.ts`.
|
- Block drawing style is in `src/render/draw.ts`.
|
||||||
- Key bindings are in `src/config/keybindings.ts`.
|
- Key bindings are in `src/config/keybindings.ts`.
|
||||||
|
- Sound files and music tracks are registered in `src/theme/assets.ts` (`soundFiles`, `musicTracks`). All volume levels are in `src/config/sound.ts`.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public sealed class Board
|
|||||||
var i = 0;
|
var i = 0;
|
||||||
foreach (var c in piece.Cells())
|
foreach (var c in piece.Cells())
|
||||||
{
|
{
|
||||||
var item = i == piece.Spec.ItemMinoIndex ? piece.Spec.ItemId : null;
|
var item = piece.Spec.Items is { } items && i < items.Count ? items[i] : null;
|
||||||
_rows[c.Y][c.X] = new Cell((byte)piece.Type, item);
|
_rows[c.Y][c.X] = new Cell((byte)piece.Type, item);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
namespace ActionBlocks.Game.Core;
|
namespace ActionBlocks.Game.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A piece definition as it travels through the queue: its shape plus an optional item
|
/// A piece definition as it travels through the queue: its shape plus optional items
|
||||||
/// hidden inside one of its minos.
|
/// hidden inside its minos. <see cref="Items"/>[i] is the item in mino i (null = none).
|
||||||
|
/// <see cref="Original"/> is set on a transformed piece and holds the piece it was before.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PieceSpec(PieceType Type, string? ItemId = null, int ItemMinoIndex = -1)
|
public sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items = null, PieceSpec? Original = null)
|
||||||
{
|
{
|
||||||
public bool HasItem => ItemId is not null;
|
public bool HasItem => Items?.Any(i => i is not null) == true;
|
||||||
|
|
||||||
|
/// <summary>The same piece as <paramref name="type"/>. Items move to the new minos in order; extras are dropped.</summary>
|
||||||
|
public PieceSpec Transform(PieceType type)
|
||||||
|
{
|
||||||
|
var original = Original ?? this;
|
||||||
|
var carried = original.Items?.Where(i => i is not null).ToList() ?? [];
|
||||||
|
string?[]? items = null;
|
||||||
|
if (carried.Count > 0)
|
||||||
|
{
|
||||||
|
items = new string?[PieceDefinitions.Minos(type, 0).Count];
|
||||||
|
for (var i = 0; i < Math.Min(items.Length, carried.Count); i++)
|
||||||
|
{
|
||||||
|
items[i] = carried[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new PieceSpec(type, items, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Undoes <see cref="Transform"/>.</summary>
|
||||||
|
public PieceSpec Restore() => Original ?? this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
|
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
|
||||||
|
|||||||
@@ -18,12 +18,23 @@ public static class PieceDefinitions
|
|||||||
[PieceType.S] = (3, [new(1, 0), new(2, 0), new(0, 1), new(1, 1)]),
|
[PieceType.S] = (3, [new(1, 0), new(2, 0), new(0, 1), new(1, 1)]),
|
||||||
[PieceType.Z] = (3, [new(0, 0), new(1, 0), new(1, 1), new(2, 1)]),
|
[PieceType.Z] = (3, [new(0, 0), new(1, 0), new(1, 1), new(2, 1)]),
|
||||||
[PieceType.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]),
|
[PieceType.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]),
|
||||||
|
[PieceType.Dot] = (1, [new(0, 0)]),
|
||||||
|
[PieceType.SmallL] = (2, [new(0, 0), new(0, 1), new(1, 1)]),
|
||||||
|
[PieceType.U] = (3, [new(0, 0), new(2, 0), new(0, 1), new(1, 1), new(2, 1)]),
|
||||||
|
[PieceType.Plus] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1), new(1, 2)]),
|
||||||
|
[PieceType.W] = (3, [new(0, 0), new(0, 1), new(1, 1), new(1, 2), new(2, 2)]),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
|
private static readonly Dictionary<PieceType, Point[][]> Rotations = BuildRotations();
|
||||||
|
|
||||||
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
|
public static IReadOnlyList<PieceType> All { get; } = Enum.GetValues<PieceType>();
|
||||||
|
|
||||||
|
/// <summary>The seven four-cell shapes dealt by the randomizer.</summary>
|
||||||
|
public static IReadOnlyList<PieceType> Standard { get; } = All.Where(t => t <= PieceType.T).ToArray();
|
||||||
|
|
||||||
|
/// <summary>Oddball shapes that only appear while a player is transformed.</summary>
|
||||||
|
public static IReadOnlyList<PieceType> Odd { get; } = All.Where(t => t > PieceType.T).ToArray();
|
||||||
|
|
||||||
public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize;
|
public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize;
|
||||||
|
|
||||||
/// <summary>Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L).</summary>
|
/// <summary>Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L).</summary>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
namespace ActionBlocks.Game.Core;
|
namespace ActionBlocks.Game.Core;
|
||||||
|
|
||||||
/// <summary>The seven four-cell piece shapes. Numeric values double as cell colors (1-7).</summary>
|
/// <summary>
|
||||||
|
/// Piece shapes. Numeric values double as cell colors: 1-7 are the seven four-cell shapes,
|
||||||
|
/// 9-13 the odd shapes dealt only during a Transformation (8 is garbage).
|
||||||
|
/// </summary>
|
||||||
public enum PieceType : byte
|
public enum PieceType : byte
|
||||||
{
|
{
|
||||||
I = 1,
|
I = 1,
|
||||||
@@ -10,6 +13,11 @@ public enum PieceType : byte
|
|||||||
S = 5,
|
S = 5,
|
||||||
Z = 6,
|
Z = 6,
|
||||||
T = 7,
|
T = 7,
|
||||||
|
Dot = 9,
|
||||||
|
SmallL = 10,
|
||||||
|
U = 11,
|
||||||
|
Plus = 12,
|
||||||
|
W = 13,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class CellColor
|
public static class CellColor
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class SevenBagRandomizer(Random random) : IRandomizer
|
|||||||
{
|
{
|
||||||
if (_bag.Count == 0)
|
if (_bag.Count == 0)
|
||||||
{
|
{
|
||||||
var pieces = PieceDefinitions.All.ToArray();
|
var pieces = PieceDefinitions.Standard.ToArray();
|
||||||
random.Shuffle(pieces);
|
random.Shuffle(pieces);
|
||||||
foreach (var p in pieces)
|
foreach (var p in pieces)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ActionBlocks.Game.Items;
|
||||||
|
|
||||||
namespace ActionBlocks.Game;
|
namespace ActionBlocks.Game;
|
||||||
|
|
||||||
/// <summary>Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay.</summary>
|
/// <summary>Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay.</summary>
|
||||||
@@ -18,10 +20,16 @@ public sealed record GameSettings
|
|||||||
public int LinesPerLevel { get; init; } = 10;
|
public int LinesPerLevel { get; init; } = 10;
|
||||||
public int MaxLevel { get; init; } = 15;
|
public int MaxLevel { get; init; } = 15;
|
||||||
|
|
||||||
/// <summary>Chance (0..1) that a newly generated piece carries an item.</summary>
|
/// <summary>Chance (0..1) that each mino of a newly generated piece carries an item.</summary>
|
||||||
public double ItemSpawnChance { get; init; } = 0.1;
|
public double ItemSpawnChance { get; init; } = 0.1;
|
||||||
public int MaxInventory { get; init; } = 10;
|
public int MaxInventory { get; init; } = 10;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-item tuning by item id (e.g. "blind"). Items missing here keep weight 1 and their default duration.
|
||||||
|
/// Usually filled from config/game.json; see <see cref="ItemRegistry.FromSettings"/>. Ids are case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ItemSettings> Items { get; init; } = new();
|
||||||
|
|
||||||
public double CountdownSeconds { get; init; } = 3;
|
public double CountdownSeconds { get; init; } = 3;
|
||||||
|
|
||||||
/// <summary>Seconds per row for a level (marathon speed curve).</summary>
|
/// <summary>Seconds per row for a level (marathon speed curve).</summary>
|
||||||
@@ -30,4 +38,50 @@ public sealed record GameSettings
|
|||||||
var l = Math.Clamp(level, 1, MaxLevel);
|
var l = Math.Clamp(level, 1, MaxLevel);
|
||||||
return Math.Pow(0.8 - (l - 1) * 0.007, l - 1);
|
return Math.Pow(0.8 - (l - 1) * 0.007, l - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Problems with the item settings, as readable messages. Empty = valid.</summary>
|
||||||
|
public IReadOnlyList<string> Validate()
|
||||||
|
{
|
||||||
|
var errors = new List<string>();
|
||||||
|
if (double.IsNaN(ItemSpawnChance) || ItemSpawnChance is < 0 or > 1)
|
||||||
|
{
|
||||||
|
errors.Add($"ItemSpawnChance must be between 0 and 1 (is {ItemSpawnChance}).");
|
||||||
|
}
|
||||||
|
foreach (var (id, item) in Items)
|
||||||
|
{
|
||||||
|
var known = ItemRegistry.Default.Get(id);
|
||||||
|
if (known is null)
|
||||||
|
{
|
||||||
|
var ids = string.Join(", ", ItemRegistry.Default.All.Select(i => i.Id));
|
||||||
|
errors.Add($"Items.{id}: unknown item. Known items: {ids}.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.Weight is { } weight && (double.IsNaN(weight) || weight < 0))
|
||||||
|
{
|
||||||
|
errors.Add($"Items.{id}.Weight must be 0 or more (is {weight}).");
|
||||||
|
}
|
||||||
|
if (item.DurationSeconds is { } duration)
|
||||||
|
{
|
||||||
|
if (known.DurationSeconds is null)
|
||||||
|
{
|
||||||
|
errors.Add($"Items.{id}: this item acts instantly and has no DurationSeconds.");
|
||||||
|
}
|
||||||
|
else if (double.IsNaN(duration) || duration <= 0)
|
||||||
|
{
|
||||||
|
errors.Add($"Items.{id}.DurationSeconds must be more than 0 (is {duration}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tuning for one item. Null = keep the default.</summary>
|
||||||
|
public sealed record ItemSettings
|
||||||
|
{
|
||||||
|
/// <summary>Relative spawn weight: the item's share is weight / sum of all weights. 0 = never spawns.</summary>
|
||||||
|
public double? Weight { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Effect length for timed items (Blackout, Transformation).</summary>
|
||||||
|
public double? DurationSeconds { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace ActionBlocks.Game.Items;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An item that can be found inside pieces and used on a player.
|
/// An item that can be found inside pieces and used on a player.
|
||||||
/// To add a new item: implement this interface and register it in <see cref="ItemRegistry"/>.
|
/// To add a new item: implement this interface and add it to <see cref="ItemRegistry.FromSettings"/>.
|
||||||
/// The frontend needs a matching icon entry for <see cref="Id"/>.
|
/// The frontend needs a matching icon entry for <see cref="Id"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IItem
|
public interface IItem
|
||||||
@@ -13,8 +13,8 @@ public interface IItem
|
|||||||
/// <summary>Name shown when the item is used.</summary>
|
/// <summary>Name shown when the item is used.</summary>
|
||||||
string DisplayName { get; }
|
string DisplayName { get; }
|
||||||
|
|
||||||
/// <summary>Relative chance of this item being picked when an item spawns.</summary>
|
/// <summary>How long the effect lasts, or null for items that act instantly.</summary>
|
||||||
int SpawnWeight => 1;
|
double? DurationSeconds => null;
|
||||||
|
|
||||||
void Apply(ItemContext context);
|
void Apply(ItemContext context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,104 @@
|
|||||||
namespace ActionBlocks.Game.Items;
|
namespace ActionBlocks.Game.Items;
|
||||||
|
|
||||||
/// <summary>All items that exist in the game. Register new items here.</summary>
|
/// <summary>All items that exist in the game, each with its spawn weight. Register new items in <see cref="FromSettings"/>.</summary>
|
||||||
public sealed class ItemRegistry
|
public sealed class ItemRegistry
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, IItem> _items;
|
private readonly Dictionary<string, (IItem Item, double Weight)> _items;
|
||||||
|
private readonly double _totalWeight;
|
||||||
|
|
||||||
public ItemRegistry(IEnumerable<IItem> items)
|
public ItemRegistry(IEnumerable<IItem> items)
|
||||||
|
: this(items.Select(i => (i, 1.0)))
|
||||||
{
|
{
|
||||||
_items = items.ToDictionary(i => i.Id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ItemRegistry Default { get; } = new(
|
public ItemRegistry(IEnumerable<(IItem Item, double Weight)> items)
|
||||||
[
|
{
|
||||||
new AddGarbageLineItem(),
|
_items = items.ToDictionary(e => e.Item.Id, e => (e.Item, Math.Max(0, e.Weight)), StringComparer.OrdinalIgnoreCase);
|
||||||
new ClearBottomLineItem(),
|
_totalWeight = _items.Values.Sum(e => e.Weight);
|
||||||
new ClearBoardItem(),
|
}
|
||||||
new SwapBoardItem(),
|
|
||||||
new InvisibilityItem(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
public IReadOnlyCollection<IItem> All => _items.Values;
|
/// <summary>The built-in items with their default durations and equal weights.</summary>
|
||||||
|
public static ItemRegistry Default { get; } = FromSettings(new GameSettings());
|
||||||
|
|
||||||
public IItem? Get(string id) => _items.GetValueOrDefault(id);
|
/// <summary>
|
||||||
|
/// The built-in items with durations and weights from <see cref="GameSettings.Items"/>.
|
||||||
|
/// Items missing there keep weight 1 and their default duration.
|
||||||
|
/// </summary>
|
||||||
|
public static ItemRegistry FromSettings(GameSettings settings)
|
||||||
|
{
|
||||||
|
// Item ids in the config are case-insensitive, like all configuration keys.
|
||||||
|
var config = new Dictionary<string, ItemSettings>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (id, item) in settings.Items)
|
||||||
|
{
|
||||||
|
config[id] = item;
|
||||||
|
}
|
||||||
|
double Duration(string id, double fallback) => config.GetValueOrDefault(id)?.DurationSeconds ?? fallback;
|
||||||
|
|
||||||
|
IItem[] items =
|
||||||
|
[
|
||||||
|
new AddGarbageLineItem(),
|
||||||
|
new ClearBottomLineItem(),
|
||||||
|
new ClearBoardItem(),
|
||||||
|
new SwapBoardItem(),
|
||||||
|
new InvisibilityItem(Duration(InvisibilityItem.ItemId, InvisibilityItem.DefaultDurationSeconds)),
|
||||||
|
new TransformationItem(Duration(TransformationItem.ItemId, TransformationItem.DefaultDurationSeconds)),
|
||||||
|
];
|
||||||
|
return new(items.Select(i => (i, config.GetValueOrDefault(i.Id)?.Weight ?? 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<IItem> All => _items.Values.Select(e => e.Item).ToList();
|
||||||
|
|
||||||
|
/// <summary>False when every weight is 0, i.e. no item can spawn.</summary>
|
||||||
|
public bool CanSpawn => _totalWeight > 0;
|
||||||
|
|
||||||
|
public IItem? Get(string id) => _items.TryGetValue(id, out var e) ? e.Item : null;
|
||||||
|
|
||||||
|
/// <summary>Fraction (0..1) of spawned items that are <paramref name="id"/>.</summary>
|
||||||
|
public double Share(string id) =>
|
||||||
|
_totalWeight > 0 && _items.TryGetValue(id, out var e) ? e.Weight / _totalWeight : 0;
|
||||||
|
|
||||||
public IItem PickRandom(Random random)
|
public IItem PickRandom(Random random)
|
||||||
{
|
{
|
||||||
var total = _items.Values.Sum(i => i.SpawnWeight);
|
var roll = random.NextDouble() * _totalWeight;
|
||||||
var roll = random.Next(total);
|
IItem? last = null;
|
||||||
foreach (var item in _items.Values)
|
foreach (var (item, weight) in _items.Values)
|
||||||
{
|
{
|
||||||
roll -= item.SpawnWeight;
|
if (weight <= 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last = item;
|
||||||
|
roll -= weight;
|
||||||
if (roll < 0)
|
if (roll < 0)
|
||||||
{
|
{
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _items.Values.First();
|
return last ?? throw new InvalidOperationException("No item has a spawn weight above 0.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Decides whether a new piece carries an item and in which of its minos.</summary>
|
/// <summary>Decides which minos of a new piece carry an item. Each mino rolls <c>chance</c> (0..1) on its own.</summary>
|
||||||
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
|
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
|
||||||
{
|
{
|
||||||
|
private readonly double _chance = double.IsNaN(chance) ? 0 : Math.Clamp(chance, 0, 1);
|
||||||
|
|
||||||
public Core.PieceSpec Decorate(Core.PieceType type)
|
public Core.PieceSpec Decorate(Core.PieceType type)
|
||||||
{
|
{
|
||||||
if (registry.All.Count == 0 || random.NextDouble() >= chance)
|
if (!registry.CanSpawn)
|
||||||
{
|
{
|
||||||
return new Core.PieceSpec(type);
|
return new Core.PieceSpec(type);
|
||||||
}
|
}
|
||||||
var item = registry.PickRandom(random);
|
var items = new string?[Core.PieceDefinitions.Minos(type, 0).Count];
|
||||||
return new Core.PieceSpec(type, item.Id, random.Next(4));
|
var any = false;
|
||||||
|
for (var i = 0; i < items.Length; i++)
|
||||||
|
{
|
||||||
|
if (random.NextDouble() < _chance)
|
||||||
|
{
|
||||||
|
items[i] = registry.PickRandom(random).Id;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any ? new Core.PieceSpec(type, items) : new Core.PieceSpec(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,29 @@ public sealed class SwapBoardItem : IItem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class InvisibilityItem : IItem
|
public sealed class InvisibilityItem(double durationSeconds = InvisibilityItem.DefaultDurationSeconds) : IItem
|
||||||
{
|
{
|
||||||
public const double DurationSeconds = 4;
|
public const string ItemId = "blind";
|
||||||
|
public const double DefaultDurationSeconds = 4;
|
||||||
|
|
||||||
public string Id => "blind";
|
public string Id => ItemId;
|
||||||
public string DisplayName => "Blackout";
|
public string DisplayName => "Blackout";
|
||||||
|
public double? DurationSeconds => durationSeconds;
|
||||||
|
|
||||||
|
// Stacks: a second Blackout adds its full duration to whatever is left.
|
||||||
public void Apply(ItemContext context) =>
|
public void Apply(ItemContext context) =>
|
||||||
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now + DurationSeconds);
|
context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TransformationItem(double durationSeconds = TransformationItem.DefaultDurationSeconds) : IItem
|
||||||
|
{
|
||||||
|
public const string ItemId = "transform";
|
||||||
|
public const double DefaultDurationSeconds = 8;
|
||||||
|
|
||||||
|
public string Id => ItemId;
|
||||||
|
public string DisplayName => "Transformation";
|
||||||
|
public double? DurationSeconds => durationSeconds;
|
||||||
|
|
||||||
|
// Stacks like Blackout.
|
||||||
|
public void Apply(ItemContext context) => context.Target.Transform(context.Now, durationSeconds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ public sealed class PlayerGame
|
|||||||
public double InvisibleUntil { get; set; }
|
public double InvisibleUntil { get; set; }
|
||||||
public bool IsInvisible => Now < InvisibleUntil;
|
public bool IsInvisible => Now < InvisibleUntil;
|
||||||
|
|
||||||
|
/// <summary>Match time until which this player's upcoming pieces are odd shapes.</summary>
|
||||||
|
public double TransformedUntil { get; private set; }
|
||||||
|
public bool IsTransformed => Now < TransformedUntil;
|
||||||
|
|
||||||
/// <summary>The player selected as item target (own id = self).</summary>
|
/// <summary>The player selected as item target (own id = self).</summary>
|
||||||
public string TargetId { get; set; }
|
public string TargetId { get; set; }
|
||||||
|
|
||||||
@@ -103,7 +107,12 @@ public sealed class PlayerGame
|
|||||||
|
|
||||||
public void Update(double deltaSeconds, double now)
|
public void Update(double deltaSeconds, double now)
|
||||||
{
|
{
|
||||||
|
var wasTransformed = IsTransformed;
|
||||||
Now = now;
|
Now = now;
|
||||||
|
if (wasTransformed && !IsTransformed)
|
||||||
|
{
|
||||||
|
RestoreTransformedPieces();
|
||||||
|
}
|
||||||
if (!IsAlive || Active is null)
|
if (!IsAlive || Active is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -280,11 +289,40 @@ public sealed class PlayerGame
|
|||||||
|
|
||||||
public void Forfeit() => TopOut();
|
public void Forfeit() => TopOut();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the queue into odd shapes for <paramref name="seconds"/> (added to any time left).
|
||||||
|
/// The falling piece stays as it is; pieces still queued or held change back when the time runs out.
|
||||||
|
/// </summary>
|
||||||
|
public void Transform(double now, double seconds)
|
||||||
|
{
|
||||||
|
TransformedUntil = Math.Max(TransformedUntil, now) + seconds;
|
||||||
|
for (var i = 0; i < _next.Count; i++)
|
||||||
|
{
|
||||||
|
if (_next[i].Original is null)
|
||||||
|
{
|
||||||
|
_next[i] = TransformRandomly(_next[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PieceSpec TransformRandomly(PieceSpec spec) =>
|
||||||
|
spec.Transform(PieceDefinitions.Odd[_random.Next(PieceDefinitions.Odd.Count)]);
|
||||||
|
|
||||||
|
private void RestoreTransformedPieces()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _next.Count; i++)
|
||||||
|
{
|
||||||
|
_next[i] = _next[i].Restore();
|
||||||
|
}
|
||||||
|
Hold = Hold?.Restore();
|
||||||
|
}
|
||||||
|
|
||||||
private void FillQueue()
|
private void FillQueue()
|
||||||
{
|
{
|
||||||
while (_next.Count < Math.Max(1, _settings.PreviewCount))
|
while (_next.Count < Math.Max(1, _settings.PreviewCount))
|
||||||
{
|
{
|
||||||
_next.Add(_itemSpawner.Decorate(_randomizer.Next()));
|
var spec = _itemSpawner.Decorate(_randomizer.Next());
|
||||||
|
_next.Add(IsTransformed ? TransformRandomly(spec) : spec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +440,8 @@ public sealed class PlayerGame
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var current = Active.Spec;
|
// A piece left over from an expired Transformation changes back once it is put away.
|
||||||
|
var current = IsTransformed ? Active.Spec : Active.Spec.Restore();
|
||||||
HoldUsed = true;
|
HoldUsed = true;
|
||||||
if (Hold is null)
|
if (Hold is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using Microsoft.Extensions.Configuration.EnvironmentVariables;
|
||||||
|
using Microsoft.Extensions.Configuration.Json;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Location of the game config file (config/game.json) and the last error from reading it.
|
||||||
|
/// The file is added as a configuration source, so its "Game" section binds to <see cref="Game.GameSettings"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameConfigFile
|
||||||
|
{
|
||||||
|
private const string RelativePath = "config/game.json";
|
||||||
|
|
||||||
|
/// <summary>Full path of the file, or null when none was configured or found.</summary>
|
||||||
|
public string? Path { get; private init; }
|
||||||
|
|
||||||
|
public bool Exists => Path is not null && File.Exists(Path);
|
||||||
|
|
||||||
|
/// <summary>Why the last read failed (e.g. a JSON syntax error), or null. Cleared by <see cref="TakeLoadError"/>.</summary>
|
||||||
|
private string? _loadError;
|
||||||
|
|
||||||
|
public string? TakeLoadError() => Interlocked.Exchange(ref _loadError, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the file as a reloading configuration source. <paramref name="configuredPath"/> comes from GAME_CONFIG;
|
||||||
|
/// when empty, config/game.json is searched upwards from <paramref name="contentRoot"/> (finds the repo root in dev).
|
||||||
|
/// </summary>
|
||||||
|
public static GameConfigFile AddTo(IConfigurationBuilder configuration, string? configuredPath, string contentRoot)
|
||||||
|
{
|
||||||
|
var file = new GameConfigFile
|
||||||
|
{
|
||||||
|
Path = string.IsNullOrWhiteSpace(configuredPath)
|
||||||
|
? FindUpwards(contentRoot)
|
||||||
|
: System.IO.Path.GetFullPath(configuredPath, contentRoot),
|
||||||
|
};
|
||||||
|
if (file.Path is null)
|
||||||
|
{
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
var source = new JsonConfigurationSource
|
||||||
|
{
|
||||||
|
FileProvider = new PhysicalFileProvider(System.IO.Path.GetDirectoryName(file.Path)!),
|
||||||
|
Path = System.IO.Path.GetFileName(file.Path),
|
||||||
|
Optional = true,
|
||||||
|
ReloadOnChange = true,
|
||||||
|
// Don't crash on a broken edit; GameConfigProvider reports it and keeps the last good values.
|
||||||
|
OnLoadException = context =>
|
||||||
|
{
|
||||||
|
file._loadError = context.Exception.InnerException?.Message ?? context.Exception.Message;
|
||||||
|
context.Ignore = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Later sources win: insert before the environment variables so Game__* env vars still override the file.
|
||||||
|
var envIndex = configuration.Sources.ToList().FindIndex(s => s is EnvironmentVariablesConfigurationSource);
|
||||||
|
if (envIndex < 0)
|
||||||
|
{
|
||||||
|
configuration.Sources.Add(source);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
configuration.Sources.Insert(envIndex, source);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FindUpwards(string start)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(start); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
var candidate = System.IO.Path.Combine(dir.FullName, RelativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using ActionBlocks.Game;
|
||||||
|
using ActionBlocks.Game.Items;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>Settings and items for new matches. A match keeps the instance it started with.</summary>
|
||||||
|
public sealed record GameConfig(GameSettings Settings, ItemRegistry Items);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds the "Game" configuration section and follows changes to config/game.json.
|
||||||
|
/// An invalid config at startup stops the server; an invalid edit later is logged and the last good config stays.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GameConfigProvider : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly GameConfigFile _file;
|
||||||
|
private readonly ILogger<GameConfigProvider> _logger;
|
||||||
|
private readonly IDisposable _subscription;
|
||||||
|
private volatile GameConfig _current;
|
||||||
|
|
||||||
|
public GameConfigProvider(IConfiguration configuration, GameConfigFile file, ILogger<GameConfigProvider> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_file = file;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
var (config, errors) = Load();
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Invalid game config ({file.Path ?? "no file"}):\n {string.Join("\n ", errors)}");
|
||||||
|
}
|
||||||
|
_current = config;
|
||||||
|
if (file.Path is null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No {File} found, using built-in game settings", "config/game.json");
|
||||||
|
}
|
||||||
|
LogValues("Game config loaded", config);
|
||||||
|
_subscription = ChangeToken.OnChange(configuration.GetReloadToken, Reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GameConfig Current => _current;
|
||||||
|
|
||||||
|
private void Reload()
|
||||||
|
{
|
||||||
|
if (_file.Path is not null && !_file.Exists)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Game config {Path} is missing, keeping the previous values", _file.Path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var (config, errors) = Load();
|
||||||
|
if (config is null)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"Game config change ignored, keeping the previous values:\n {Errors}", string.Join("\n ", errors));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_current = config;
|
||||||
|
LogValues("Game config reloaded, used from the next match", config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private (GameConfig? Config, IReadOnlyList<string> Errors) Load()
|
||||||
|
{
|
||||||
|
if (_file.TakeLoadError() is { } loadError)
|
||||||
|
{
|
||||||
|
return (null, [loadError]);
|
||||||
|
}
|
||||||
|
GameSettings settings;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
settings = _configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings();
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException e)
|
||||||
|
{
|
||||||
|
// A value of the wrong type, e.g. "Weight": "high".
|
||||||
|
return (null, [e.InnerException?.Message ?? e.Message]);
|
||||||
|
}
|
||||||
|
var errors = settings.Validate();
|
||||||
|
return errors.Count > 0 ? (null, errors) : (new GameConfig(settings, ItemRegistry.FromSettings(settings)), errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogValues(string message, GameConfig config)
|
||||||
|
{
|
||||||
|
var items = config.Items.All.Select(i =>
|
||||||
|
$"{i.Id} {config.Items.Share(i.Id):P0}" + (i.DurationSeconds is { } d ? $" {d}s" : ""));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"{Message}: item chance {Chance} per block; {Items}",
|
||||||
|
message,
|
||||||
|
config.Settings.ItemSpawnChance,
|
||||||
|
string.Join(", ", items));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _subscription.Dispose();
|
||||||
|
}
|
||||||
@@ -14,11 +14,13 @@ public sealed record LobbyDto(
|
|||||||
|
|
||||||
public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby);
|
public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby);
|
||||||
|
|
||||||
public sealed record PieceDto(int Type, string? ItemId, int ItemIndex);
|
/// <summary><see cref="Items"/>[i] is the item in mino i, null if none.</summary>
|
||||||
|
public sealed record PieceDto(int Type, string?[] Items);
|
||||||
|
|
||||||
public sealed record ItemCellDto(int X, int Y, string ItemId);
|
public sealed record ItemCellDto(int X, int Y, string ItemId);
|
||||||
|
|
||||||
public sealed record ActivePieceDto(int Type, int[][] Cells, int ItemIndex, string? ItemId);
|
/// <summary><see cref="Items"/>[i] is the item in <see cref="Cells"/>[i], null if none.</summary>
|
||||||
|
public sealed record ActivePieceDto(int Type, int[][] Cells, string?[] Items);
|
||||||
|
|
||||||
public sealed record PlayerStateDto(
|
public sealed record PlayerStateDto(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -37,7 +39,8 @@ public sealed record PlayerStateDto(
|
|||||||
int Lines,
|
int Lines,
|
||||||
int Level,
|
int Level,
|
||||||
int PendingGarbage,
|
int PendingGarbage,
|
||||||
double InvisibleRemaining);
|
double InvisibleRemaining,
|
||||||
|
double TransformedRemaining);
|
||||||
|
|
||||||
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
|
/// <summary>A match event. <see cref="Type"/> is "itemUsed" or "eliminated".</summary>
|
||||||
public sealed record MatchEventDto(
|
public sealed record MatchEventDto(
|
||||||
@@ -57,4 +60,6 @@ public sealed record MatchStateDto(
|
|||||||
IReadOnlyList<PlayerStateDto> Players,
|
IReadOnlyList<PlayerStateDto> Players,
|
||||||
IReadOnlyList<MatchEventDto> Events);
|
IReadOnlyList<MatchEventDto> Events);
|
||||||
|
|
||||||
public sealed record ItemInfoDto(string Id, string Name);
|
/// <param name="Share">Fraction (0..1) of spawned items that are this item; 0 = disabled.</param>
|
||||||
|
/// <param name="DurationSeconds">Effect length for timed items, null for instant ones.</param>
|
||||||
|
public sealed record ItemInfoDto(string Id, string Name, double Share, double? DurationSeconds);
|
||||||
|
|||||||
@@ -51,16 +51,19 @@ public static class SnapshotMapper
|
|||||||
player.Lines,
|
player.Lines,
|
||||||
player.Level,
|
player.Level,
|
||||||
player.PendingGarbage,
|
player.PendingGarbage,
|
||||||
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2));
|
Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2),
|
||||||
|
Math.Round(Math.Max(0, player.TransformedUntil - match.Now), 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
|
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
|
||||||
new((int)piece.Type, ToCells(piece, board), piece.Spec.ItemMinoIndex, piece.Spec.ItemId);
|
new((int)piece.Type, ToCells(piece, board), ToItems(piece.Spec));
|
||||||
|
|
||||||
private static int[][] ToCells(ActivePiece piece, Board board) =>
|
private static int[][] ToCells(ActivePiece piece, Board board) =>
|
||||||
piece.Cells().Select(c => new[] { c.X, c.Y - board.BufferRows }).ToArray();
|
piece.Cells().Select(c => new[] { c.X, c.Y - board.BufferRows }).ToArray();
|
||||||
|
|
||||||
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, spec.ItemId, spec.ItemMinoIndex);
|
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, ToItems(spec));
|
||||||
|
|
||||||
|
private static string?[] ToItems(PieceSpec spec) => spec.Items?.ToArray() ?? new string?[PieceDefinitions.Minos(spec.Type, 0).Count];
|
||||||
|
|
||||||
private static MatchEventDto ToDto(MatchEvent e) => e switch
|
private static MatchEventDto ToDto(MatchEvent e) => e switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ActionBlocks.Game;
|
using ActionBlocks.Game;
|
||||||
|
using ActionBlocks.Server.Config;
|
||||||
using ActionBlocks.Server.Dtos;
|
using ActionBlocks.Server.Dtos;
|
||||||
using ActionBlocks.Server.Lobbies;
|
using ActionBlocks.Server.Lobbies;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
@@ -8,7 +9,7 @@ namespace ActionBlocks.Server.Hubs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client-facing API. Server-to-client messages: "LobbyUpdated" (LobbyDto) and "MatchState" (MatchStateDto).
|
/// Client-facing API. Server-to-client messages: "LobbyUpdated" (LobbyDto) and "MatchState" (MatchStateDto).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub
|
public sealed class GameHub(LobbyManager lobbies, GameConfigProvider config) : Hub
|
||||||
{
|
{
|
||||||
public const string LobbyUpdated = "LobbyUpdated";
|
public const string LobbyUpdated = "LobbyUpdated";
|
||||||
public const string MatchState = "MatchState";
|
public const string MatchState = "MatchState";
|
||||||
@@ -51,7 +52,7 @@ public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub
|
|||||||
{
|
{
|
||||||
throw new HubException("A game is already running.");
|
throw new HubException("A game is already running.");
|
||||||
}
|
}
|
||||||
lobby.StartMatch(settings);
|
lobby.StartMatch(config.Current.Settings, config.Current.Items);
|
||||||
dto = lobby.ToDto();
|
dto = lobby.ToDto();
|
||||||
}
|
}
|
||||||
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
|
await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto);
|
||||||
@@ -67,8 +68,12 @@ public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub
|
|||||||
public void SelectTarget(string targetId) =>
|
public void SelectTarget(string targetId) =>
|
||||||
WithPlayer((lobby, player) => lobby.Match?.SelectTarget(player.Id, targetId));
|
WithPlayer((lobby, player) => lobby.Match?.SelectTarget(player.Id, targetId));
|
||||||
|
|
||||||
public IReadOnlyList<ItemInfoDto> GetItems() =>
|
/// <summary>Items of the current config with their spawn share and duration (used by the lobby help panel).</summary>
|
||||||
Game.Items.ItemRegistry.Default.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName)).ToList();
|
public IReadOnlyList<ItemInfoDto> GetItems()
|
||||||
|
{
|
||||||
|
var items = config.Current.Items;
|
||||||
|
return items.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName, items.Share(i.Id), i.DurationSeconds)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ActionBlocks.Game;
|
using ActionBlocks.Game;
|
||||||
|
using ActionBlocks.Game.Items;
|
||||||
using ActionBlocks.Server.Dtos;
|
using ActionBlocks.Server.Dtos;
|
||||||
|
|
||||||
namespace ActionBlocks.Server.Lobbies;
|
namespace ActionBlocks.Server.Lobbies;
|
||||||
@@ -48,9 +49,9 @@ public sealed class Lobby(string code)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void StartMatch(GameSettings settings)
|
public void StartMatch(GameSettings settings, ItemRegistry items)
|
||||||
{
|
{
|
||||||
Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings);
|
Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings, items);
|
||||||
MatchFinishedAt = null;
|
MatchFinishedAt = null;
|
||||||
Dirty = true;
|
Dirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using ActionBlocks.Game;
|
using ActionBlocks.Server.Config;
|
||||||
using ActionBlocks.Server.Hubs;
|
using ActionBlocks.Server.Hubs;
|
||||||
using ActionBlocks.Server.Lobbies;
|
using ActionBlocks.Server.Lobbies;
|
||||||
|
|
||||||
@@ -31,13 +31,19 @@ builder.Services
|
|||||||
.AddSignalR()
|
.AddSignalR()
|
||||||
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
.AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
// Rules can be tuned via configuration, e.g. env Game__ItemSpawnChance=0.2
|
// Rules come from the "Game" section: config/game.json (path in GAME_CONFIG, reloaded for each new match),
|
||||||
builder.Services.AddSingleton(builder.Configuration.GetSection("Game").Get<GameSettings>() ?? new GameSettings());
|
// overridable by env vars such as Game__ItemSpawnChance=0.2.
|
||||||
|
builder.Services.AddSingleton(
|
||||||
|
GameConfigFile.AddTo(builder.Configuration, builder.Configuration["GAME_CONFIG"], builder.Environment.ContentRootPath));
|
||||||
|
builder.Services.AddSingleton<GameConfigProvider>();
|
||||||
builder.Services.AddSingleton<LobbyManager>();
|
builder.Services.AddSingleton<LobbyManager>();
|
||||||
builder.Services.AddHostedService<MatchRunner>();
|
builder.Services.AddHostedService<MatchRunner>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Read the game config now, so an invalid file stops the server at startup instead of at the first match.
|
||||||
|
app.Services.GetRequiredService<GameConfigProvider>();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(pathBase))
|
if (!string.IsNullOrWhiteSpace(pathBase))
|
||||||
{
|
{
|
||||||
app.UsePathBase(pathBase);
|
app.UsePathBase(pathBase);
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using ActionBlocks.Game.Core;
|
||||||
|
using ActionBlocks.Game.Items;
|
||||||
|
using static ActionBlocks.Game.Tests.TestHelpers;
|
||||||
|
|
||||||
|
namespace ActionBlocks.Game.Tests;
|
||||||
|
|
||||||
|
public class ConfigTests
|
||||||
|
{
|
||||||
|
private static GameSettings WithItems(params (string Id, ItemSettings Item)[] items) =>
|
||||||
|
new() { Items = items.ToDictionary(i => i.Id, i => i.Item, StringComparer.OrdinalIgnoreCase) };
|
||||||
|
|
||||||
|
private static ItemContext Context(PlayerGame target, double now) => new(target, target, new Random(3), now);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_settings_keep_the_built_in_defaults()
|
||||||
|
{
|
||||||
|
var registry = ItemRegistry.FromSettings(new GameSettings());
|
||||||
|
|
||||||
|
Assert.Equal(6, registry.All.Count);
|
||||||
|
Assert.All(registry.All, i => Assert.Equal(1.0 / 6, registry.Share(i.Id), 6));
|
||||||
|
Assert.Equal(4, registry.Get("blind")!.DurationSeconds);
|
||||||
|
Assert.Equal(8, registry.Get("transform")!.DurationSeconds);
|
||||||
|
Assert.Null(registry.Get("nuke")!.DurationSeconds);
|
||||||
|
Assert.Empty(new GameSettings().Validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configured_durations_change_the_effect_length()
|
||||||
|
{
|
||||||
|
var registry = ItemRegistry.FromSettings(WithItems(
|
||||||
|
("blind", new ItemSettings { DurationSeconds = 2 }),
|
||||||
|
("transform", new ItemSettings { DurationSeconds = 3 })));
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
registry.Get("blind")!.Apply(Context(target, 10));
|
||||||
|
registry.Get("transform")!.Apply(Context(target, 10));
|
||||||
|
|
||||||
|
target.Update(0.01, 11.9);
|
||||||
|
Assert.True(target.IsInvisible);
|
||||||
|
target.Update(0.01, 12.1);
|
||||||
|
Assert.False(target.IsInvisible);
|
||||||
|
Assert.True(target.IsTransformed);
|
||||||
|
target.Update(0.01, 13.1);
|
||||||
|
Assert.False(target.IsTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Only_items_with_weight_spawn()
|
||||||
|
{
|
||||||
|
var settings = WithItems(
|
||||||
|
("add_line", new ItemSettings { Weight = 0 }),
|
||||||
|
("clear_line", new ItemSettings { Weight = 0 }),
|
||||||
|
("nuke", new ItemSettings { Weight = 0 }),
|
||||||
|
("swap", new ItemSettings { Weight = 0 }),
|
||||||
|
("transform", new ItemSettings { Weight = 0 }));
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1));
|
||||||
|
|
||||||
|
var items = Enumerable.Range(0, 50).SelectMany(_ => spawner.Decorate(PieceType.T).Items!).ToHashSet();
|
||||||
|
|
||||||
|
Assert.Equal(["blind"], items);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weights_split_the_item_chance()
|
||||||
|
{
|
||||||
|
var settings = WithItems(
|
||||||
|
("add_line", new ItemSettings { Weight = 3 }),
|
||||||
|
("nuke", new ItemSettings { Weight = 1 }),
|
||||||
|
("clear_line", new ItemSettings { Weight = 0 }),
|
||||||
|
("swap", new ItemSettings { Weight = 0 }),
|
||||||
|
("blind", new ItemSettings { Weight = 0 }),
|
||||||
|
("transform", new ItemSettings { Weight = 0 }));
|
||||||
|
var registry = ItemRegistry.FromSettings(settings);
|
||||||
|
var random = new Random(7);
|
||||||
|
|
||||||
|
var picks = Enumerable.Range(0, 4000).Select(_ => registry.PickRandom(random).Id).ToList();
|
||||||
|
|
||||||
|
Assert.Equal(0.75, registry.Share("add_line"));
|
||||||
|
Assert.Equal(0.25, registry.Share("nuke"));
|
||||||
|
Assert.InRange(picks.Count(p => p == "add_line") / 4000.0, 0.72, 0.78);
|
||||||
|
Assert.Equal(4000, picks.Count(p => p is "add_line" or "nuke"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_weights_zero_means_no_items()
|
||||||
|
{
|
||||||
|
var settings = new GameSettings
|
||||||
|
{
|
||||||
|
Items = ItemRegistry.Default.All.ToDictionary(i => i.Id, _ => new ItemSettings { Weight = 0 }),
|
||||||
|
};
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1));
|
||||||
|
|
||||||
|
Assert.Empty(settings.Validate());
|
||||||
|
Assert.False(spawner.Decorate(PieceType.T).HasItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Item_ids_are_case_insensitive()
|
||||||
|
{
|
||||||
|
var settings = new GameSettings { Items = new() { ["Blind"] = new ItemSettings { DurationSeconds = 2 } } };
|
||||||
|
|
||||||
|
Assert.Empty(settings.Validate());
|
||||||
|
Assert.Equal(2, ItemRegistry.FromSettings(settings).Get("blind")!.DurationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Missing_items_keep_weight_one()
|
||||||
|
{
|
||||||
|
var registry = ItemRegistry.FromSettings(WithItems(("nuke", new ItemSettings { Weight = 5 })));
|
||||||
|
|
||||||
|
Assert.Equal(0.5, registry.Share("nuke"));
|
||||||
|
Assert.Equal(0.1, registry.Share("blind"), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_reports_every_problem()
|
||||||
|
{
|
||||||
|
var settings = WithItems(
|
||||||
|
("add_line", new ItemSettings { Weight = -1 }),
|
||||||
|
("blind", new ItemSettings { DurationSeconds = 0 }),
|
||||||
|
("nuke", new ItemSettings { DurationSeconds = 5 }),
|
||||||
|
("laser", new ItemSettings { Weight = 1 })) with { ItemSpawnChance = 1.5 };
|
||||||
|
|
||||||
|
var errors = settings.Validate();
|
||||||
|
|
||||||
|
Assert.Equal(5, errors.Count);
|
||||||
|
Assert.Contains(errors, e => e.StartsWith("ItemSpawnChance"));
|
||||||
|
Assert.Contains(errors, e => e.StartsWith("Items.add_line.Weight"));
|
||||||
|
Assert.Contains(errors, e => e.StartsWith("Items.blind.DurationSeconds"));
|
||||||
|
Assert.Contains(errors, e => e.StartsWith("Items.nuke: this item acts instantly"));
|
||||||
|
Assert.Contains(errors, e => e.StartsWith("Items.laser: unknown item"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,24 @@ public class CoreTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rotations_keep_the_mino_count()
|
||||||
|
{
|
||||||
|
foreach (var type in PieceDefinitions.All)
|
||||||
|
{
|
||||||
|
var count = PieceDefinitions.Minos(type, 0).Count;
|
||||||
|
Assert.All(Enumerable.Range(1, 3), r => Assert.Equal(count, PieceDefinitions.Minos(type, r).Distinct().Count()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Seven_bag_never_deals_odd_shapes()
|
||||||
|
{
|
||||||
|
var randomizer = new SevenBagRandomizer(new Random(42));
|
||||||
|
|
||||||
|
Assert.All(Enumerable.Range(0, 70), _ => Assert.Contains(randomizer.Next(), PieceDefinitions.Standard));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Seven_bag_deals_every_shape_once_per_bag()
|
public void Seven_bag_deals_every_shape_once_per_bag()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -91,9 +91,163 @@ public class ItemTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Registry_contains_five_unique_items()
|
public void Blackout_durations_stack()
|
||||||
{
|
{
|
||||||
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new InvisibilityItem().Apply(Context(target, target, now: 10));
|
||||||
|
new InvisibilityItem().Apply(Context(target, target, now: 12));
|
||||||
|
|
||||||
|
target.Update(0.01, 17.9);
|
||||||
|
Assert.True(target.IsInvisible);
|
||||||
|
target.Update(0.01, 18.1);
|
||||||
|
Assert.False(target.IsInvisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Blackout_after_expiry_starts_fresh()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new InvisibilityItem().Apply(Context(target, target, now: 10));
|
||||||
|
new InvisibilityItem().Apply(Context(target, target, now: 20));
|
||||||
|
|
||||||
|
target.Update(0.01, 23.9);
|
||||||
|
Assert.True(target.IsInvisible);
|
||||||
|
target.Update(0.01, 24.1);
|
||||||
|
Assert.False(target.IsInvisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformation_turns_the_queue_into_odd_shapes_but_not_the_falling_piece()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new TransformationItem().Apply(Context(target, target, now: 10));
|
||||||
|
|
||||||
|
Assert.Equal(PieceType.T, target.Active!.Type);
|
||||||
|
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformation_deals_odd_shapes_while_active()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new TransformationItem().Apply(Context(target, target));
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
target.HandleInput(InputAction.HardDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(target.IsAlive);
|
||||||
|
Assert.Contains(target.Active!.Type, PieceDefinitions.Odd);
|
||||||
|
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Odd));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformation_expiry_restores_queue_and_hold()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b", pieces: [PieceType.T, PieceType.O, PieceType.S]);
|
||||||
|
target.Start();
|
||||||
|
var queued = target.Next.Select(p => p.Type).ToList();
|
||||||
|
|
||||||
|
new TransformationItem().Apply(Context(target, target));
|
||||||
|
target.HandleInput(InputAction.HardDrop);
|
||||||
|
target.HandleInput(InputAction.Hold);
|
||||||
|
Assert.Contains(target.Hold!.Type, PieceDefinitions.Odd);
|
||||||
|
|
||||||
|
target.Update(0.01, 7.9);
|
||||||
|
Assert.True(target.IsTransformed);
|
||||||
|
target.Update(0.01, 8.1);
|
||||||
|
|
||||||
|
Assert.False(target.IsTransformed);
|
||||||
|
Assert.Equal(queued[0], target.Hold!.Type);
|
||||||
|
Assert.All(target.Next, p => Assert.Contains(p.Type, PieceDefinitions.Standard));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformation_durations_stack()
|
||||||
|
{
|
||||||
|
var target = CreatePlayer("b");
|
||||||
|
target.Start();
|
||||||
|
|
||||||
|
new TransformationItem().Apply(Context(target, target, now: 10));
|
||||||
|
new TransformationItem().Apply(Context(target, target, now: 12));
|
||||||
|
|
||||||
|
target.Update(0.01, 25.9);
|
||||||
|
Assert.True(target.IsTransformed);
|
||||||
|
target.Update(0.01, 26.1);
|
||||||
|
Assert.False(target.IsTransformed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Transformed_piece_keeps_its_items_and_restores_them()
|
||||||
|
{
|
||||||
|
var spec = new PieceSpec(PieceType.T, [null, "nuke", null, "swap"]);
|
||||||
|
|
||||||
|
var plus = spec.Transform(PieceType.Plus);
|
||||||
|
var dot = plus.Transform(PieceType.Dot);
|
||||||
|
|
||||||
|
Assert.Equal(["nuke", "swap", null, null, null], plus.Items);
|
||||||
|
Assert.Equal(["nuke"], dot.Items);
|
||||||
|
Assert.Same(spec, dot.Restore());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Registry_contains_six_unique_items()
|
||||||
|
{
|
||||||
|
Assert.Equal(6, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spawner_sizes_items_to_the_shape()
|
||||||
|
{
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.Default, 1, new Random(1));
|
||||||
|
|
||||||
|
Assert.Equal(5, spawner.Decorate(PieceType.Plus).Items!.Count);
|
||||||
|
Assert.Single(spawner.Decorate(PieceType.Dot).Items!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void Spawner_with_zero_chance_never_adds_items(double chance)
|
||||||
|
{
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
|
||||||
|
|
||||||
|
Assert.All(Enumerable.Range(0, 100), _ => Assert.False(spawner.Decorate(PieceType.T).HasItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
public void Spawner_with_full_chance_puts_an_item_in_every_mino(double chance)
|
||||||
|
{
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
|
||||||
|
|
||||||
|
var spec = spawner.Decorate(PieceType.T);
|
||||||
|
|
||||||
|
Assert.NotNull(spec.Items);
|
||||||
|
Assert.Equal(4, spec.Items.Count);
|
||||||
|
Assert.All(spec.Items, i => Assert.NotNull(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Spawner_rolls_each_mino_separately()
|
||||||
|
{
|
||||||
|
var spawner = new ItemSpawner(ItemRegistry.Default, 0.5, new Random(1));
|
||||||
|
|
||||||
|
var counts = Enumerable.Range(0, 1000)
|
||||||
|
.Select(_ => spawner.Decorate(PieceType.T).Items?.Count(i => i is not null) ?? 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Contains(counts, c => c >= 2);
|
||||||
|
Assert.InRange(counts.Average(), 1.8, 2.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,17 +35,18 @@ public class PlayerGameTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Item_inside_piece_is_collected_when_its_row_clears()
|
public void Items_inside_piece_are_collected_when_their_row_clears()
|
||||||
{
|
{
|
||||||
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
|
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
|
||||||
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
|
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
|
||||||
player.Start();
|
player.Start();
|
||||||
var itemId = player.Active!.Spec.ItemId;
|
var items = player.Active!.Spec.Items;
|
||||||
Assert.NotNull(itemId);
|
Assert.NotNull(items);
|
||||||
|
Assert.All(items, i => Assert.NotNull(i));
|
||||||
|
|
||||||
player.HandleInput(InputAction.HardDrop);
|
player.HandleInput(InputAction.HardDrop);
|
||||||
|
|
||||||
Assert.Equal([itemId!], player.Inventory);
|
Assert.Equal(items.Order(), player.Inventory.Order());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -58,7 +59,7 @@ public class PlayerGameTests
|
|||||||
|
|
||||||
Assert.Empty(player.Inventory);
|
Assert.Empty(player.Inventory);
|
||||||
var bottom = Bottom(player.Board);
|
var bottom = Bottom(player.Board);
|
||||||
Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null);
|
Assert.Equal(4, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].ItemId is not null));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="3" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="0" y="0" width="1" height="1" fill="#ffffff"/><rect x="7" y="0" width="1" height="1" fill="#ffffff"/><rect x="0" y="7" width="1" height="1" fill="#ffffff"/><rect x="7" y="7" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
Optional audio files. Nothing in here is required: every sound effect and the default music are
|
||||||
|
synthesized in the browser (src/audio/synthSounds.ts and src/audio/songs.ts).
|
||||||
|
|
||||||
|
- Sound effects: put e.g. `line_clear.wav` here and register it in `soundFiles` in
|
||||||
|
`src/theme/assets.ts` under its SoundId (`lineClear: asset('assets/sounds/line_clear.wav')`).
|
||||||
|
- Music: put tracks in `music/` and add them to `musicTracks` in `src/theme/assets.ts`
|
||||||
|
(`{ id: 'my_track', title: 'My Track', kind: 'file', src: asset('assets/sounds/music/my_track.mp3') }`).
|
||||||
|
|
||||||
|
Any format the browser can decode works (wav, mp3, ogg). Only use files you have the rights to.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="7" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="7" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="5" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,151 @@
|
|||||||
|
import { soundMix } from '../config/sound';
|
||||||
|
import { musicTracks, soundFiles } from '../theme/assets';
|
||||||
|
import { MusicPlaylist } from './MusicPlaylist';
|
||||||
|
import { defaultSoundSettings } from './settings';
|
||||||
|
import { synthSounds } from './synthSounds';
|
||||||
|
import type { MusicTrack, PlayOptions, SoundId, SoundPlayer, SoundSettings } from './types';
|
||||||
|
|
||||||
|
/** The same sound is not restarted more often than this, e.g. many moves inside one snapshot. */
|
||||||
|
const THROTTLE_MS = 30;
|
||||||
|
|
||||||
|
const log = (id: SoundId) => {
|
||||||
|
if (import.meta.env.DEV) console.debug('[sound]', id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default SoundPlayer built on the Web Audio API. Effects use a registered file from
|
||||||
|
* `soundFiles` when it loaded, otherwise the synth recipe; music comes from `musicTracks`.
|
||||||
|
*/
|
||||||
|
export class WebAudioPlayer implements SoundPlayer {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private sfxGain: GainNode | null = null;
|
||||||
|
private musicGain: GainNode | null = null;
|
||||||
|
/** One gain node per effect, set from soundMix.effects. */
|
||||||
|
private readonly effectGains = new Map<SoundId, GainNode>();
|
||||||
|
private playlist: MusicPlaylist | null = null;
|
||||||
|
private settings: SoundSettings = defaultSoundSettings;
|
||||||
|
private wantMusic = false;
|
||||||
|
private tempo = 1;
|
||||||
|
private readonly buffers = new Map<string, Promise<AudioBuffer | null>>();
|
||||||
|
private readonly loaded = new Map<string, AudioBuffer>();
|
||||||
|
private readonly lastPlayed = new Map<SoundId, number>();
|
||||||
|
private readonly trackListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
unlock() {
|
||||||
|
if (!this.ctx) {
|
||||||
|
try {
|
||||||
|
this.ctx = new AudioContext();
|
||||||
|
} catch {
|
||||||
|
return; // No Web Audio support: the game simply stays silent.
|
||||||
|
}
|
||||||
|
const master = this.ctx.createGain();
|
||||||
|
master.gain.value = soundMix.master;
|
||||||
|
master.connect(this.ctx.destination);
|
||||||
|
this.sfxGain = this.ctx.createGain();
|
||||||
|
this.musicGain = this.ctx.createGain();
|
||||||
|
this.sfxGain.connect(master);
|
||||||
|
this.musicGain.connect(master);
|
||||||
|
this.playlist = new MusicPlaylist(this.ctx, this.musicGain, musicTracks, (src) => this.loadBuffer(src));
|
||||||
|
this.playlist.onChange(() => this.trackListeners.forEach((l) => l()));
|
||||||
|
this.applySettings(this.settings);
|
||||||
|
this.playlist.setTempo(this.tempo);
|
||||||
|
Object.values(soundFiles).forEach((src) => void this.loadBuffer(src));
|
||||||
|
if (this.wantMusic && !this.settings.musicMuted) this.playlist.start();
|
||||||
|
}
|
||||||
|
if (this.ctx.state === 'suspended') void this.ctx.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
play(id: SoundId, options: PlayOptions = {}) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
if (!ctx || !this.sfxGain || this.settings.sfxMuted || ctx.state !== 'running') return;
|
||||||
|
if (soundMix.effects[id] <= 0) return;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - (this.lastPlayed.get(id) ?? -Infinity) < THROTTLE_MS) return;
|
||||||
|
this.lastPlayed.set(id, now);
|
||||||
|
log(id);
|
||||||
|
|
||||||
|
const file = soundFiles[id];
|
||||||
|
const buffer = file ? this.loaded.get(file) : undefined;
|
||||||
|
if (buffer) {
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(this.effectGain(id));
|
||||||
|
source.start();
|
||||||
|
} else {
|
||||||
|
synthSounds[id](ctx, this.effectGain(id), options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private effectGain(id: SoundId): GainNode {
|
||||||
|
let gain = this.effectGains.get(id);
|
||||||
|
if (!gain) {
|
||||||
|
gain = this.ctx!.createGain();
|
||||||
|
gain.gain.value = soundMix.effects[id];
|
||||||
|
gain.connect(this.sfxGain!);
|
||||||
|
this.effectGains.set(id, gain);
|
||||||
|
}
|
||||||
|
return gain;
|
||||||
|
}
|
||||||
|
|
||||||
|
startMusic() {
|
||||||
|
this.wantMusic = true;
|
||||||
|
if (!this.settings.musicMuted) this.playlist?.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopMusic() {
|
||||||
|
this.wantMusic = false;
|
||||||
|
this.playlist?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTrack() {
|
||||||
|
if (this.playlist?.playing) this.playlist.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack(): MusicTrack | null {
|
||||||
|
return this.playlist?.currentTrack() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTrackChange(listener: () => void) {
|
||||||
|
this.trackListeners.add(listener);
|
||||||
|
return () => void this.trackListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMusicTempo(factor: number) {
|
||||||
|
this.tempo = factor;
|
||||||
|
this.playlist?.setTempo(factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
applySettings(settings: SoundSettings) {
|
||||||
|
this.settings = settings;
|
||||||
|
if (!this.ctx || !this.sfxGain || !this.musicGain) return;
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
// Squared for a more natural loudness curve on the sliders.
|
||||||
|
this.sfxGain.gain.setTargetAtTime(settings.sfxMuted ? 0 : settings.sfxVolume ** 2 * soundMix.sfx, t, 0.02);
|
||||||
|
this.musicGain.gain.setTargetAtTime(settings.musicVolume ** 2 * soundMix.music, t, 0.02);
|
||||||
|
this.playlist?.setDisabled(settings.disabledTracks);
|
||||||
|
// Music that is switched off doesn't run at all, so the header shows no track.
|
||||||
|
if (settings.musicMuted) this.playlist?.stop();
|
||||||
|
else if (this.wantMusic) this.playlist?.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadBuffer(src: string): Promise<AudioBuffer | null> {
|
||||||
|
let promise = this.buffers.get(src);
|
||||||
|
if (!promise) {
|
||||||
|
const ctx = this.ctx!;
|
||||||
|
promise = fetch(src)
|
||||||
|
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(response.statusText))))
|
||||||
|
.then((data) => ctx.decodeAudioData(data))
|
||||||
|
.then((buffer) => {
|
||||||
|
this.loaded.set(src, buffer);
|
||||||
|
return buffer;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.warn(`Could not load sound ${src}, using the synth sound instead.`, error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
this.buffers.set(src, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { soundMix } from '../config/sound';
|
||||||
|
import { SynthSongPlayer } from './music';
|
||||||
|
import type { MusicTrack } from './types';
|
||||||
|
|
||||||
|
const log = (...args: unknown[]) => {
|
||||||
|
if (import.meta.env.DEV) console.debug('[music]', ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the registered music tracks one after another in shuffled order. Every enabled track is
|
||||||
|
* played once before the order is reshuffled. File tracks advance when they end, synth tracks
|
||||||
|
* after their configured number of loops.
|
||||||
|
*/
|
||||||
|
export class MusicPlaylist {
|
||||||
|
private queue: string[] = [];
|
||||||
|
private disabled = new Set<string>();
|
||||||
|
private current: MusicTrack | null = null;
|
||||||
|
private lastPlayed: string | null = null;
|
||||||
|
private stopCurrent: (() => void) | null = null;
|
||||||
|
private synth: SynthSongPlayer | null = null;
|
||||||
|
private tempo = 1;
|
||||||
|
/** Incremented on every start/stop so late async loads and end callbacks of old tracks are ignored. */
|
||||||
|
private generation = 0;
|
||||||
|
private readonly listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: AudioContext,
|
||||||
|
private readonly out: AudioNode,
|
||||||
|
private readonly tracks: MusicTrack[],
|
||||||
|
private readonly loadBuffer: (src: string) => Promise<AudioBuffer | null>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get playing() {
|
||||||
|
return this.stopCurrent !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack() {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(listener: () => void) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => void this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabled(ids: string[]) {
|
||||||
|
this.disabled = new Set(ids);
|
||||||
|
this.queue = this.queue.filter((id) => !this.disabled.has(id));
|
||||||
|
if (this.current && this.disabled.has(this.current.id) && this.playing) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTempo(factor: number) {
|
||||||
|
this.tempo = factor;
|
||||||
|
this.synth?.setTempo(factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.playing) this.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.generation++;
|
||||||
|
this.stopCurrent?.();
|
||||||
|
this.stopCurrent = null;
|
||||||
|
this.synth = null;
|
||||||
|
if (this.current) log('stop', this.current.id);
|
||||||
|
this.setCurrent(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.stop();
|
||||||
|
const track = this.takeNext();
|
||||||
|
if (!track) return;
|
||||||
|
const generation = this.generation;
|
||||||
|
this.setCurrent(track);
|
||||||
|
log('play', track.id);
|
||||||
|
|
||||||
|
// Each track gets its own level from the central mix.
|
||||||
|
const trackGain = this.ctx.createGain();
|
||||||
|
trackGain.gain.value = soundMix.tracks[track.id] ?? 1;
|
||||||
|
trackGain.connect(this.out);
|
||||||
|
|
||||||
|
// Marks playback active right away so start() during a file load doesn't start a second track.
|
||||||
|
this.stopCurrent = () => trackGain.disconnect();
|
||||||
|
const ended = () => {
|
||||||
|
if (generation === this.generation) this.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (track.kind === 'synth') {
|
||||||
|
const player = new SynthSongPlayer(this.ctx, trackGain, track.song, ended);
|
||||||
|
player.setTempo(this.tempo);
|
||||||
|
player.start();
|
||||||
|
this.synth = player;
|
||||||
|
this.stopCurrent = () => {
|
||||||
|
player.stop();
|
||||||
|
// Disconnect after the player's short fade out.
|
||||||
|
window.setTimeout(() => trackGain.disconnect(), 100);
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.loadBuffer(track.src).then((buffer) => {
|
||||||
|
if (generation !== this.generation) return;
|
||||||
|
if (!buffer) {
|
||||||
|
log('skip, could not load', track.src);
|
||||||
|
// Avoid spinning when every enabled track fails to load.
|
||||||
|
window.setTimeout(ended, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = this.ctx.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(trackGain);
|
||||||
|
source.onended = ended;
|
||||||
|
source.start();
|
||||||
|
this.stopCurrent = () => {
|
||||||
|
source.onended = null;
|
||||||
|
source.stop();
|
||||||
|
source.disconnect();
|
||||||
|
trackGain.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private takeNext(): MusicTrack | null {
|
||||||
|
const enabled = this.tracks.filter((t) => !this.disabled.has(t.id));
|
||||||
|
if (enabled.length === 0) return null;
|
||||||
|
if (this.queue.length === 0) {
|
||||||
|
this.queue = shuffle(enabled.map((t) => t.id));
|
||||||
|
// Don't repeat the last track right after reshuffling.
|
||||||
|
if (this.queue.length > 1 && this.queue[0] === this.lastPlayed) this.queue.push(this.queue.shift()!);
|
||||||
|
}
|
||||||
|
const id = this.queue.shift()!;
|
||||||
|
this.lastPlayed = id;
|
||||||
|
return this.tracks.find((t) => t.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCurrent(track: MusicTrack | null) {
|
||||||
|
if (this.current === track) return;
|
||||||
|
this.current = track;
|
||||||
|
this.listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[]): T[] {
|
||||||
|
for (let i = items.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[items[i], items[j]] = [items[j], items[i]];
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
import { muteKey, musicKey, nextTrackKey } from '../config/keybindings';
|
||||||
|
import { WebAudioPlayer } from './AudioEngine';
|
||||||
|
import { loadSoundSettings, saveSoundSettings } from './settings';
|
||||||
|
import type { SoundPlayer, SoundSettings } from './types';
|
||||||
|
|
||||||
|
interface SoundApi {
|
||||||
|
player: SoundPlayer;
|
||||||
|
settings: SoundSettings;
|
||||||
|
setSettings: (patch: Partial<SoundSettings>) => void;
|
||||||
|
toggleSfx: () => void;
|
||||||
|
toggleMusic: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SoundContext = createContext<SoundApi | null>(null);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Defaults to the Web Audio engine; pass another SoundPlayer to replace it. */
|
||||||
|
createPlayer?: () => SoundPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoundProvider({ children, createPlayer = () => new WebAudioPlayer() }: Props) {
|
||||||
|
const [player] = useState(createPlayer);
|
||||||
|
const [settings, setSettingsState] = useState(loadSoundSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
player.applySettings(settings);
|
||||||
|
saveSoundSettings(settings);
|
||||||
|
}, [player, settings]);
|
||||||
|
|
||||||
|
const setSettings = useCallback((patch: Partial<SoundSettings>) => setSettingsState((s) => ({ ...s, ...patch })), []);
|
||||||
|
const toggleSfx = useCallback(() => setSettingsState((s) => ({ ...s, sfxMuted: !s.sfxMuted })), []);
|
||||||
|
const toggleMusic = useCallback(() => setSettingsState((s) => ({ ...s, musicMuted: !s.musicMuted })), []);
|
||||||
|
// Mutes everything if anything is audible, otherwise turns everything back on.
|
||||||
|
const toggleAll = useCallback(
|
||||||
|
() =>
|
||||||
|
setSettingsState((s) => {
|
||||||
|
const mute = !s.sfxMuted || !s.musicMuted;
|
||||||
|
return { ...s, sfxMuted: mute, musicMuted: mute };
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Browsers only allow audio after a user gesture.
|
||||||
|
const unlock = () => player.unlock();
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
unlock();
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (e.repeat || target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return;
|
||||||
|
if (e.code === muteKey) toggleAll();
|
||||||
|
else if (e.code === musicKey) toggleMusic();
|
||||||
|
else if (e.code === nextTrackKey) player.nextTrack();
|
||||||
|
};
|
||||||
|
window.addEventListener('pointerdown', unlock);
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', unlock);
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [player, toggleAll, toggleMusic]);
|
||||||
|
|
||||||
|
const api = useMemo(
|
||||||
|
() => ({ player, settings, setSettings, toggleSfx, toggleMusic }),
|
||||||
|
[player, settings, setSettings, toggleSfx, toggleMusic],
|
||||||
|
);
|
||||||
|
return <SoundContext.Provider value={api}>{children}</SoundContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSound(): SoundApi {
|
||||||
|
const api = useContext(SoundContext);
|
||||||
|
if (!api) throw new Error('useSound must be used inside <SoundProvider>');
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The music track that is playing right now, or null. */
|
||||||
|
export function useCurrentTrack() {
|
||||||
|
const { player } = useSound();
|
||||||
|
return useSyncExternalStore(
|
||||||
|
useCallback((onChange) => player.onTrackChange(onChange), [player]),
|
||||||
|
() => player.currentTrack(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { noteToFreq } from './pitch';
|
||||||
|
import { noise } from './synthSounds';
|
||||||
|
import type { SynthChannel, SynthSong } from './types';
|
||||||
|
|
||||||
|
type Step = { freq: number; length: number } | { hit: 'short' | 'long' } | null;
|
||||||
|
|
||||||
|
interface ParsedChannel {
|
||||||
|
wave: SynthChannel['wave'];
|
||||||
|
volume: number;
|
||||||
|
steps: Step[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChannel(channel: SynthChannel): ParsedChannel {
|
||||||
|
const tokens = channel.pattern.trim().split(/\s+/);
|
||||||
|
const steps: Step[] = [];
|
||||||
|
let lastNote: { freq: number; length: number } | null = null;
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token === '-') {
|
||||||
|
if (lastNote) lastNote.length++;
|
||||||
|
steps.push(null);
|
||||||
|
} else if (token === '.') {
|
||||||
|
lastNote = null;
|
||||||
|
steps.push(null);
|
||||||
|
} else if (token === 'x' || token === 'o') {
|
||||||
|
lastNote = null;
|
||||||
|
steps.push({ hit: token === 'x' ? 'short' : 'long' });
|
||||||
|
} else {
|
||||||
|
lastNote = { freq: noteToFreq(token), length: 1 };
|
||||||
|
steps.push(lastNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { wave: channel.wave, volume: channel.volume, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOKAHEAD_S = 0.12;
|
||||||
|
const TIMER_MS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a SynthSong with a lookahead scheduler: a timer regularly schedules the notes of the next
|
||||||
|
* ~120 ms on the audio clock, so playback stays exact even when the timer itself is late.
|
||||||
|
*/
|
||||||
|
export class SynthSongPlayer {
|
||||||
|
private readonly channels: ParsedChannel[];
|
||||||
|
private readonly loopSteps: number;
|
||||||
|
private readonly output: GainNode;
|
||||||
|
private timer: number | undefined;
|
||||||
|
private step = 0;
|
||||||
|
private loopsDone = 0;
|
||||||
|
private nextTime = 0;
|
||||||
|
private tempo = 1;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ctx: AudioContext,
|
||||||
|
out: AudioNode,
|
||||||
|
private readonly song: SynthSong,
|
||||||
|
private readonly onEnded: () => void,
|
||||||
|
) {
|
||||||
|
this.channels = song.channels.map(parseChannel);
|
||||||
|
this.loopSteps = Math.max(...this.channels.map((c) => c.steps.length));
|
||||||
|
this.output = ctx.createGain();
|
||||||
|
this.output.connect(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTempo(factor: number) {
|
||||||
|
this.tempo = factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.nextTime = this.ctx.currentTime + 0.05;
|
||||||
|
this.timer = window.setInterval(() => this.schedule(), TIMER_MS);
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
window.clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
// Fade out quickly; notes already scheduled ahead are cut off with the output node.
|
||||||
|
const t = this.ctx.currentTime;
|
||||||
|
this.output.gain.setValueAtTime(this.output.gain.value, t);
|
||||||
|
this.output.gain.linearRampToValueAtTime(0, t + 0.05);
|
||||||
|
window.setTimeout(() => this.output.disconnect(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stepDuration() {
|
||||||
|
return 60 / (this.song.bpm * this.tempo) / this.song.stepsPerBeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedule() {
|
||||||
|
while (this.timer !== undefined && this.nextTime < this.ctx.currentTime + LOOKAHEAD_S) {
|
||||||
|
const duration = this.stepDuration();
|
||||||
|
for (const channel of this.channels) this.playStep(channel, channel.steps[this.step % channel.steps.length], duration);
|
||||||
|
this.nextTime += duration;
|
||||||
|
if (++this.step >= this.loopSteps) {
|
||||||
|
this.step = 0;
|
||||||
|
if (++this.loopsDone >= this.song.loops) {
|
||||||
|
this.onEnded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private playStep(channel: ParsedChannel, step: Step, stepDuration: number) {
|
||||||
|
if (!step) return;
|
||||||
|
const start = this.nextTime - this.ctx.currentTime;
|
||||||
|
if ('hit' in step) {
|
||||||
|
const long = step.hit === 'long';
|
||||||
|
noise(this.ctx, this.output, {
|
||||||
|
start,
|
||||||
|
duration: long ? 0.12 : 0.04,
|
||||||
|
volume: channel.volume,
|
||||||
|
filter: long ? 'bandpass' : 'highpass',
|
||||||
|
filterFrom: long ? 1800 : 7000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel.wave === 'noise') return;
|
||||||
|
const t = this.nextTime;
|
||||||
|
const end = t + step.length * stepDuration * 0.92;
|
||||||
|
const osc = this.ctx.createOscillator();
|
||||||
|
const gain = this.ctx.createGain();
|
||||||
|
osc.type = channel.wave;
|
||||||
|
osc.frequency.setValueAtTime(step.freq, t);
|
||||||
|
gain.gain.setValueAtTime(0, t);
|
||||||
|
gain.gain.linearRampToValueAtTime(channel.volume, t + 0.005);
|
||||||
|
gain.gain.setValueAtTime(channel.volume * 0.8, Math.max(t + 0.006, end - 0.03));
|
||||||
|
gain.gain.linearRampToValueAtTime(0, end);
|
||||||
|
osc.connect(gain).connect(this.output);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(end + 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const SEMITONES: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
||||||
|
|
||||||
|
/** Converts a note name like "A4", "C#5" or "Bb3" to a frequency in Hz (A4 = 440). */
|
||||||
|
export function noteToFreq(name: string): number {
|
||||||
|
const match = /^([A-G])([#b]?)(-?\d)$/.exec(name);
|
||||||
|
if (!match) throw new Error(`Invalid note name: ${name}`);
|
||||||
|
const [, letter, accidental, octave] = match;
|
||||||
|
const semitone = SEMITONES[letter] + (accidental === '#' ? 1 : accidental === 'b' ? -1 : 0);
|
||||||
|
const midi = (Number(octave) + 1) * 12 + semitone;
|
||||||
|
return 440 * 2 ** ((midi - 69) / 12);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defaultMusicVolume, defaultSfxVolume } from '../config/sound';
|
||||||
|
import type { SoundSettings } from './types';
|
||||||
|
|
||||||
|
const SETTINGS_KEY = 'actionblocks.sound';
|
||||||
|
|
||||||
|
export const defaultSoundSettings: SoundSettings = {
|
||||||
|
sfxMuted: false,
|
||||||
|
musicMuted: false,
|
||||||
|
sfxVolume: defaultSfxVolume,
|
||||||
|
musicVolume: defaultMusicVolume,
|
||||||
|
disabledTracks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const volume = (value: unknown, fallback: number) =>
|
||||||
|
typeof value === 'number' && value >= 0 && value <= 1 ? value : fallback;
|
||||||
|
|
||||||
|
export function loadSoundSettings(): SoundSettings {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial<SoundSettings> & { muted?: boolean };
|
||||||
|
// Older versions had a single "muted" flag for everything.
|
||||||
|
const legacyMuted = stored.muted === true;
|
||||||
|
return {
|
||||||
|
sfxMuted: stored.sfxMuted === true || legacyMuted,
|
||||||
|
musicMuted: stored.musicMuted === true || legacyMuted,
|
||||||
|
sfxVolume: volume(stored.sfxVolume, defaultSoundSettings.sfxVolume),
|
||||||
|
musicVolume: volume(stored.musicVolume, defaultSoundSettings.musicVolume),
|
||||||
|
disabledTracks: Array.isArray(stored.disabledTracks) ? stored.disabledTracks.filter((id) => typeof id === 'string') : [],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultSoundSettings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSoundSettings(settings: SoundSettings) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
} catch {
|
||||||
|
// Storage may be unavailable (private mode); settings are just not remembered.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { SynthSong } from './types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Original background tunes for the synth sequencer (see SynthChannel for the pattern format).
|
||||||
|
* Each line of a pattern is one bar of eighth notes. Channel volumes balance the voices of a
|
||||||
|
* song against each other; the overall level of each track is set in config/sound.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const pixelRush: SynthSong = {
|
||||||
|
bpm: 150,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
loops: 8,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
volume: 0.3,
|
||||||
|
pattern: `
|
||||||
|
A2 A3 A2 A3 A2 A3 A2 A3
|
||||||
|
F2 F3 F2 F3 F2 F3 F2 F3
|
||||||
|
C3 C4 C3 C4 C3 C4 C3 C4
|
||||||
|
G2 G3 G2 G3 G2 G3 G2 G3`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'square',
|
||||||
|
volume: 0.07,
|
||||||
|
pattern: `
|
||||||
|
A4 . C5 . E5 . D5 C5
|
||||||
|
C5 - A4 . F4 . A4 .
|
||||||
|
G4 . C5 . E5 . G5 E5
|
||||||
|
D5 - - . B4 . G4 .
|
||||||
|
A4 . C5 . E5 . A5 .
|
||||||
|
G5 F5 E5 . C5 . A4 .
|
||||||
|
E5 . D5 . C5 . B4 .
|
||||||
|
B4 - D5 - G5 - - .`,
|
||||||
|
},
|
||||||
|
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const neonStack: SynthSong = {
|
||||||
|
bpm: 132,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
loops: 6,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
volume: 0.3,
|
||||||
|
pattern: `
|
||||||
|
E2 . E2 E3 . E2 E3 .
|
||||||
|
C2 . C2 C3 . C2 C3 .
|
||||||
|
G2 . G2 G3 . G2 G3 .
|
||||||
|
D2 . D2 D3 . D2 D3 .`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'square',
|
||||||
|
volume: 0.06,
|
||||||
|
pattern: `
|
||||||
|
B4 - - . G4 - E4 .
|
||||||
|
C5 - - . E5 - D5 .
|
||||||
|
B4 - - . D5 - G5 .
|
||||||
|
F#5 - - - D5 - - .
|
||||||
|
E5 - - . B4 - G4 .
|
||||||
|
A4 - G4 - E4 - C5 .
|
||||||
|
B4 - D5 - G5 - F#5 .
|
||||||
|
E5 - - - - - - .`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
volume: 0.08,
|
||||||
|
pattern: `
|
||||||
|
. . G4 . . . B4 .
|
||||||
|
. . G4 . . . E4 .
|
||||||
|
. . B4 . . . D5 .
|
||||||
|
. . A4 . . . F#4 .`,
|
||||||
|
},
|
||||||
|
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const lastLine: SynthSong = {
|
||||||
|
bpm: 168,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
loops: 8,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
volume: 0.3,
|
||||||
|
pattern: `
|
||||||
|
D2 D2 D3 D2 D2 D3 D2 D3
|
||||||
|
A#1 A#1 A#2 A#1 A#1 A#2 A#1 A#2
|
||||||
|
C2 C2 C3 C2 C2 C3 C2 C3
|
||||||
|
A1 A1 A2 A1 A1 A2 A1 A2`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'square',
|
||||||
|
volume: 0.06,
|
||||||
|
pattern: `
|
||||||
|
D5 F5 A5 F5 D5 F5 A5 F5
|
||||||
|
D5 F5 A#5 F5 D5 F5 A#5 F5
|
||||||
|
E5 G5 C6 G5 E5 G5 C6 G5
|
||||||
|
C#5 E5 A5 E5 C#5 E5 A5 E5
|
||||||
|
A5 - G5 - F5 - E5 F5
|
||||||
|
D5 - - . A#4 - D5 .
|
||||||
|
E5 - F5 - G5 - E5 C5
|
||||||
|
C#5 - E5 - A5 - - .`,
|
||||||
|
},
|
||||||
|
{ wave: 'noise', volume: 0.05, pattern: 'x x o x x x o x' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const songs = { pixelRush, neonStack, lastLine };
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { noteToFreq } from './pitch';
|
||||||
|
import type { PlayOptions, SoundId } from './types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The volumes in these recipes shape each sound and balance its parts against each other.
|
||||||
|
* To make a whole sound louder or quieter, change its value in config/sound.ts instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Builds one sound effect from oscillators and noise, connected to `out`. */
|
||||||
|
export type SynthRecipe = (ctx: AudioContext, out: AudioNode, options: PlayOptions) => void;
|
||||||
|
|
||||||
|
interface ToneOptions {
|
||||||
|
type?: OscillatorType;
|
||||||
|
from: number;
|
||||||
|
to?: number;
|
||||||
|
/** Delay in seconds from now. */
|
||||||
|
start?: number;
|
||||||
|
duration: number;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single oscillator with an exponential fade out and an optional pitch slide. */
|
||||||
|
export function tone(ctx: AudioContext, out: AudioNode, { type = 'square', from, to = from, start = 0, duration, volume = 0.2 }: ToneOptions) {
|
||||||
|
const t = ctx.currentTime + start;
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.setValueAtTime(from, t);
|
||||||
|
if (to !== from) osc.frequency.exponentialRampToValueAtTime(to, t + duration);
|
||||||
|
gain.gain.setValueAtTime(volume, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
||||||
|
osc.connect(gain).connect(out);
|
||||||
|
osc.start(t);
|
||||||
|
osc.stop(t + duration + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NoiseOptions {
|
||||||
|
start?: number;
|
||||||
|
duration: number;
|
||||||
|
volume?: number;
|
||||||
|
filter?: BiquadFilterType;
|
||||||
|
filterFrom?: number;
|
||||||
|
filterTo?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noiseBuffers = new WeakMap<BaseAudioContext, AudioBuffer>();
|
||||||
|
|
||||||
|
function noiseBuffer(ctx: BaseAudioContext): AudioBuffer {
|
||||||
|
let buffer = noiseBuffers.get(ctx);
|
||||||
|
if (!buffer) {
|
||||||
|
buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
|
||||||
|
const data = buffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
||||||
|
noiseBuffers.set(ctx, buffer);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filtered white noise burst, for drops, hits and drums. */
|
||||||
|
export function noise(
|
||||||
|
ctx: AudioContext,
|
||||||
|
out: AudioNode,
|
||||||
|
{ start = 0, duration, volume = 0.2, filter = 'lowpass', filterFrom = 4000, filterTo = filterFrom }: NoiseOptions,
|
||||||
|
) {
|
||||||
|
const t = ctx.currentTime + start;
|
||||||
|
const source = ctx.createBufferSource();
|
||||||
|
source.buffer = noiseBuffer(ctx);
|
||||||
|
const biquad = ctx.createBiquadFilter();
|
||||||
|
biquad.type = filter;
|
||||||
|
biquad.frequency.setValueAtTime(filterFrom, t);
|
||||||
|
if (filterTo !== filterFrom) biquad.frequency.exponentialRampToValueAtTime(filterTo, t + duration);
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(volume, t);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
|
||||||
|
source.connect(biquad).connect(gain).connect(out);
|
||||||
|
source.start(t);
|
||||||
|
source.stop(t + duration + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays notes one after another, `step` seconds apart. */
|
||||||
|
function arpeggio(ctx: AudioContext, out: AudioNode, notes: string[], step: number, type: OscillatorType, volume: number, noteLength = step * 1.5) {
|
||||||
|
notes.forEach((note, i) =>
|
||||||
|
tone(ctx, out, { type, from: noteToFreq(note), start: i * step, duration: noteLength, volume }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const synthSounds: Record<SoundId, SynthRecipe> = {
|
||||||
|
move: (ctx, out) => tone(ctx, out, { from: 220, duration: 0.03, volume: 0.06 }),
|
||||||
|
rotate: (ctx, out) => tone(ctx, out, { from: 440, to: 660, duration: 0.045, volume: 0.07 }),
|
||||||
|
hold: (ctx, out) => {
|
||||||
|
tone(ctx, out, { type: 'triangle', from: 330, to: 495, duration: 0.07, volume: 0.2 });
|
||||||
|
tone(ctx, out, { type: 'triangle', from: 495, start: 0.05, duration: 0.08, volume: 0.18 });
|
||||||
|
},
|
||||||
|
hardDrop: (ctx, out) => {
|
||||||
|
noise(ctx, out, { duration: 0.12, volume: 0.25, filterFrom: 3000, filterTo: 200 });
|
||||||
|
tone(ctx, out, { from: 160, to: 60, duration: 0.1, volume: 0.12 });
|
||||||
|
},
|
||||||
|
lock: (ctx, out) => tone(ctx, out, { type: 'triangle', from: 130, to: 80, duration: 0.07, volume: 0.25 }),
|
||||||
|
lineClear: (ctx, out, { intensity = 1 }) => {
|
||||||
|
// Each extra line raises the sweep by a major third.
|
||||||
|
const base = 523 * 2 ** (((Math.min(intensity, 3) - 1) * 4) / 12);
|
||||||
|
tone(ctx, out, { from: base, to: base * 2, duration: 0.16, volume: 0.1 });
|
||||||
|
tone(ctx, out, { type: 'triangle', from: base * 1.5, to: base * 3, start: 0.04, duration: 0.16, volume: 0.15 });
|
||||||
|
noise(ctx, out, { duration: 0.18, volume: 0.06, filter: 'highpass', filterFrom: 2000, filterTo: 8000 });
|
||||||
|
},
|
||||||
|
quad: (ctx, out) => {
|
||||||
|
arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'E6'], 0.065, 'square', 0.09);
|
||||||
|
tone(ctx, out, { type: 'triangle', from: noteToFreq('C6'), start: 0.33, duration: 0.4, volume: 0.2 });
|
||||||
|
},
|
||||||
|
garbageIncoming: (ctx, out) => tone(ctx, out, { type: 'sawtooth', from: 110, to: 70, duration: 0.2, volume: 0.09 }),
|
||||||
|
itemPickup: (ctx, out) => {
|
||||||
|
tone(ctx, out, { from: noteToFreq('E6'), duration: 0.06, volume: 0.08 });
|
||||||
|
tone(ctx, out, { from: noteToFreq('B6'), start: 0.06, duration: 0.12, volume: 0.08 });
|
||||||
|
},
|
||||||
|
itemUse: (ctx, out) => {
|
||||||
|
tone(ctx, out, { from: 660, to: 1760, duration: 0.14, volume: 0.08 });
|
||||||
|
noise(ctx, out, { duration: 0.12, volume: 0.06, filter: 'highpass', filterFrom: 1000, filterTo: 6000 });
|
||||||
|
},
|
||||||
|
itemHit: (ctx, out) => {
|
||||||
|
tone(ctx, out, { type: 'sawtooth', from: 600, to: 120, duration: 0.28, volume: 0.1 });
|
||||||
|
noise(ctx, out, { duration: 0.22, volume: 0.12, filterFrom: 5000, filterTo: 300 });
|
||||||
|
},
|
||||||
|
countdown: (ctx, out) => tone(ctx, out, { from: noteToFreq('A4'), duration: 0.12, volume: 0.1 }),
|
||||||
|
go: (ctx, out) => tone(ctx, out, { from: noteToFreq('A5'), duration: 0.35, volume: 0.1 }),
|
||||||
|
knockout: (ctx, out) => {
|
||||||
|
tone(ctx, out, { from: 400, to: 100, duration: 0.3, volume: 0.08 });
|
||||||
|
noise(ctx, out, { start: 0.05, duration: 0.25, volume: 0.1, filterFrom: 1500, filterTo: 100 });
|
||||||
|
},
|
||||||
|
lose: (ctx, out) => arpeggio(ctx, out, ['G4', 'E4', 'C4', 'G3'], 0.18, 'triangle', 0.25, 0.22),
|
||||||
|
win: (ctx, out) => arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'G5', 'C6'], 0.1, 'square', 0.08, 0.16),
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/** Every sound effect the game can play. Add an id here, a recipe in synthSounds.ts and a trigger. */
|
||||||
|
export type SoundId =
|
||||||
|
| 'move'
|
||||||
|
| 'rotate'
|
||||||
|
| 'hold'
|
||||||
|
| 'hardDrop'
|
||||||
|
| 'lock'
|
||||||
|
| 'lineClear'
|
||||||
|
| 'quad'
|
||||||
|
| 'garbageIncoming'
|
||||||
|
| 'itemPickup'
|
||||||
|
| 'itemUse'
|
||||||
|
| 'itemHit'
|
||||||
|
| 'countdown'
|
||||||
|
| 'go'
|
||||||
|
| 'knockout'
|
||||||
|
| 'lose'
|
||||||
|
| 'win';
|
||||||
|
|
||||||
|
export interface PlayOptions {
|
||||||
|
/** Effect strength, e.g. number of lines cleared. Synth recipes raise their pitch with it. */
|
||||||
|
intensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One voice of a synth song. `pattern` is a space separated list of steps:
|
||||||
|
* a note name ("C4", "F#3", "A#5") starts a note, "-" holds the previous note, "." is a rest.
|
||||||
|
* Noise channels use "x" (short hit) and "o" (long hit) instead of notes.
|
||||||
|
* Channels shorter than the longest one repeat.
|
||||||
|
*/
|
||||||
|
export interface SynthChannel {
|
||||||
|
wave: OscillatorType | 'noise';
|
||||||
|
volume: number;
|
||||||
|
pattern: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthSong {
|
||||||
|
bpm: number;
|
||||||
|
/** Steps per beat, 2 = eighth notes, 4 = sixteenth notes. */
|
||||||
|
stepsPerBeat: number;
|
||||||
|
/** How often the pattern repeats before the playlist moves to the next track. */
|
||||||
|
loops: number;
|
||||||
|
channels: SynthChannel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MusicTrack =
|
||||||
|
| { id: string; title: string; kind: 'synth'; song: SynthSong }
|
||||||
|
| { id: string; title: string; kind: 'file'; src: string };
|
||||||
|
|
||||||
|
export interface SoundSettings {
|
||||||
|
/** Silences the sound effects. */
|
||||||
|
sfxMuted: boolean;
|
||||||
|
/** Turns off the background music. */
|
||||||
|
musicMuted: boolean;
|
||||||
|
/** 0..1 */
|
||||||
|
sfxVolume: number;
|
||||||
|
/** 0..1 */
|
||||||
|
musicVolume: number;
|
||||||
|
/** Ids of music tracks the player switched off. */
|
||||||
|
disabledTracks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the UI needs from the sound system. Implement it to replace the Web Audio engine. */
|
||||||
|
export interface SoundPlayer {
|
||||||
|
/** Must be called from a user gesture once; browsers block audio before that. */
|
||||||
|
unlock(): void;
|
||||||
|
play(id: SoundId, options?: PlayOptions): void;
|
||||||
|
startMusic(): void;
|
||||||
|
stopMusic(): void;
|
||||||
|
nextTrack(): void;
|
||||||
|
currentTrack(): MusicTrack | null;
|
||||||
|
/** Subscribe to track changes; returns the unsubscribe function. */
|
||||||
|
onTrackChange(listener: () => void): () => void;
|
||||||
|
/** 1 = normal speed. Only affects synth songs. */
|
||||||
|
setMusicTempo(factor: number): void;
|
||||||
|
applySettings(settings: SoundSettings): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { ActivePieceState, MatchState, PlayerState } from '../net/types';
|
||||||
|
import { useSound } from './SoundContext';
|
||||||
|
|
||||||
|
interface Previous {
|
||||||
|
me?: PlayerState;
|
||||||
|
phase?: MatchState['phase'];
|
||||||
|
countdown?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of a piece independent of its position, to tell rotations from moves. */
|
||||||
|
function shapeKey(piece: ActivePieceState) {
|
||||||
|
const minX = Math.min(...piece.cells.map(([x]) => x));
|
||||||
|
const minY = Math.min(...piece.cells.map(([, y]) => y));
|
||||||
|
return piece.cells
|
||||||
|
.map(([x, y]) => `${x - minX},${y - minY}`)
|
||||||
|
.sort()
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueKey = (p: PlayerState) => p.next.map((n) => n.type).join('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays sounds for everything that changes between two match snapshots. Snapshots are
|
||||||
|
* server-confirmed, so blocked moves or rotations stay silent.
|
||||||
|
*/
|
||||||
|
export function useGameSounds(match: MatchState, playerId: string) {
|
||||||
|
const { player } = useSound();
|
||||||
|
const previous = useRef<Previous>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const before = previous.current;
|
||||||
|
const me = match.players.find((p) => p.id === playerId);
|
||||||
|
const countdown = Math.ceil(match.countdown);
|
||||||
|
|
||||||
|
if (match.phase === 'countdown' && countdown > 0 && countdown !== before.countdown) player.play('countdown');
|
||||||
|
if (match.phase === 'running' && before.phase !== 'running') {
|
||||||
|
if (before.phase === 'countdown') player.play('go');
|
||||||
|
player.startMusic();
|
||||||
|
}
|
||||||
|
if (match.phase === 'finished' && before.phase !== 'finished') {
|
||||||
|
player.stopMusic();
|
||||||
|
if (match.winnerId === playerId) player.play('win');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of match.events) {
|
||||||
|
if (event.type === 'itemUsed' && event.targetId === playerId) player.play('itemHit');
|
||||||
|
else if (event.type === 'eliminated' && event.playerId === playerId) player.play('lose');
|
||||||
|
// The last knockout ends the match; the win sound plays instead.
|
||||||
|
else if (event.type === 'eliminated' && match.phase !== 'finished') player.play('knockout');
|
||||||
|
}
|
||||||
|
|
||||||
|
const old = before.me;
|
||||||
|
if (me && old && me.alive) {
|
||||||
|
const cleared = me.lines - old.lines;
|
||||||
|
if (cleared >= 4) player.play('quad');
|
||||||
|
else if (cleared > 0) player.play('lineClear', { intensity: cleared });
|
||||||
|
|
||||||
|
const held = !old.holdUsed && me.holdUsed;
|
||||||
|
const spawned = queueKey(me) !== queueKey(old);
|
||||||
|
if (held) player.play('hold');
|
||||||
|
else if (spawned && cleared === 0) player.play('lock');
|
||||||
|
|
||||||
|
if (!held && !spawned && me.active && old.active) {
|
||||||
|
if (shapeKey(me.active) !== shapeKey(old.active)) player.play('rotate');
|
||||||
|
else if (me.active.cells[0][0] !== old.active.cells[0][0]) player.play('move');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me.inventory.length > old.inventory.length) player.play('itemPickup');
|
||||||
|
else if (me.inventory.length < old.inventory.length) player.play('itemUse');
|
||||||
|
if (me.pendingGarbage > old.pendingGarbage) player.play('garbageIncoming');
|
||||||
|
|
||||||
|
player.setMusicTempo(Math.min(1.3, 1 + (me.level - 1) * 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
previous.current = { me, phase: match.phase, countdown };
|
||||||
|
}, [match, playerId, player]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
player.stopMusic();
|
||||||
|
player.setMusicTempo(1);
|
||||||
|
},
|
||||||
|
[player],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.corner {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect, useState, type MouseEvent } from 'react';
|
||||||
|
import { SoundMenu } from './SoundMenu';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
import styles from './CornerControls.module.css';
|
||||||
|
|
||||||
|
const CONFIRM_MS = 3000;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Shows a Leave button below the sound button. */
|
||||||
|
onLeave?: () => void;
|
||||||
|
/** Ask for a second click before leaving, e.g. because leaving forfeits a running match. */
|
||||||
|
leaveConfirm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sound menu and optional Leave button, fixed in the upper right corner on every screen. */
|
||||||
|
export function CornerControls({ onLeave, leaveConfirm = false }: Props) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirming) return;
|
||||||
|
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [confirming]);
|
||||||
|
|
||||||
|
const onLeaveClick = (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
// Keep keyboard focus off the button so Space (hard drop) doesn't press it.
|
||||||
|
e.currentTarget.blur();
|
||||||
|
if (leaveConfirm && !confirming) setConfirming(true);
|
||||||
|
else onLeave?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.corner}>
|
||||||
|
<SoundMenu />
|
||||||
|
{onLeave && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={confirming ? 'danger' : 'secondary'}
|
||||||
|
onClick={onLeaveClick}
|
||||||
|
title={leaveConfirm ? 'Leaving gives up the running match' : 'Leave the lobby'}
|
||||||
|
>
|
||||||
|
{confirming ? 'Sure?' : 'Leave'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.timers {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 3px solid;
|
||||||
|
background: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small .timer {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
border-width: 2px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small .icon {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blink during the last second so the end of the effect isn't a surprise. */
|
||||||
|
.ending {
|
||||||
|
animation: blink 0.25s steps(2) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { itemVisual } from '../theme/assets';
|
||||||
|
import styles from './EffectTimers.module.css';
|
||||||
|
|
||||||
|
/** A timed item effect on a player: the item id and the seconds it has left. */
|
||||||
|
export type Effect = [itemId: string, remaining: number];
|
||||||
|
|
||||||
|
/** Seconds left on a player's timed item effects (Blackout, Transformation), in the corner of their board. */
|
||||||
|
export function EffectTimers({ effects, small = false }: { effects: Effect[]; small?: boolean }) {
|
||||||
|
const active = effects.filter(([, remaining]) => remaining > 0);
|
||||||
|
if (active.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={[styles.timers, small ? styles.small : ''].join(' ')}>
|
||||||
|
{active.map(([itemId, remaining]) => {
|
||||||
|
const visual = itemVisual(itemId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={itemId}
|
||||||
|
className={[styles.timer, remaining < 1 ? styles.ending : ''].join(' ')}
|
||||||
|
style={{ color: visual.color, borderColor: visual.color }}
|
||||||
|
title={visual.name}
|
||||||
|
>
|
||||||
|
<img className={styles.icon} src={visual.icon} alt="" />
|
||||||
|
{remaining.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { PlayerState } from '../net/types';
|
import type { PlayerState } from '../net/types';
|
||||||
import type { ItemFlash } from '../net/useGameConnection';
|
import type { ItemFlash } from '../net/useGameConnection';
|
||||||
|
import { EffectTimers } from './EffectTimers';
|
||||||
import { BoardCanvas } from './BoardCanvas';
|
import { BoardCanvas } from './BoardCanvas';
|
||||||
import { ItemBar } from './ItemBar';
|
import { ItemBar } from './ItemBar';
|
||||||
import { ItemFlashOverlay } from './ItemFlashOverlay';
|
import { ItemFlashOverlay } from './ItemFlashOverlay';
|
||||||
@@ -29,6 +30,15 @@ export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, s
|
|||||||
</div>
|
</div>
|
||||||
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
|
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
|
||||||
<ItemFlashOverlay flashes={flashes} small />
|
<ItemFlashOverlay flashes={flashes} small />
|
||||||
|
{state.alive && (
|
||||||
|
<EffectTimers
|
||||||
|
effects={[
|
||||||
|
['blind', state.invisibleRemaining],
|
||||||
|
['transform', state.transformedRemaining],
|
||||||
|
]}
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</BoardCanvas>
|
</BoardCanvas>
|
||||||
<div className={styles.items} style={{ width: width * cell + 6 }}>
|
<div className={styles.items} style={{ width: width * cell + 6 }}>
|
||||||
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
|
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.tracks {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks input {
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useSound } from '../audio/SoundContext';
|
||||||
|
import { musicTracks } from '../theme/assets';
|
||||||
|
import styles from './MusicTracks.module.css';
|
||||||
|
|
||||||
|
/** One checkbox per registered music track; unchecked tracks are skipped by the playlist. */
|
||||||
|
export function MusicTracks() {
|
||||||
|
const { settings, setSettings } = useSound();
|
||||||
|
|
||||||
|
const toggleTrack = (id: string, enabled: boolean) =>
|
||||||
|
setSettings({
|
||||||
|
disabledTracks: enabled ? settings.disabledTracks.filter((t) => t !== id) : [...settings.disabledTracks, id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.tracks}>
|
||||||
|
{musicTracks.map((track) => (
|
||||||
|
<li key={track.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!settings.disabledTracks.includes(track.id)}
|
||||||
|
onChange={(e) => toggleTrack(track.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
{track.title}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 2px solid var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 2px 2px 0 var(--color-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel input {
|
||||||
|
width: 80px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted .label,
|
||||||
|
.muted input {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useRef, useState, type SyntheticEvent } from 'react';
|
||||||
|
import { useSound } from '../audio/SoundContext';
|
||||||
|
import type { SoundSettings } from '../audio/types';
|
||||||
|
import { uiAssets } from '../theme/assets';
|
||||||
|
import styles from './SoundMenu.module.css';
|
||||||
|
|
||||||
|
/** Give keyboard focus back to the game, so Space or the arrows don't operate the control. */
|
||||||
|
const blur = (e: SyntheticEvent<HTMLElement>) => e.currentTarget.blur();
|
||||||
|
|
||||||
|
interface ChannelProps {
|
||||||
|
label: string;
|
||||||
|
muted: boolean;
|
||||||
|
volume: number;
|
||||||
|
onToggle: () => void;
|
||||||
|
onVolume: (volume: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Channel({ label, muted, volume, onToggle, onVolume }: ChannelProps) {
|
||||||
|
return (
|
||||||
|
<div className={[styles.channel, muted ? styles.muted : ''].join(' ')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.iconButton}
|
||||||
|
onClick={(e) => {
|
||||||
|
onToggle();
|
||||||
|
blur(e);
|
||||||
|
}}
|
||||||
|
title={muted ? `Unmute ${label.toLowerCase()}` : `Mute ${label.toLowerCase()}`}
|
||||||
|
aria-pressed={muted}
|
||||||
|
>
|
||||||
|
<img src={muted ? uiAssets.soundOff : uiAssets.soundOn} alt={muted ? 'Muted' : 'On'} />
|
||||||
|
</button>
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={volume}
|
||||||
|
aria-label={`${label} volume`}
|
||||||
|
onChange={(e) => onVolume(Number(e.target.value))}
|
||||||
|
onPointerUp={blur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sound button for the upper right corner. It opens an inline strip to its left with a mute button
|
||||||
|
* and a volume slider for effects and music. It only changes settings; the game keeps running.
|
||||||
|
*/
|
||||||
|
export function SoundMenu() {
|
||||||
|
const { settings, setSettings, toggleSfx, toggleMusic } = useSound();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Publish the menu's current width (it grows when open) as a CSS variable, so headers can keep
|
||||||
|
// their content clear of it, e.g. GamePage's lobby code and track label.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const style = document.documentElement.style;
|
||||||
|
const observer = new ResizeObserver(() => style.setProperty('--sound-menu-width', `${root.offsetWidth}px`));
|
||||||
|
observer.observe(root);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
style.removeProperty('--sound-menu-width');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Moving a muted slider turns that channel back on.
|
||||||
|
const setVolume = (volume: 'sfxVolume' | 'musicVolume', mutedKey: 'sfxMuted' | 'musicMuted', value: number) => {
|
||||||
|
const patch: Partial<SoundSettings> = { [volume]: value };
|
||||||
|
if (settings[mutedKey]) patch[mutedKey] = false;
|
||||||
|
setSettings(patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allMuted = settings.sfxMuted && settings.musicMuted;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.menu} ref={rootRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[styles.iconButton, styles.toggle, open ? styles.active : ''].join(' ')}
|
||||||
|
onClick={(e) => {
|
||||||
|
setOpen((o) => !o);
|
||||||
|
blur(e);
|
||||||
|
}}
|
||||||
|
title="Sound"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<img src={allMuted ? uiAssets.soundOff : uiAssets.soundOn} alt="Sound" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={styles.strip}>
|
||||||
|
<Channel
|
||||||
|
label="SFX"
|
||||||
|
muted={settings.sfxMuted}
|
||||||
|
volume={settings.sfxVolume}
|
||||||
|
onToggle={toggleSfx}
|
||||||
|
onVolume={(v) => setVolume('sfxVolume', 'sfxMuted', v)}
|
||||||
|
/>
|
||||||
|
<Channel
|
||||||
|
label="MUSIC"
|
||||||
|
muted={settings.musicMuted}
|
||||||
|
volume={settings.musicVolume}
|
||||||
|
onToggle={toggleMusic}
|
||||||
|
onVolume={(v) => setVolume('musicVolume', 'musicMuted', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,3 +39,20 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
color: var(--color-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
box-shadow: 2px 2px 0 var(--color-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small:active:not(:disabled) {
|
||||||
|
transform: translate(1px, 1px);
|
||||||
|
box-shadow: 1px 1px 0 var(--color-dim);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import type { ButtonHTMLAttributes } from 'react';
|
|||||||
import styles from './Button.module.css';
|
import styles from './Button.module.css';
|
||||||
|
|
||||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'primary' | 'secondary';
|
variant?: 'primary' | 'secondary' | 'danger';
|
||||||
|
size?: 'normal' | 'small';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ variant = 'primary', className, ...rest }: Props) {
|
export function Button({ variant = 'primary', size = 'normal', className, ...rest }: Props) {
|
||||||
return <button className={[styles.button, styles[variant], className].filter(Boolean).join(' ')} {...rest} />;
|
return (
|
||||||
|
<button
|
||||||
|
className={[styles.button, styles[variant], size === 'small' ? styles.small : '', className].filter(Boolean).join(' ')}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export const keyBindings: Record<BoundAction, string[]> = {
|
|||||||
/** Number row keys select the item target: 1 = yourself, 2..9 = opponents in display order. */
|
/** Number row keys select the item target: 1 = yourself, 2..9 = opponents in display order. */
|
||||||
export const targetKeys = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'];
|
export const targetKeys = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'];
|
||||||
|
|
||||||
|
/** Global sound keys, active on every screen. */
|
||||||
|
export const muteKey = 'KeyM';
|
||||||
|
export const musicKey = 'KeyB';
|
||||||
|
export const nextTrackKey = 'KeyN';
|
||||||
|
|
||||||
/** Delayed auto shift and auto repeat rate in milliseconds. */
|
/** Delayed auto shift and auto repeat rate in milliseconds. */
|
||||||
export const DAS_MS = 167;
|
export const DAS_MS = 167;
|
||||||
export const ARR_MS = 33;
|
export const ARR_MS = 33;
|
||||||
@@ -35,6 +40,9 @@ export const controlsHelp: { keys: string; action: string }[] = [
|
|||||||
{ keys: 'C / SHIFT', action: 'Hold' },
|
{ keys: 'C / SHIFT', action: 'Hold' },
|
||||||
{ keys: '1 - 9', action: 'Select target (1 = you)' },
|
{ keys: '1 - 9', action: 'Select target (1 = you)' },
|
||||||
{ keys: 'F', action: 'Use item on target' },
|
{ keys: 'F', action: 'Use item on target' },
|
||||||
|
{ keys: 'M', action: 'All sound on/off' },
|
||||||
|
{ keys: 'B', action: 'Music on/off' },
|
||||||
|
{ keys: 'N', action: 'Next music track' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const simpleActions: Partial<Record<BoundAction, InputAction>> = {
|
export const simpleActions: Partial<Record<BoundAction, InputAction>> = {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SoundId } from '../audio/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central mix for developers. Every value multiplies the level a sound was designed with:
|
||||||
|
* 1 = as designed, 0 = silent, above 1 = louder (up to about 2 is sensible).
|
||||||
|
* The players' volume sliders apply on top of this.
|
||||||
|
*/
|
||||||
|
export const soundMix = {
|
||||||
|
/** Applied to everything. */
|
||||||
|
master: 1,
|
||||||
|
/** All sound effects. */
|
||||||
|
sfx: 1,
|
||||||
|
/** All background music. */
|
||||||
|
music: 0.1,
|
||||||
|
/** Per sound effect. Every SoundId must be listed, so new effects can't be forgotten. */
|
||||||
|
effects: {
|
||||||
|
move: 1,
|
||||||
|
rotate: 1,
|
||||||
|
hold: 1,
|
||||||
|
hardDrop: 1,
|
||||||
|
lock: 1,
|
||||||
|
lineClear: 1,
|
||||||
|
quad: 1,
|
||||||
|
garbageIncoming: 1,
|
||||||
|
itemPickup: 1,
|
||||||
|
itemUse: 1,
|
||||||
|
itemHit: 1,
|
||||||
|
countdown: 1,
|
||||||
|
go: 1,
|
||||||
|
knockout: 1,
|
||||||
|
lose: 1,
|
||||||
|
win: 1,
|
||||||
|
} satisfies Record<SoundId, number>,
|
||||||
|
/** Per music track, by its id in `musicTracks` (theme/assets.ts). Tracks not listed use 1. */
|
||||||
|
tracks: {
|
||||||
|
pixel_rush: 1,
|
||||||
|
neon_stack: 1,
|
||||||
|
last_line: 1,
|
||||||
|
} as Partial<Record<string, number>>,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Initial slider positions (0..1) for players without saved settings. */
|
||||||
|
export const defaultSfxVolume = 0.6;
|
||||||
|
export const defaultMusicVolume = 0.35;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
|
import { SoundProvider } from './audio/SoundContext';
|
||||||
import { preloadAssets } from './theme/assets';
|
import { preloadAssets } from './theme/assets';
|
||||||
import './theme/global.css';
|
import './theme/global.css';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ preloadAssets();
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<SoundProvider>
|
||||||
|
<App />
|
||||||
|
</SoundProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as signalR from '@microsoft/signalr';
|
import * as signalR from '@microsoft/signalr';
|
||||||
import { getServerUrl } from '../config/runtimeConfig';
|
import { getServerUrl } from '../config/runtimeConfig';
|
||||||
import type { InputAction, JoinResult, Lobby, MatchState } from './types';
|
import type { InputAction, ItemInfo, JoinResult, Lobby, MatchState } from './types';
|
||||||
|
|
||||||
export interface HubHandlers {
|
export interface HubHandlers {
|
||||||
onLobbyUpdated: (lobby: Lobby) => void;
|
onLobbyUpdated: (lobby: Lobby) => void;
|
||||||
@@ -64,6 +64,11 @@ export class HubClient {
|
|||||||
return this.invoke<void>('StartGame');
|
return this.invoke<void>('StartGame');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items with their spawn share and duration from the server config. */
|
||||||
|
getItems() {
|
||||||
|
return this.invoke<ItemInfo[]>('GetItems');
|
||||||
|
}
|
||||||
|
|
||||||
/** Fire-and-forget input; ordering is preserved by the connection. */
|
/** Fire-and-forget input; ordering is preserved by the connection. */
|
||||||
input(action: InputAction) {
|
input(action: InputAction) {
|
||||||
if (this.connection.state === signalR.HubConnectionState.Connected) {
|
if (this.connection.state === signalR.HubConnectionState.Connected) {
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ export interface Lobby {
|
|||||||
lastResults: MatchResult[] | null;
|
lastResults: MatchResult[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An item of the server's current config (config/game.json). */
|
||||||
|
export interface ItemInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Fraction (0..1) of spawned items that are this item; 0 = disabled. */
|
||||||
|
share: number;
|
||||||
|
/** Effect length for timed items, null for instant ones. */
|
||||||
|
durationSeconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JoinResult {
|
export interface JoinResult {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
lobby: Lobby;
|
lobby: Lobby;
|
||||||
@@ -30,8 +40,8 @@ export interface JoinResult {
|
|||||||
|
|
||||||
export interface PieceInfo {
|
export interface PieceInfo {
|
||||||
type: number;
|
type: number;
|
||||||
itemId: string | null;
|
/** items[i] is the item in mino i, null if none. */
|
||||||
itemIndex: number;
|
items: (string | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemCell {
|
export interface ItemCell {
|
||||||
@@ -43,8 +53,8 @@ export interface ItemCell {
|
|||||||
export interface ActivePieceState {
|
export interface ActivePieceState {
|
||||||
type: number;
|
type: number;
|
||||||
cells: [number, number][];
|
cells: [number, number][];
|
||||||
itemIndex: number;
|
/** items[i] is the item in cells[i], null if none. */
|
||||||
itemId: string | null;
|
items: (string | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
@@ -52,7 +62,8 @@ export interface PlayerState {
|
|||||||
name: string;
|
name: string;
|
||||||
alive: boolean;
|
alive: boolean;
|
||||||
place: number | null;
|
place: number | null;
|
||||||
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage. */
|
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage,
|
||||||
|
* '9'-'=' (values 9-13) odd shapes from Transformation. Decode with charCodeAt - 48. */
|
||||||
board: string;
|
board: string;
|
||||||
items: ItemCell[];
|
items: ItemCell[];
|
||||||
active: ActivePieceState | null;
|
active: ActivePieceState | null;
|
||||||
@@ -66,6 +77,7 @@ export interface PlayerState {
|
|||||||
level: number;
|
level: number;
|
||||||
pendingGarbage: number;
|
pendingGarbage: number;
|
||||||
invisibleRemaining: number;
|
invisibleRemaining: number;
|
||||||
|
transformedRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchEvent {
|
export interface MatchEvent {
|
||||||
|
|||||||
@@ -79,12 +79,13 @@ export function useGameConnection() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startGame = useCallback(() => clientRef.current!.startGame(), []);
|
const startGame = useCallback(() => clientRef.current!.startGame(), []);
|
||||||
|
const getItems = useCallback(() => clientRef.current!.getItems(), []);
|
||||||
const sendInput = useCallback((action: InputAction) => clientRef.current?.input(action), []);
|
const sendInput = useCallback((action: InputAction) => clientRef.current?.input(action), []);
|
||||||
const selectTarget = useCallback((targetId: string) => clientRef.current?.selectTarget(targetId), []);
|
const selectTarget = useCallback((targetId: string) => clientRef.current?.selectTarget(targetId), []);
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() => ({ createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget }),
|
() => ({ createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget }),
|
||||||
[createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget],
|
[createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { status, playerId, lobby, match, flashes, actions };
|
return { status, playerId, lobby, match, flashes, actions };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 12px 16px 24px;
|
padding: 16px 16px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -8,7 +8,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-height: 26px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
/* Keep clear of the fixed sound menu in the corner (CornerControls), also while it is open. */
|
||||||
|
padding-right: calc(var(--sound-menu-width, 26px) + 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobbyCode {
|
.lobbyCode {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useCurrentTrack, useSound } from '../audio/SoundContext';
|
||||||
|
import { useGameSounds } from '../audio/useGameSounds';
|
||||||
|
import { EffectTimers } from '../components/EffectTimers';
|
||||||
import { BoardCanvas } from '../components/BoardCanvas';
|
import { BoardCanvas } from '../components/BoardCanvas';
|
||||||
import { ItemBar } from '../components/ItemBar';
|
import { ItemBar } from '../components/ItemBar';
|
||||||
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
|
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import { MiniBoard } from '../components/MiniBoard';
|
import { MiniBoard } from '../components/MiniBoard';
|
||||||
import { PiecePreview } from '../components/PiecePreview';
|
import { PiecePreview } from '../components/PiecePreview';
|
||||||
|
import { CornerControls } from '../components/CornerControls';
|
||||||
import { useKeyboardInput } from '../input/useKeyboardInput';
|
import { useKeyboardInput } from '../input/useKeyboardInput';
|
||||||
import type { Lobby, MatchState } from '../net/types';
|
import type { InputAction, Lobby, MatchState } from '../net/types';
|
||||||
import type { GameActions, ItemFlash } from '../net/useGameConnection';
|
import type { GameActions, ItemFlash } from '../net/useGameConnection';
|
||||||
import { itemVisual } from '../theme/assets';
|
import { itemVisual } from '../theme/assets';
|
||||||
import styles from './GamePage.module.css';
|
import styles from './GamePage.module.css';
|
||||||
@@ -56,9 +60,22 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
|
|||||||
[me, opponents, actions],
|
[me, opponents, actions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { player } = useSound();
|
||||||
|
const track = useCurrentTrack();
|
||||||
|
useGameSounds(match, playerId);
|
||||||
|
|
||||||
|
// A hard drop can't be told apart from a normal lock in the snapshots, so it sounds on input.
|
||||||
|
const onAction = useCallback(
|
||||||
|
(action: InputAction) => {
|
||||||
|
if (action === 'HardDrop') player.play('hardDrop');
|
||||||
|
actions.sendInput(action);
|
||||||
|
},
|
||||||
|
[actions, player],
|
||||||
|
);
|
||||||
|
|
||||||
useKeyboardInput({
|
useKeyboardInput({
|
||||||
enabled: !!me?.alive && match.phase !== 'finished',
|
enabled: !!me?.alive && match.phase !== 'finished',
|
||||||
onAction: actions.sendInput,
|
onAction,
|
||||||
onTarget,
|
onTarget,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,8 +87,15 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
|
|||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Logo small />
|
<Logo small />
|
||||||
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
|
<div className={styles.headerInfo}>
|
||||||
|
{track && <span className={styles.track}>MUSIC: {track.title} (N)</span>}
|
||||||
|
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<CornerControls
|
||||||
|
onLeave={() => void actions.leaveLobby()}
|
||||||
|
leaveConfirm={!!me?.alive && match.phase !== 'finished'}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.arena}>
|
<div className={styles.arena}>
|
||||||
{me ? (
|
{me ? (
|
||||||
@@ -106,6 +130,12 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
|
|||||||
highlighted={targetId === playerId}
|
highlighted={targetId === playerId}
|
||||||
>
|
>
|
||||||
<ItemFlashOverlay flashes={flashesFor(me.id)} />
|
<ItemFlashOverlay flashes={flashesFor(me.id)} />
|
||||||
|
<EffectTimers
|
||||||
|
effects={[
|
||||||
|
['blind', me.invisibleRemaining],
|
||||||
|
['transform', me.transformedRemaining],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
{match.phase === 'countdown' && (
|
{match.phase === 'countdown' && (
|
||||||
<div className={styles.overlay}>
|
<div className={styles.overlay}>
|
||||||
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
|
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
|
import { CornerControls } from '../components/CornerControls';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Panel } from '../components/ui/Panel';
|
import { Panel } from '../components/ui/Panel';
|
||||||
import type { GameActions } from '../net/useGameConnection';
|
import type { GameActions } from '../net/useGameConnection';
|
||||||
@@ -57,6 +58,7 @@ export function HomePage({ connected, actions }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
|
<CornerControls />
|
||||||
<Logo />
|
<Logo />
|
||||||
<p className={styles.tagline}>Stack. Clear. Collect. Strike.</p>
|
<p className={styles.tagline}>Stack. Clear. Collect. Strike.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,20 @@
|
|||||||
image-rendering: pixelated;
|
image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.itemText {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemName {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemShare {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.itemDescription {
|
.itemDescription {
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
|
import { MusicTracks } from '../components/MusicTracks';
|
||||||
|
import { CornerControls } from '../components/CornerControls';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Panel } from '../components/ui/Panel';
|
import { Panel } from '../components/ui/Panel';
|
||||||
import { controlsHelp } from '../config/keybindings';
|
import { controlsHelp } from '../config/keybindings';
|
||||||
import type { Lobby } from '../net/types';
|
import type { ItemInfo, Lobby } from '../net/types';
|
||||||
import type { GameActions } from '../net/useGameConnection';
|
import type { GameActions } from '../net/useGameConnection';
|
||||||
import { itemVisuals } from '../theme/assets';
|
import { itemDescription, itemVisual, itemVisuals } from '../theme/assets';
|
||||||
import styles from './LobbyPage.module.css';
|
import styles from './LobbyPage.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +20,23 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const isHost = lobby.hostId === playerId;
|
const isHost = lobby.hostId === playerId;
|
||||||
|
// Items as configured on the server; null until loaded (or if the call fails) = built-in list.
|
||||||
|
const [items, setItems] = useState<ItemInfo[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
actions
|
||||||
|
.getItems()
|
||||||
|
.then((list) => !cancelled && setItems(list))
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
const shownItems = items
|
||||||
|
? items.filter((i) => i.share > 0).map((i) => ({ id: i.id, share: i.share, duration: i.durationSeconds }))
|
||||||
|
: Object.keys(itemVisuals).map((id) => ({ id, share: null, duration: null }));
|
||||||
|
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`;
|
const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`;
|
||||||
@@ -43,10 +62,8 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
|||||||
<main className={styles.page}>
|
<main className={styles.page}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<Logo small />
|
<Logo small />
|
||||||
<Button variant="secondary" onClick={() => void actions.leaveLobby()}>
|
|
||||||
Leave
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
|
<CornerControls onLeave={() => void actions.leaveLobby()} />
|
||||||
|
|
||||||
<Panel title="Lobby code" className={styles.codePanel}>
|
<Panel title="Lobby code" className={styles.codePanel}>
|
||||||
<div className={styles.code}>{lobby.code}</div>
|
<div className={styles.code}>{lobby.code}</div>
|
||||||
@@ -107,18 +124,33 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
|||||||
<Panel title="Items">
|
<Panel title="Items">
|
||||||
<p className={styles.hint}>Items hide inside blocks. Clear their line to collect them.</p>
|
<p className={styles.hint}>Items hide inside blocks. Clear their line to collect them.</p>
|
||||||
<ul className={styles.items}>
|
<ul className={styles.items}>
|
||||||
{Object.entries(itemVisuals).map(([id, v]) => (
|
{shownItems.map(({ id, share, duration }) => {
|
||||||
<li key={id}>
|
const v = itemVisual(id);
|
||||||
<img src={v.icon} alt="" width={20} height={20} />
|
return (
|
||||||
<div>
|
<li key={id}>
|
||||||
<div style={{ color: v.color }}>{v.name}</div>
|
<img src={v.icon} alt="" width={20} height={20} />
|
||||||
<div className={styles.itemDescription}>{v.description}</div>
|
<div className={styles.itemText}>
|
||||||
</div>
|
<div className={styles.itemName}>
|
||||||
</li>
|
<span style={{ color: v.color }}>{v.name}</span>
|
||||||
))}
|
{share !== null && <span className={styles.itemShare}>{formatShare(share)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.itemDescription}>{itemDescription(v, duration)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Music">
|
||||||
|
<MusicTracks />
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Share of spawned items as a percentage, e.g. "17%". */
|
||||||
|
function formatShare(share: number): string {
|
||||||
|
return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export function drawBoard(
|
|||||||
active.cells.forEach(([x, y], index) => {
|
active.cells.forEach(([x, y], index) => {
|
||||||
if (y < 0) return;
|
if (y < 0) return;
|
||||||
drawBlock(ctx, x * cell, y * cell, cell, color);
|
drawBlock(ctx, x * cell, y * cell, cell, color);
|
||||||
if (active.itemId && index === active.itemIndex) drawItemIcon(ctx, x * cell, y * cell, cell, active.itemId);
|
const itemId = active.items[index];
|
||||||
|
if (itemId) drawItemIcon(ctx, x * cell, y * cell, cell, itemId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,8 @@ export function drawPreviewPiece(
|
|||||||
if (!shape) return;
|
if (!shape) return;
|
||||||
const w = Math.max(...shape.map(([x]) => x)) + 1;
|
const w = Math.max(...shape.map(([x]) => x)) + 1;
|
||||||
const h = Math.max(...shape.map(([, y]) => y)) + 1;
|
const h = Math.max(...shape.map(([, y]) => y)) + 1;
|
||||||
|
// Three-row shapes (Transformation) are shrunk to fit the preview box.
|
||||||
|
cell = Math.min(cell, Math.floor(boxHeight / (h + 0.5)));
|
||||||
const ox = offsetX + (boxWidth - w * cell) / 2;
|
const ox = offsetX + (boxWidth - w * cell) / 2;
|
||||||
const oy = offsetY + (boxHeight - h * cell) / 2;
|
const oy = offsetY + (boxHeight - h * cell) / 2;
|
||||||
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
|
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
|
||||||
@@ -141,6 +144,7 @@ export function drawPreviewPiece(
|
|||||||
const px = Math.round(ox + x * cell);
|
const px = Math.round(ox + x * cell);
|
||||||
const py = Math.round(oy + y * cell);
|
const py = Math.round(oy + y * cell);
|
||||||
drawBlock(ctx, px, py, cell, color);
|
drawBlock(ctx, px, py, cell, color);
|
||||||
if (piece.itemId && index === piece.itemIndex) drawItemIcon(ctx, px, py, cell, piece.itemId);
|
const itemId = piece.items[index];
|
||||||
|
if (itemId) drawItemIcon(ctx, px, py, cell, itemId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,10 @@ export const pieceShapes: Record<number, [number, number][]> = {
|
|||||||
5: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
5: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
||||||
6: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
6: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
||||||
7: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
7: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
// Odd shapes, only dealt during a Transformation.
|
||||||
|
9: [[0, 0]],
|
||||||
|
10: [[0, 0], [0, 1], [1, 1]],
|
||||||
|
11: [[0, 0], [2, 0], [0, 1], [1, 1], [2, 1]],
|
||||||
|
12: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]],
|
||||||
|
13: [[0, 0], [0, 1], [1, 1], [1, 2], [2, 2]],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { songs } from '../audio/songs';
|
||||||
|
import type { MusicTrack, SoundId } from '../audio/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central registry of every graphic the game uses. Replace the files in /public/assets
|
* Central registry of every graphic and sound the game uses. Replace the files in /public/assets
|
||||||
* (or point these paths elsewhere) to change the look without touching components.
|
* (or point these paths elsewhere) to change the look and sound without touching components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Resolves a file in /public relative to where the site is served (works under sub-paths). */
|
/** Resolves a file in /public relative to where the site is served (works under sub-paths). */
|
||||||
@@ -13,8 +16,10 @@ export interface ItemVisual {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Accent color used for flashes and as icon fallback. */
|
/** Accent color used for flashes and as icon fallback. */
|
||||||
color: string;
|
color: string;
|
||||||
/** Short explanation for the lobby help panel. */
|
/** Short explanation for the lobby help panel. "{duration}" is replaced with the configured seconds. */
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Built-in duration of timed items, shown when the server config can't be read. */
|
||||||
|
defaultDurationSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
|
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
|
||||||
@@ -47,7 +52,15 @@ export const itemVisuals: Record<string, ItemVisual> = {
|
|||||||
icon: asset('assets/items/blind.svg'),
|
icon: asset('assets/items/blind.svg'),
|
||||||
name: 'Blackout',
|
name: 'Blackout',
|
||||||
color: '#c452ff',
|
color: '#c452ff',
|
||||||
description: 'Hides placed blocks and the drop shadow for 4 seconds.',
|
description: 'Hides placed blocks and the drop shadow for {duration} seconds. Stacks.',
|
||||||
|
defaultDurationSeconds: 4,
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
icon: asset('assets/items/transform.svg'),
|
||||||
|
name: 'Transformation',
|
||||||
|
color: '#ff6fd8',
|
||||||
|
description: "Turns the target's next pieces into odd shapes for {duration} seconds. Stacks.",
|
||||||
|
defaultDurationSeconds: 8,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,12 +75,36 @@ export const uiAssets = {
|
|||||||
/** Optional logo image. When null the text logo is rendered. */
|
/** Optional logo image. When null the text logo is rendered. */
|
||||||
logo: null as string | null,
|
logo: null as string | null,
|
||||||
favicon: asset('assets/favicon.svg'),
|
favicon: asset('assets/favicon.svg'),
|
||||||
|
soundOn: asset('assets/ui/sound_on.svg'),
|
||||||
|
soundOff: asset('assets/ui/sound_off.svg'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional audio files that replace the synthesized sound effects. Put the files in
|
||||||
|
* /public/assets/sounds/. Effects without an entry (or whose file fails to load) use the synth.
|
||||||
|
*/
|
||||||
|
export const soundFiles: Partial<Record<SoundId, string>> = {
|
||||||
|
// lineClear: asset('assets/sounds/line_clear.wav'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Background music, played as a shuffled playlist during matches. Add as many tracks as you like. */
|
||||||
|
export const musicTracks: MusicTrack[] = [
|
||||||
|
{ id: 'pixel_rush', title: 'Pixel Rush', kind: 'synth', song: songs.pixelRush },
|
||||||
|
{ id: 'neon_stack', title: 'Neon Stack', kind: 'synth', song: songs.neonStack },
|
||||||
|
{ id: 'last_line', title: 'Last Line', kind: 'synth', song: songs.lastLine },
|
||||||
|
// { id: 'my_track', title: 'My Track', kind: 'file', src: asset('assets/sounds/music/my_track.mp3') },
|
||||||
|
];
|
||||||
|
|
||||||
export function itemVisual(id: string): ItemVisual {
|
export function itemVisual(id: string): ItemVisual {
|
||||||
return itemVisuals[id] ?? unknownItem;
|
return itemVisuals[id] ?? unknownItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The lobby description of an item, with its duration filled in. */
|
||||||
|
export function itemDescription(visual: ItemVisual, durationSeconds?: number | null): string {
|
||||||
|
const seconds = durationSeconds ?? visual.defaultDurationSeconds;
|
||||||
|
return seconds === undefined ? visual.description : visual.description.replace('{duration}', String(seconds));
|
||||||
|
}
|
||||||
|
|
||||||
const imageCache = new Map<string, HTMLImageElement>();
|
const imageCache = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
/** Returns a (possibly still loading) image for canvas rendering. */
|
/** Returns a (possibly still loading) image for canvas rendering. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
|
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
|
||||||
* Index 0 is the empty cell, 8 is garbage.
|
* Index 0 is the empty cell, 1-7 the standard pieces, 8 garbage, 9-13 the Transformation shapes.
|
||||||
*/
|
*/
|
||||||
export const blockColors: Record<number, string> = {
|
export const blockColors: Record<number, string> = {
|
||||||
1: '#3fe3f2',
|
1: '#3fe3f2',
|
||||||
@@ -11,6 +11,11 @@ export const blockColors: Record<number, string> = {
|
|||||||
6: '#ff4256',
|
6: '#ff4256',
|
||||||
7: '#c452ff',
|
7: '#c452ff',
|
||||||
8: '#8c8c8c',
|
8: '#8c8c8c',
|
||||||
|
9: '#ff6fd8',
|
||||||
|
10: '#2fd6a3',
|
||||||
|
11: '#b8ff3f',
|
||||||
|
12: '#f2f2f2',
|
||||||
|
13: '#ff7a3a',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const boardColors = {
|
export const boardColors = {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Gameplay tuning for the Action Blocks server.
|
||||||
|
// Changes are picked up automatically and used from the next match on (running matches keep their values).
|
||||||
|
// An invalid edit is logged by the server and ignored; an invalid file at startup stops the server.
|
||||||
|
// Any value can also be overridden with an env var, e.g. Game__Items__blind__DurationSeconds=6.
|
||||||
|
// Other rules (CountdownSeconds, LockDelaySeconds, ...) can be added to "Game" too, see GameSettings.cs.
|
||||||
|
{
|
||||||
|
"Game": {
|
||||||
|
// Chance (0..1) that each block of a new piece carries an item. Every block rolls on its own,
|
||||||
|
// so one piece can carry several items. 0 = no items, 1 = every block has one.
|
||||||
|
"ItemSpawnChance": 0.05,
|
||||||
|
|
||||||
|
// Which item a block gets: Weight / sum of all weights. Example: weights 2, 1, 1 = 50%, 25%, 25%.
|
||||||
|
// Weight 0 disables an item. DurationSeconds only exists for the timed items (blind, transform).
|
||||||
|
"Items": {
|
||||||
|
"add_line": { "Weight": 1 },
|
||||||
|
"clear_line": { "Weight": 1 },
|
||||||
|
"nuke": { "Weight": 1 },
|
||||||
|
"swap": { "Weight": 1 },
|
||||||
|
"blind": { "Weight": 1, "DurationSeconds": 4 },
|
||||||
|
"transform": { "Weight": 1, "DurationSeconds": 8 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,33 @@ cp .env.example .env
|
|||||||
| `ALLOWED_ORIGINS` | server | empty → any origin | Comma-separated list of origins allowed to connect, e.g. `https://game.example.com` |
|
| `ALLOWED_ORIGINS` | server | empty → any origin | Comma-separated list of origins allowed to connect, e.g. `https://game.example.com` |
|
||||||
| `PATH_BASE` | server | empty | Prefix to accept if the proxy forwards a sub-path **without stripping it**, e.g. `/server` |
|
| `PATH_BASE` | server | empty | Prefix to accept if the proxy forwards a sub-path **without stripping it**, e.g. `/server` |
|
||||||
| `HUB_PATH` | server | `/hub` | Hub endpoint path. It is fixed to `/hub` in the compose file |
|
| `HUB_PATH` | server | `/hub` | Hub endpoint path. It is fixed to `/hub` in the compose file |
|
||||||
| `Game__<Setting>` | server | see `GameSettings.cs` | Overrides gameplay values, e.g. `Game__ItemSpawnChance=0.2`. Add these under the server's `environment:` in the compose file |
|
| `GAME_CONFIG` | server | `/app/config/game.json` (compose) | Path of the gameplay config file, see [below](#game-config) |
|
||||||
|
| `Game__<Setting>` | server | see `GameSettings.cs` | Overrides a value from the config file, e.g. `Game__CountdownSeconds=5` or `Game__Items__blind__DurationSeconds=6`. Add these under the server's `environment:` in the compose file |
|
||||||
|
|
||||||
|
### Game config
|
||||||
|
|
||||||
|
`config/game.json` (next to `docker-compose.yml`) holds the gameplay tuning. The compose file mounts the `config` folder read-only into the server container.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"Game": {
|
||||||
|
"ItemSpawnChance": 0.1, // chance (0..1) per block of a new piece to carry an item
|
||||||
|
"Items": {
|
||||||
|
"nuke": { "Weight": 1 }, // share = weight / sum of all weights; 0 = disabled
|
||||||
|
"blind": { "Weight": 2, "DurationSeconds": 4 },
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Effective chance** of an item per block = `ItemSpawnChance × weight / sum of weights`. With the defaults (all weights 1), each of the six items gets 1/6 of the 10%.
|
||||||
|
- `DurationSeconds` exists only for the timed items `blind` and `transform`. The other items act instantly.
|
||||||
|
- Items left out of the file keep weight 1 and their built-in duration. Item ids are case-insensitive. Any other `GameSettings` value (e.g. `CountdownSeconds`) may be added to `"Game"` too.
|
||||||
|
- **Changes apply from the next match on**, without a restart. Running matches keep the values they started with. The server logs `Game config reloaded` with the new shares.
|
||||||
|
- **Invalid values** (chance outside 0..1, negative weight, duration ≤ 0, unknown item, duration on an instant item, broken JSON) are reported in the server log:
|
||||||
|
- at startup, the server refuses to start;
|
||||||
|
- on a later edit, the change is ignored and the last good config stays active.
|
||||||
|
|
||||||
Start or update:
|
Start or update:
|
||||||
|
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ To play against yourself, open the site in two browser windows. Create a lobby i
|
|||||||
|
|
||||||
### Handy server settings for testing
|
### Handy server settings for testing
|
||||||
|
|
||||||
Any `GameSettings` value can be overridden with an environment variable:
|
`dotnet run` finds `config/game.json` in the repo root automatically. Edit it (changes apply from the next match), point `GAME_CONFIG` to a separate test file, or override single values with environment variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
Game__ItemSpawnChance=0.9 Game__CountdownSeconds=1 \
|
Game__ItemSpawnChance=0.9 Game__CountdownSeconds=1 \
|
||||||
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080
|
||||||
```
|
```
|
||||||
|
|
||||||
That gives items on almost every piece and a near-instant start, which is useful for testing items.
|
That gives items on almost every piece and a near-instant start, which is useful for testing items. To test one item, set the weight of all others to 0 in a test config.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ That gives items on almost every piece and a near-instant start, which is useful
|
|||||||
|
|
||||||
**Server**
|
**Server**
|
||||||
|
|
||||||
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all five items in `Items.cs`.
|
- File-scoped namespaces, nullable reference types enabled, one main type per file. Small closely related types may share a file, e.g. all items in `Items.cs`.
|
||||||
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
- `ActionBlocks.Game` must stay free of ASP.NET or serialization dependencies. Mapping to the wire format happens in `ActionBlocks.Server/Dtos`.
|
||||||
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
- Game objects are not thread-safe. Only touch them inside `lock (lobby.Sync)`.
|
||||||
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
- Prefer adding a value to `GameSettings`, or an implementation of an existing interface, over hard-coding numbers in logic.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Step-by-step guides for the most common changes.
|
|||||||
- [Add a new match event](#add-a-new-match-event)
|
- [Add a new match event](#add-a-new-match-event)
|
||||||
- [Change graphics and colors](#change-graphics-and-colors)
|
- [Change graphics and colors](#change-graphics-and-colors)
|
||||||
- [Change key bindings](#change-key-bindings)
|
- [Change key bindings](#change-key-bindings)
|
||||||
|
- [Add or replace sounds and music](#add-or-replace-sounds-and-music)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -26,18 +27,20 @@ public sealed class SpeedUpItem : IItem
|
|||||||
{
|
{
|
||||||
public string Id => "speed"; // stable id, also used by the frontend
|
public string Id => "speed"; // stable id, also used by the frontend
|
||||||
public string DisplayName => "Speed Up";
|
public string DisplayName => "Speed Up";
|
||||||
public int SpawnWeight => 1; // optional, default 1 (higher = more common)
|
public double? DurationSeconds => 5; // optional: only for timed items, shown in the lobby
|
||||||
|
|
||||||
public void Apply(ItemContext context) =>
|
public void Apply(ItemContext context) =>
|
||||||
context.Target.FastUntil = Math.Max(context.Target.FastUntil, context.Now + 5);
|
context.Target.FastUntil = Math.Max(context.Target.FastUntil, context.Now + 5);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To make the duration configurable, take it as a constructor parameter like `InvisibilityItem` does.
|
||||||
|
|
||||||
`ItemContext` gives you the `User`, the `Target`, a shared `Random` and the match time `Now`. Use `context.IsSelfTarget` to special-case self-use.
|
`ItemContext` gives you the `User`, the `Target`, a shared `Random` and the match time `Now`. Use `context.IsSelfTarget` to special-case self-use.
|
||||||
|
|
||||||
### 2. Server: add any new state the item needs
|
### 2. Server: add any new state the item needs
|
||||||
|
|
||||||
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
If existing `PlayerGame` methods (`InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`, `Transform`) aren't enough, add a property or method to `PlayerGame`. For the example:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public double FastUntil { get; set; }
|
public double FastUntil { get; set; }
|
||||||
@@ -48,31 +51,38 @@ If the board changes under the active piece, call `ResolveActiveOverlap()` after
|
|||||||
|
|
||||||
### 3. Server: register it
|
### 3. Server: register it
|
||||||
|
|
||||||
In `Items/ItemRegistry.cs`:
|
In `Items/ItemRegistry.cs`, add it to `FromSettings`:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public static ItemRegistry Default { get; } = new(
|
IItem[] items =
|
||||||
[
|
[
|
||||||
new AddGarbageLineItem(),
|
new AddGarbageLineItem(),
|
||||||
...
|
...
|
||||||
new SpeedUpItem(),
|
new SpeedUpItem(), // or new SpeedUpItem(Duration("speed", 5)) for a configurable duration
|
||||||
]);
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add it to `config/game.json` so its weight (and duration) can be tuned:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"speed": { "Weight": 1, "DurationSeconds": 5 }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Server: test it
|
### 4. Server: test it
|
||||||
|
|
||||||
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_five_unique_items` to the new count.
|
Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update `Registry_contains_six_unique_items` to the new count.
|
||||||
|
|
||||||
### 5. Website: add the visuals
|
### 5. Website: add the visuals
|
||||||
|
|
||||||
Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`:
|
Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`. In `description`, write `{duration}` where the configured duration should appear:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
speed: {
|
speed: {
|
||||||
icon: asset('assets/items/speed.svg'),
|
icon: asset('assets/items/speed.svg'),
|
||||||
name: 'Speed Up',
|
name: 'Speed Up',
|
||||||
color: '#ff9b26',
|
color: '#ff9b26',
|
||||||
description: 'Target pieces fall very fast for 5 seconds.',
|
description: 'Target pieces fall very fast for {duration} seconds.',
|
||||||
|
defaultDurationSeconds: 5,
|
||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ The id key must match `IItem.Id`. Unknown ids still work but show `unknown.svg`.
|
|||||||
|
|
||||||
### 6. Website: show the effect (only if needed)
|
### 6. Website: show the effect (only if needed)
|
||||||
|
|
||||||
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`.
|
If the item needs a visual effect, as Blackout does, expose the state in the snapshot (see [below](#add-new-state-to-the-snapshot)) and use it in `GamePage` / `draw.ts`. For a timed effect, add an entry to the `effects` list of `EffectTimers` in `GamePage` and `MiniBoard` to show a countdown.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -94,11 +104,12 @@ All numbers are in `ActionBlocks.Game/GameSettings.cs`. There are two ways to ch
|
|||||||
```yaml
|
```yaml
|
||||||
actionblocks-server:
|
actionblocks-server:
|
||||||
environment:
|
environment:
|
||||||
Game__ItemSpawnChance: "0.15"
|
|
||||||
Game__MaxInventory: "6"
|
Game__MaxInventory: "6"
|
||||||
Game__LockDelaySeconds: "0.6"
|
Game__LockDelaySeconds: "0.6"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **Put it in `config/game.json`** (same `"Game"` section). That needs no rebuild and applies from the next match. The item chance, item weights and durations already live there; see [Deployment → Game config](deployment.md#game-config).
|
||||||
|
|
||||||
To add a **new** setting, add an `init` property with a default to `GameSettings` and read it where needed. Configuration binding picks it up automatically.
|
To add a **new** setting, add an `init` property with a default to `GameSettings` and read it where needed. Configuration binding picks it up automatically.
|
||||||
|
|
||||||
If you change the board width or height, the website adapts automatically, because `MatchState.width`/`height` drive the rendering.
|
If you change the board width or height, the website adapts automatically, because `MatchState.width`/`height` drive the rendering.
|
||||||
@@ -159,7 +170,7 @@ Keep snapshots small. They are sent ~30 times per second for every player to eve
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
| Item icon | Replace the file in `public/assets/items/`, or point `itemVisuals[id].icon` elsewhere |
|
||||||
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
| Logo | Put an image in `public/assets/`, set `uiAssets.logo: asset('assets/logo.png')` |
|
||||||
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage) |
|
| Block colors | `src/theme/colors.ts` (`blockColors[1..7]` pieces, `[8]` garbage, `[9..13]` Transformation shapes) |
|
||||||
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
| Block style (bevel, texture, …) | `drawBlock` in `src/render/draw.ts` |
|
||||||
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
| Ghost look | `drawGhostCell` in `draw.ts`, `boardColors.ghostAlpha` |
|
||||||
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
| UI colors, borders, font | CSS variables in `src/theme/global.css` |
|
||||||
@@ -179,3 +190,81 @@ Edit `src/config/keybindings.ts`:
|
|||||||
- `controlsHelp` holds the texts shown in the lobby's Controls panel. Keep them ASCII.
|
- `controlsHelp` holds the texts shown in the lobby's Controls panel. Keep them ASCII.
|
||||||
|
|
||||||
To add a new **action**, add it to `BoundAction` and `keyBindings`. If it maps 1:1 to a server action, add it to `simpleActions`. A new server action also needs an `InputAction` value on the server, handled in `PlayerGame.HandleInput` or `Match.HandleInput`, plus the matching string in `src/net/types.ts`.
|
To add a new **action**, add it to `BoundAction` and `keyBindings`. If it maps 1:1 to a server action, add it to `simpleActions`. A new server action also needs an `InputAction` value on the server, handled in `PlayerGame.HandleInput` or `Match.HandleInput`, plus the matching string in `src/net/types.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add or replace sounds and music
|
||||||
|
|
||||||
|
The audio system is described in [Frontend > Audio](frontend.md#audio-srcaudio). Everything you can swap is registered in `src/theme/assets.ts`, next to the graphics.
|
||||||
|
|
||||||
|
### Balance the volumes
|
||||||
|
|
||||||
|
All loudness settings for developers are in **`src/config/sound.ts`**:
|
||||||
|
|
||||||
|
| Setting | Affects |
|
||||||
|
|---|---|
|
||||||
|
| `soundMix.master` | Everything |
|
||||||
|
| `soundMix.sfx` / `soundMix.music` | All effects / all music |
|
||||||
|
| `soundMix.effects.<id>` | One effect, e.g. `move: 0.5` makes moving half as loud |
|
||||||
|
| `soundMix.tracks.<track id>` | One music track. Tracks not listed play at 1 |
|
||||||
|
| `defaultSfxVolume` / `defaultMusicVolume` | Where the player's sliders start (0..1) when no settings are saved yet |
|
||||||
|
|
||||||
|
Every value multiplies the level the sound was designed with: `1` is unchanged, `0` is silent (an effect at `0` is not played at all), and values above `1` make it louder. The levels apply to synthesized sounds and to registered audio files alike. The player's volume sliders apply on top.
|
||||||
|
|
||||||
|
The `volume` numbers inside `synthSounds.ts` and `songs.ts` shape a sound (balance between its tones, or between a song's voices). Change those only when you redesign a sound itself.
|
||||||
|
|
||||||
|
### Replace an effect with an audio file
|
||||||
|
|
||||||
|
Put the file in `public/assets/sounds/` and register it under its `SoundId`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const soundFiles: Partial<Record<SoundId, string>> = {
|
||||||
|
lineClear: asset('assets/sounds/line_clear.wav'),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Any format the browser can decode works (wav, mp3, ogg). If the file fails to load, the synth sound is used and a warning is logged.
|
||||||
|
|
||||||
|
### Add music tracks
|
||||||
|
|
||||||
|
`musicTracks` is a playlist, and you can register as many tracks as you like. Tracks play in shuffled order, and players can switch single tracks off in the lobby.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const musicTracks: MusicTrack[] = [
|
||||||
|
{ id: 'pixel_rush', title: 'Pixel Rush', kind: 'synth', song: songs.pixelRush },
|
||||||
|
{ id: 'boss_fight', title: 'Boss Fight', kind: 'file', src: asset('assets/sounds/music/boss_fight.mp3') },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
- **File track:** put the file in `public/assets/sounds/music/`. The next track starts when the file ends.
|
||||||
|
- **Synth track:** add a `SynthSong` to `src/audio/songs.ts`. Each channel is a waveform, a volume and a text pattern: a note name (`C4`, `F#3`, `A#5`) starts a note, `-` holds it, `.` is a rest. Noise channels use `x` (short hit) and `o` (long hit). Shorter channels repeat until the longest one ends. After `loops` repetitions the playlist moves on.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const myTune: SynthSong = {
|
||||||
|
bpm: 140,
|
||||||
|
stepsPerBeat: 2, // eighth notes
|
||||||
|
loops: 6,
|
||||||
|
channels: [
|
||||||
|
{ wave: 'triangle', volume: 0.3, pattern: 'C3 C4 C3 C4 G2 G3 G2 G3' },
|
||||||
|
{ wave: 'square', volume: 0.06, pattern: 'E5 - G5 . C6 - - . B5 - G5 . D5 - - .' },
|
||||||
|
{ wave: 'noise', volume: 0.05, pattern: 'x . o .' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` must be unique and stay stable, because disabled tracks are saved by id. To change a track's level, add its id to `soundMix.tracks` in `src/config/sound.ts`.
|
||||||
|
|
||||||
|
### Add a new sound effect
|
||||||
|
|
||||||
|
1. Add the id to `SoundId` in `src/audio/types.ts`.
|
||||||
|
2. Add a recipe to `synthSounds` in `src/audio/synthSounds.ts` (usually a few `tone()` / `noise()` calls) and a volume to `soundMix.effects` in `src/config/sound.ts`. TypeScript reports an error until both exist for every id.
|
||||||
|
3. Trigger it with `player.play('myId')`: from `useGameSounds` for something visible in the snapshots, or anywhere through `useSound().player`.
|
||||||
|
4. If the server should announce something the client can't detect, add a [match event](#add-a-new-match-event) and react to it in `useGameSounds`.
|
||||||
|
|
||||||
|
### Replace the whole engine
|
||||||
|
|
||||||
|
The UI only talks to the `SoundPlayer` interface (`src/audio/types.ts`). To use a different audio library, implement that interface and pass it in `main.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SoundProvider createPlayer={() => new MyHowlerPlayer()}>
|
||||||
|
```
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ actionBlocks_website/
|
|||||||
│ ├── config.js # runtime config (overwritten in the container)
|
│ ├── config.js # runtime config (overwritten in the container)
|
||||||
│ └── assets/ # exchangeable graphics
|
│ └── assets/ # exchangeable graphics
|
||||||
│ ├── favicon.svg
|
│ ├── favicon.svg
|
||||||
│ └── items/*.svg # one icon per item id (+ unknown.svg)
|
│ ├── items/*.svg # one icon per item id (+ unknown.svg)
|
||||||
|
│ ├── ui/ # UI icons (sound menu)
|
||||||
|
│ └── sounds/ # optional audio files (+ music/)
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf
|
||||||
│ └── 40-runtime-config.sh # writes config.js from env SERVER_URL
|
│ └── 40-runtime-config.sh # writes config.js from env SERVER_URL
|
||||||
@@ -25,6 +27,7 @@ actionBlocks_website/
|
|||||||
│ ├── hubClient.ts # typed SignalR wrapper
|
│ ├── hubClient.ts # typed SignalR wrapper
|
||||||
│ └── useGameConnection.ts # React hook: connection + all server state
|
│ └── useGameConnection.ts # React hook: connection + all server state
|
||||||
├── input/useKeyboardInput.ts # keyboard → game actions (with DAS/ARR)
|
├── input/useKeyboardInput.ts # keyboard → game actions (with DAS/ARR)
|
||||||
|
├── audio/ # sound effects + music (see "Audio" below)
|
||||||
├── render/
|
├── render/
|
||||||
│ ├── draw.ts # canvas drawing: blocks, items, ghost, board, previews
|
│ ├── draw.ts # canvas drawing: blocks, items, ghost, board, previews
|
||||||
│ └── shapes.ts # piece shapes for previews
|
│ └── shapes.ts # piece shapes for previews
|
||||||
@@ -34,7 +37,7 @@ actionBlocks_website/
|
|||||||
│ └── assets.ts # registry of every graphic + item visuals
|
│ └── assets.ts # registry of every graphic + item visuals
|
||||||
├── pages/ # HomePage, LobbyPage, GamePage (+ .module.css)
|
├── pages/ # HomePage, LobbyPage, GamePage (+ .module.css)
|
||||||
└── components/ # BoardCanvas, MiniBoard, PiecePreview, ItemBar,
|
└── components/ # BoardCanvas, MiniBoard, PiecePreview, ItemBar,
|
||||||
# ItemFlashOverlay, Logo, ui/Button, ui/Panel
|
# ItemFlashOverlay, Logo, CornerControls, SoundMenu, MusicTracks, ui/Button, ui/Panel
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data flow
|
## Data flow
|
||||||
@@ -88,8 +91,9 @@ Layout: **hold + stats + "1 YOU"**, then the **own board** with the garbage mete
|
|||||||
- The selected target lives in local state, `targetId`. Slot `1` is yourself and slot `n ≥ 2` is `opponents[n-2]`, in match order excluding yourself.
|
- The selected target lives in local state, `targetId`. Slot `1` is yourself and slot `n ≥ 2` is `opponents[n-2]`, in match order excluding yourself.
|
||||||
- A key press updates the local state and calls `selectTarget` on the server.
|
- A key press updates the local state and calls `selectTarget` on the server.
|
||||||
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
||||||
|
- **Leave.** The Leave button in the corner calls `leaveLobby`. While you are still alive in a match, leaving gives up that match, so the first click only turns the button into a red `SURE?` for 3 s and a second click leaves. After you are out, or once the match has finished, one click is enough. No browser dialog is used, because dialogs block the page.
|
||||||
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
||||||
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost.
|
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost. `EffectTimers` shows the seconds left on Blackout and Transformation in the board corner.
|
||||||
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
||||||
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
- **Inventory bar.** 10 slots, with the oldest item outlined, plus the hint line `F: <next item> > <target>`.
|
||||||
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
- **Mini boards.** `MiniBoard` is memoized. It shows the slot number, the name, the board, a flash overlay and the opponent's **item bar beneath the board**.
|
||||||
@@ -134,6 +138,52 @@ The pixel font (*Press Start 2P*) has **no arrow, ellipsis or dash glyphs**, so
|
|||||||
|
|
||||||
Asset paths go through the `asset()` helper in `assets.ts` (`import.meta.env.BASE_URL + path`), so they stay correct when the site is served under a sub-path.
|
Asset paths go through the `asset()` helper in `assets.ts` (`import.meta.env.BASE_URL + path`), so they stay correct when the site is served under a sub-path.
|
||||||
|
|
||||||
|
## Audio (`src/audio/`)
|
||||||
|
|
||||||
|
All sound is generated in the browser with the **Web Audio API**: oscillators (square, triangle, sawtooth) and filtered noise with short volume envelopes, in the style of old console sound chips. No audio files are needed, but any effect or music track can be replaced by a file.
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `../config/sound.ts` | **Central volume mix for developers**: master, sfx, music, per effect, per track, default slider positions |
|
||||||
|
| `types.ts` | `SoundId` (all effects), `SoundPlayer` (the interface the UI talks to), `MusicTrack`, `SynthSong`, `SoundSettings` |
|
||||||
|
| `synthSounds.ts` | One recipe per `SoundId`, plus the `tone()` / `noise()` helpers |
|
||||||
|
| `music.ts` | `SynthSongPlayer`: plays a `SynthSong` with a lookahead scheduler on the audio clock |
|
||||||
|
| `songs.ts` | The three built-in songs, written as text patterns |
|
||||||
|
| `MusicPlaylist.ts` | Plays the registered tracks in shuffled order. Each track plays once before the order is reshuffled. File tracks advance when they end, synth songs after `loops` repetitions |
|
||||||
|
| `AudioEngine.ts` | `WebAudioPlayer implements SoundPlayer`: owns the `AudioContext`, the SFX and music volume nodes, file loading with fallback to the synth, and a 30 ms per-sound throttle |
|
||||||
|
| `settings.ts` | `sfxMuted`, `musicMuted`, both volumes and the disabled tracks, saved in `localStorage`. An old single `muted` flag is migrated to both |
|
||||||
|
| `SoundContext.tsx` | `SoundProvider` (wraps `<App/>` in `main.tsx`) and the `useSound()` / `useCurrentTrack()` hooks. It also handles the global **M** (all sound on/off), **B** (music on/off) and **N** (next track) keys |
|
||||||
|
| `useGameSounds.ts` | Compares each snapshot with the previous one and plays the matching sounds |
|
||||||
|
|
||||||
|
**When sounds play.** Everything comes from data the client already receives, so the server is unchanged. Snapshots are server-confirmed, so a blocked move or rotation stays silent.
|
||||||
|
|
||||||
|
| Sound | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| `countdown`, `go` | Countdown number changes; phase changes to `running` (music starts) |
|
||||||
|
| `move`, `rotate` | Own active piece moved sideways or changed shape |
|
||||||
|
| `hold` | `holdUsed` became true |
|
||||||
|
| `lock` | Next queue advanced without a line clear |
|
||||||
|
| `hardDrop` | Sent from `GamePage` when the HardDrop input is sent (a hard drop looks like any other lock in the snapshot) |
|
||||||
|
| `lineClear` (pitch rises per line), `quad` | `lines` increased by 1–3, or by 4 |
|
||||||
|
| `itemPickup`, `itemUse` | Own inventory grew or shrank |
|
||||||
|
| `itemHit` | `itemUsed` event targeting you |
|
||||||
|
| `garbageIncoming` | `pendingGarbage` increased |
|
||||||
|
| `knockout`, `lose`, `win` | An opponent was eliminated; you were eliminated; you won (music stops when the match finishes) |
|
||||||
|
|
||||||
|
The music tempo rises slightly with your level, by 3 % per level and at most 30 %.
|
||||||
|
|
||||||
|
**Autoplay.** Browsers block audio until the user interacts with the page. The `AudioContext` is created on the first key press or click, which always happens before a match starts.
|
||||||
|
|
||||||
|
**UI.**
|
||||||
|
|
||||||
|
- `CornerControls` is fixed in the upper right corner of every screen (16px from the edges): the `SoundMenu` speaker button, and below it a small Leave button in the lobby and the game.
|
||||||
|
- Clicking the speaker button opens an inline strip to its left, in the header row, so it never covers a board and doesn't pause the game. The strip has a mute button and a volume slider each for `SFX` and `MUSIC`. Moving a muted slider unmutes that channel. Escape, a click outside or the button close the strip. Buttons and sliders give keyboard focus back to the page after use, so game keys keep working.
|
||||||
|
- The speaker icons are `uiAssets.soundOn` / `soundOff` (`public/assets/ui/`).
|
||||||
|
- `MusicTracks` lists one checkbox per track in the lobby's "Music" panel.
|
||||||
|
- The game header shows the current track. While music is muted the playlist doesn't run at all.
|
||||||
|
|
||||||
|
**Debugging.** In dev builds every played sound is logged with `console.debug` as `[sound] <id>`, and music changes as `[music] play|stop <track id>`. Enable the "Verbose" level in devtools to see them.
|
||||||
|
|
||||||
## Runtime configuration
|
## Runtime configuration
|
||||||
|
|
||||||
`src/config/runtimeConfig.ts` resolves the hub URL in this order:
|
`src/config/runtimeConfig.ts` resolves the hub URL in this order:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Pieces spawn in the two buffer rows directly above the visible field. They are h
|
|||||||
## Pieces and randomizer
|
## Pieces and randomizer
|
||||||
|
|
||||||
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
- There are 7 shapes. Each has 4 rotation states (0 = spawn, 1 = right, 2 = 180°, 3 = left). States are derived by rotating the spawn shape inside its bounding box (`Core/PieceDefinitions.cs`).
|
||||||
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces.
|
- The **bag randomizer** shuffles all 7 shapes into a bag and deals them one by one, then refills (`Core/Randomizer.cs`). This guarantees every shape appears exactly once in every 7 pieces. The odd Transformation shapes are never in the bag.
|
||||||
|
|
||||||
## Rotation (SRS)
|
## Rotation (SRS)
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ A player is eliminated when any of these happens:
|
|||||||
|
|
||||||
### Spawning and collecting
|
### Spawning and collecting
|
||||||
|
|
||||||
- Each **newly generated** piece has a 10% chance (`GameSettings.ItemSpawnChance`) to carry an item. The item sits in one random mino of the four. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`).
|
- Each mino of a **newly generated** piece has its own 10% chance (`ItemSpawnChance` in `config/game.json`) to carry an item, so one piece can carry several items. Which item it is depends on the item **weights** in the same file: share = weight / sum of weights (default: all equal). Each item is picked separately. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`).
|
||||||
- When the piece locks, the item stays in that cell of the board. Item cells move with the board: they drop when lines below them clear, rise with garbage, and travel along when boards are swapped.
|
- When the piece locks, the item stays in that cell of the board. Item cells move with the board: they drop when lines below them clear, rise with garbage, and travel along when boards are swapped.
|
||||||
- **Clearing the row that contains the item cell** moves the item into the player's inventory.
|
- **Clearing the row that contains the item cell** moves the item into the player's inventory.
|
||||||
- If the cell is destroyed another way (for example by *Clear Line* or *Wipe Out*), the item is lost.
|
- If the cell is destroyed another way (for example by *Clear Line* or *Wipe Out*), the item is lost.
|
||||||
@@ -133,7 +133,7 @@ A player is eliminated when any of these happens:
|
|||||||
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
- Everyone sees every opponent's inventory in the item bar beneath their mini board.
|
||||||
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
- Every client is told when an item hits someone (`ItemUsedEvent`): which player was hit and which item, but not who used it. The item name flashes on top of the hit player's board. That is the big board for yourself, or the mini board for an opponent.
|
||||||
|
|
||||||
### The five items
|
### The six items
|
||||||
|
|
||||||
| Id | Name | Effect | Class |
|
| Id | Name | Effect | Class |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -141,7 +141,8 @@ A player is eliminated when any of these happens:
|
|||||||
| `clear_line` | Clear Line | Removes the bottom row of the target. Items in it are lost. | `ClearBottomLineItem` |
|
| `clear_line` | Clear Line | Removes the bottom row of the target. Items in it are lost. | `ClearBottomLineItem` |
|
||||||
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
| `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` |
|
||||||
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
| `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` |
|
||||||
| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Re-applying extends the effect to *now + 4 s*. | `InvisibilityItem` |
|
| `blind` | Blackout | For 4 seconds (`DurationSeconds` in `config/game.json`), the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Durations stack: each further Blackout adds 4 s to the time that is left. | `InvisibilityItem` |
|
||||||
|
| `transform` | Transformation | For 8 seconds (configurable like Blackout), the target's upcoming pieces become odd shapes: Dot, small L, U, Plus or W (random). The whole next queue changes at once; the falling piece does not. Items inside a piece move to the new shape. When the time runs out, pieces still in the queue or hold change back to what they were. Durations stack like Blackout. | `TransformationItem` |
|
||||||
|
|
||||||
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively.
|
||||||
|
|
||||||
@@ -159,5 +160,9 @@ Bindings use physical key positions (`KeyboardEvent.code`), so they work on any
|
|||||||
| Hold | C, Shift |
|
| Hold | C, Shift |
|
||||||
| Select item target | 1–9 (1 = yourself) |
|
| Select item target | 1–9 (1 = yourself) |
|
||||||
| Use item | F |
|
| Use item | F |
|
||||||
|
| All sound on / off | M |
|
||||||
|
| Background music on / off (effects stay on) | B |
|
||||||
|
| Volume and mute for effects and music | Speaker button, upper right corner |
|
||||||
|
| Next music track | N |
|
||||||
|
|
||||||
Holding left/right repeats the move after 167 ms, then every 33 ms (DAS/ARR). When both directions are held, the most recently pressed one wins.
|
Holding left/right repeats the move after 167 ms, then every 33 ms (DAS/ARR). When both directions are held, the most recently pressed one wins.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Communication uses **ASP.NET Core SignalR** over WebSockets. SignalR falls back
|
|||||||
| `StartGame` | – | – | Host only; fails while a match is running |
|
| `StartGame` | – | – | Host only; fails while a match is running |
|
||||||
| `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) |
|
| `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) |
|
||||||
| `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id |
|
| `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id |
|
||||||
| `GetItems` | – | `ItemInfo[]` | Lists all server items (id + name). Currently unused by the website |
|
| `GetItems` | – | `ItemInfo[]` | Items of the current config: `{ id, name, share, durationSeconds }`. `share` is 0..1 (0 = disabled), `durationSeconds` is null for instant items. The lobby help panel uses it |
|
||||||
|
|
||||||
`InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`.
|
`InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`.
|
||||||
|
|
||||||
@@ -132,23 +132,23 @@ sequenceDiagram
|
|||||||
"active": {
|
"active": {
|
||||||
"type": 7,
|
"type": 7,
|
||||||
"cells": [[4,-1],[3,0],[4,0],[5,0]],
|
"cells": [[4,-1],[3,0],[4,0],[5,0]],
|
||||||
"itemIndex": 2, // which of the 4 cells carries the item, -1 if none
|
"items": [null, null, "swap", null] // items[i] is the item in cells[i], null if none
|
||||||
"itemId": "swap"
|
|
||||||
},
|
},
|
||||||
"ghost": [[4,17],[3,18],[4,18],[5,18]],
|
"ghost": [[4,17],[3,18],[4,18],[5,18]],
|
||||||
"hold": { "type": 3, "itemId": null, "itemIndex": -1 },
|
"hold": { "type": 3, "items": [null, null, null, null] },
|
||||||
"holdUsed": false,
|
"holdUsed": false,
|
||||||
"next": [ { "type": 4, "itemId": null, "itemIndex": -1 }, … ],
|
"next": [ { "type": 4, "items": ["nuke", null, null, "blind"] }, … ],
|
||||||
"inventory": ["add_line", "blind"], // oldest first; inventory[0] is used by F
|
"inventory": ["add_line", "blind"], // oldest first; inventory[0] is used by F
|
||||||
"score": 1250,
|
"score": 1250,
|
||||||
"lines": 12,
|
"lines": 12,
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"pendingGarbage": 3, // incoming lines waiting to rise
|
"pendingGarbage": 3, // incoming lines waiting to rise
|
||||||
"invisibleRemaining": 0 // seconds of Blackout left (> 0 = hide stack + ghost)
|
"invisibleRemaining": 0, // seconds of Blackout left (> 0 = hide stack + ghost)
|
||||||
|
"transformedRemaining": 0 // seconds of Transformation left (queue shows odd shapes)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage, and `'9'`, `':'`, `';'`, `'<'`, `'='` (values 9–13) are the Transformation shapes (Dot, SmallL, U, Plus, W). Decode with `charCode - 48`. Index = `y * width + x`, where y = 0 is the **top visible row**.
|
||||||
|
|
||||||
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ actionBlocks_server/
|
|||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
|
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
|
||||||
sealed record PieceSpec(PieceType Type, string? ItemId, int ItemMinoIndex) // what sits in the queue
|
sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items) // what sits in the queue; Items[i] = item in mino i
|
||||||
sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) // a spec placed on the board
|
sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) // a spec placed on the board
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,20 +108,22 @@ public interface IItem
|
|||||||
{
|
{
|
||||||
string Id { get; } // stable id shared with the frontend ("add_line", ...)
|
string Id { get; } // stable id shared with the frontend ("add_line", ...)
|
||||||
string DisplayName { get; } // shown in the flash
|
string DisplayName { get; } // shown in the flash
|
||||||
int SpawnWeight => 1; // relative spawn probability
|
double? DurationSeconds => null; // effect length of timed items, null = instant
|
||||||
void Apply(ItemContext context);
|
void Apply(ItemContext context);
|
||||||
}
|
}
|
||||||
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now);
|
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now);
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ItemRegistry.Default` lists all active items. `ItemRegistry.PickRandom` chooses an item weighted by `SpawnWeight`.
|
- `ItemRegistry.FromSettings(settings)` builds the items with the weights and durations from `GameSettings.Items` (from `config/game.json`). `ItemRegistry.Default` is the same with built-in values. `PickRandom` chooses an item by weight; weight-0 items never spawn. `Share(id)` is weight / sum of weights.
|
||||||
- `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`, sometimes with an item.
|
- `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`. Each mino rolls `ItemSpawnChance` on its own, so a piece can carry 0–4 items.
|
||||||
|
|
||||||
See [Extending → Add an item](extending.md#add-a-new-item).
|
See [Extending → Add an item](extending.md#add-a-new-item).
|
||||||
|
|
||||||
### `GameSettings`
|
### `GameSettings`
|
||||||
|
|
||||||
`GameSettings` is an immutable record with `init` properties, one per tunable number. See [Game rules](game-rules.md) for what each one does. The host binds it from the configuration section `Game`, so you can override any property with an environment variable named `Game__<PropertyName>`, e.g. `Game__ItemSpawnChance=0.25`.
|
`GameSettings` is an immutable record with `init` properties, one per tunable number, plus `Items` (per-item `Weight` and `DurationSeconds`). See [Game rules](game-rules.md) for what each one does. `Validate()` lists invalid values.
|
||||||
|
|
||||||
|
The host binds it from the configuration section `Game`. Its main source is `config/game.json` (`Config/GameConfigFile.cs`, path from `GAME_CONFIG`, otherwise searched upwards from the content root). Environment variables named `Game__<PropertyName>` override the file, e.g. `Game__ItemSpawnChance=0.25`. `Config/GameConfigProvider.cs` validates the config and follows changes to the file. A valid edit replaces `Current`, which the next `StartGame` hands to the new match. An invalid one is logged and ignored. At startup, an invalid config stops the server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ services:
|
|||||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||||
HUB_PATH: /hub
|
HUB_PATH: /hub
|
||||||
PATH_BASE: ${PATH_BASE:-}
|
PATH_BASE: ${PATH_BASE:-}
|
||||||
|
# Gameplay tuning (item chance, weights, durations). Edits apply from the next match on.
|
||||||
|
GAME_CONFIG: /app/config/game.json
|
||||||
|
# File change events don't always cross bind mounts; polling makes the reload reliable.
|
||||||
|
DOTNET_USE_POLLING_FILE_WATCHER: "true"
|
||||||
|
volumes:
|
||||||
|
# The folder, not the single file: editors that save by replacing the file would break a file mount.
|
||||||
|
- ./config:/app/config:ro
|
||||||
networks:
|
networks:
|
||||||
- actionBlocks_server
|
- actionBlocks_server
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |