Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Components/CharacterCard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
{
<div class="flex-shrink-0">
<DieShape Expression="@(lastRoll.Rolls?.FirstOrDefault()?.Expression ?? lastRoll.Message)">
@lastRoll.Result
<RollFace Roll="lastRoll" Size="2.5em" />
</DieShape>
</div>
}
Expand Down
170 changes: 170 additions & 0 deletions Components/CustomDiceEditor.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
@inject CampaignService CampaignService

@* DM tool for defining custom dice whose faces can be overridden with text or images.
In formulas a custom die behaves as its numeric die (e.g. "1dfate" rolls a d6); when a
die is rolled on its own, an overridden face's text/image is shown instead of the number. *@

<div class="d-flex flex-column gap-2" style="min-width: 300px">
@if (!Dice.Any())
{
<span class="text-muted">No custom dice yet. Add one below.</span>
}

@foreach (var die in Dice)
{
<div class="card">
<div class="card-header py-1 px-2 d-flex align-items-center gap-2">
<input class="form-control form-control-sm" style="width: 8em" @bind="die.Name" placeholder="name" />
<span class="text-muted">d</span>
<input type="number" class="form-control form-control-sm" style="width: 5em" min="1" max="1000"
value="@die.Sides"
@onchange="e => SetSides(die, e.Value?.ToString())" />
<div class="flex-grow-1"></div>
<button class="btn btn-sm btn-secondary" @onclick="() => Toggle(die.Id)">
@(_expanded == die.Id ? "Hide faces" : "Faces")
</button>
<button class="btn btn-sm btn-primary" @onclick="() => SaveAsync(die)">Save</button>
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAsync(die)">x</button>
</div>
@if (_expanded == die.Id)
{
<div class="card-body p-2">
<p class="text-muted mb-2" style="font-size: 0.8em">
Leave a face blank to keep its number. Text or an image URL overrides how that face is shown.
</p>
<table class="table table-sm mb-0">
<thead>
<tr><th style="width: 3em">Face</th><th>Text</th><th>Image URL</th><th style="width: 2.5em"></th></tr>
</thead>
<tbody>
@for (int value = 1; value <= die.Sides; value++)
{
var v = value;
var face = GetFace(die, v);
<tr>
<td class="align-middle">@v</td>
<td>
<input class="form-control form-control-sm" value="@face.Text"
@onchange="e => SetFace(die, v, text: e.Value?.ToString())" />
</td>
<td>
<input class="form-control form-control-sm" value="@face.Image"
@onchange="e => SetFace(die, v, image: e.Value?.ToString())" />
</td>
<td class="align-middle text-center">
@if (!string.IsNullOrWhiteSpace(face.Image))
{
<img src="@face.Image" alt="@v" style="max-width: 2em; max-height: 2em; object-fit: contain" />
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}

<div class="d-flex align-items-center gap-2 mt-1">
<input class="form-control form-control-sm" style="width: 8em" @bind="_newName" placeholder="name" />
<span class="text-muted">d</span>
<input type="number" class="form-control form-control-sm" style="width: 5em" min="1" max="1000" @bind="_newSides" />
<button class="btn btn-sm btn-primary" @onclick="AddAsync">Add die</button>
</div>
@if (!string.IsNullOrEmpty(_error))
{
<span class="text-danger" style="font-size: 0.85em">@_error</span>
}
</div>

@code {
[Parameter] public int CampaignId { get; set; }
[Parameter] public string? DiscordId { get; set; }

private List<CustomDie> Dice { get; set; } = new();
private Guid? _expanded;
private string _newName = "";
private int _newSides = 6;
private string? _error;

protected override async Task OnInitializedAsync()
{
if (CampaignId > 0)
Dice = await CampaignService.GetCustomDiceAsync(CampaignId);
}

private void Toggle(Guid id) => _expanded = _expanded == id ? null : id;

private static readonly System.Text.RegularExpressions.Regex NameRegex =
new(@"^[A-Za-z][A-Za-z0-9_]*$", System.Text.RegularExpressions.RegexOptions.Compiled);

private void SetSides(CustomDie die, string? raw)
{
if (int.TryParse(raw, out var s) && s >= 1)
die.Sides = Math.Min(s, 1000);
}

// Faces are stored as JSON, so edit a working copy and write it back.
private DieFace GetFace(CustomDie die, int value)
{
var faces = die.Faces;
return faces.FirstOrDefault(f => f.Value == value) ?? new DieFace { Value = value };
}

private void SetFace(CustomDie die, int value, string? text = null, string? image = null)
{
var faces = die.Faces;
var face = faces.FirstOrDefault(f => f.Value == value);
if (face == null)
{
face = new DieFace { Value = value };
faces.Add(face);
}
if (text != null) face.Text = string.IsNullOrWhiteSpace(text) ? null : text;
if (image != null) face.Image = string.IsNullOrWhiteSpace(image) ? null : image;

// Drop faces that no longer carry an override to keep the JSON tidy.
die.Faces = faces.Where(f => f.HasOverride).ToList();
}

private async Task AddAsync()
{
_error = null;
if (!NameRegex.IsMatch(_newName))
{
_error = "Name must start with a letter and contain only letters, numbers or underscores.";
return;
}
if (Dice.Any(d => string.Equals(d.Name, _newName, StringComparison.OrdinalIgnoreCase)))
{
_error = "A die with that name already exists.";
return;
}

var die = new CustomDie { Name = _newName, Sides = Math.Clamp(_newSides, 1, 1000), CampaignId = CampaignId };
var saved = await CampaignService.SaveCustomDieAsync(die, DiscordId ?? "");
Dice.Add(saved);
_expanded = saved.Id;
_newName = "";
_newSides = 6;
}

private async Task SaveAsync(CustomDie die)
{
_error = null;
if (!NameRegex.IsMatch(die.Name))
{
_error = "Name must start with a letter and contain only letters, numbers or underscores.";
return;
}
await CampaignService.SaveCustomDieAsync(die, DiscordId ?? "");
}

private async Task DeleteAsync(CustomDie die)
{
await CampaignService.DeleteCustomDieAsync(die.Id, DiscordId ?? "");
Dice.Remove(die);
if (_expanded == die.Id) _expanded = null;
}
}
11 changes: 11 additions & 0 deletions Components/Pages/CampaignPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
</div>
</Modal>

<Modal Title="Custom Dice" id="customDiceDialog">
<div class=p-3>
<CustomDiceEditor CampaignId="@Campaign.Id" DiscordId="@DiscordId" />
</div>
</Modal>

<Modal Title="Roll" id="rollDialog">
<div class=p-3>
<RollMenu Character="@_rollDialogCharacter" />
Expand All @@ -45,6 +51,11 @@
<span class="badge bg-secondary">Default die: @Campaign.DefaultRollDie</span>
}
<span class="badge bg-secondary">Modifier formula: @(Campaign.StatModifierFormula ?? "5e")</span>
@if (IsDM) {
<button class="badge bg-secondary border-0" onClick="customDiceDialog.showModal()">
Custom dice
</button>
}
</div>
</div>
<a onClick="rollDialog.showModal()" class="btn btn-primary p-2 rounded-3 d-flex justify-content-center flex-shrink-0">
Expand Down
2 changes: 1 addition & 1 deletion Components/Pages/Character.razor
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
viewBox="0 0 16 16">
<path d="M3 0a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V3a3 3 0 0 0-3-3zm2.5 4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m8 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M12 13.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3M5.5 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M8 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3" />
</svg>
@roll.Result
<RollFace Roll="roll" Size="1.8em" />
</td>


Expand Down
2 changes: 1 addition & 1 deletion Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
{
<div class="flex-shrink-0">
<DieShape Expression="@(lastRoll.Rolls?.FirstOrDefault()?.Expression ?? lastRoll.Message)">
@lastRoll.Result
<RollFace Roll="lastRoll" Size="2.5em" />
</DieShape>
</div>
}
Expand Down
20 changes: 20 additions & 0 deletions Components/RollFace.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@* Renders a roll outcome. When the roll was a lone custom die whose face is overridden,
shows the face's image or text; otherwise falls back to the numeric result. *@
@if (!string.IsNullOrWhiteSpace(Roll?.FaceImage))
{
<img src="@Roll!.FaceImage" alt="@(Roll.FaceText ?? Roll.Result.ToString())"
style="max-width: @Size; max-height: @Size; object-fit: contain; vertical-align: middle" />
}
else if (!string.IsNullOrWhiteSpace(Roll?.FaceText))
{
<span>@Roll!.FaceText</span>
}
else
{
@(Roll?.Result)
}

@code {
[Parameter] public RollResult? Roll { get; set; }
[Parameter] public string Size { get; set; } = "2em";
}
32 changes: 30 additions & 2 deletions Components/RollMenu.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@rendermode InteractiveServer
@inject PlayerCharacterService CharacterService
@inject CampaignService CampaignService
@inject DiscordService DiscordService
@inject ILogger<RollMenu> Logger
<!-- Middle -->
Expand Down Expand Up @@ -43,6 +44,21 @@
}
</div>

@if(CustomDice.Any()) {
<div id="customDice" class="m-auto flex-grow-1">
<h3 class="text-center">Custom Dice</h3>
<div class="d-flex flex-wrap">
@foreach (var die in CustomDice)
{
<button class="btn btn-secondary flex-fill m-1" @onclick="() => AddDie(die)">
@die.Name (d@die.Sides)
</button>
}
</div>
</div>
<br>
}

@if(Character.SavedRollsDict != null) {
<div id="savedRolls" class="m-auto flex-grow-1">
<h3 class="text-center">Custom Rolls</h3>
Expand Down Expand Up @@ -83,7 +99,7 @@
<h3 class="text-danger">@ErrorMessage</h3>
}

<h4><strong>@LastResult?.Result</strong></h4>
<h4><strong><RollFace Roll="LastResult" Size="3em" /></strong></h4>
@if(LastResult?.Result != null) {
<span class="text-muted">
(@foreach (var g in LastResult?.Rolls.GroupBy(r => r.Expression)) {
Expand Down Expand Up @@ -150,17 +166,29 @@

private PlayerCharacter? _prevCharacter;

public List<CustomDie> CustomDice { get; set; } = new();

protected override void OnInitialized() {
ClearRoll();
}

protected override void OnParametersSet() {
protected override async Task OnParametersSetAsync() {
if (Character != _prevCharacter) {
_prevCharacter = Character;
ClearRoll();

CustomDice = Character?.Campaign?.Id is int campaignId
? await CampaignService.GetCustomDiceAsync(campaignId)
: new();
}
}

private void AddDie(CustomDie die)
{
var token = $"1d{die.Name}";
RollText = string.IsNullOrWhiteSpace(RollText) ? token : $"{RollText}+{token}";
}

private string Truncate(string input, int length = 3)
{
return input.Substring(0, Math.Min(length, input.Length));
Expand Down
Loading