Initial commit: Welcome Screen mod v1.1.0
All checks were successful
Build VS Mod / build (push) Successful in 14s
All checks were successful
Build VS Mod / build (push) Successful in 14s
- Welcome screen GUI dialog on player join - Markdown to VTML converter - Mod checker (recommended/blacklisted mods) - HOME key to reopen - CI/CD pipeline for build + release
This commit is contained in:
77
.gitea/workflows/build.yml
Normal file
77
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Build VS Mod
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build and package mod
|
||||
run: |
|
||||
VERSION=$(grep -oPm1 '"version":\s*"\K[^"]+' resources/modinfo.json)
|
||||
MODID="welcomescreen"
|
||||
OUTPUT="${MODID}_${VERSION}.zip"
|
||||
|
||||
# Build inside .NET SDK container using docker cp (DinD — bind mounts don't work)
|
||||
docker volume create mod-build
|
||||
docker pull alpine:3 >/dev/null 2>&1
|
||||
HELPER=$(docker create -v mod-build:/src alpine:3 true)
|
||||
docker cp . $HELPER:/src/
|
||||
docker rm $HELPER >/dev/null
|
||||
|
||||
docker run --rm -v mod-build:/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 \
|
||||
dotnet build -c Release
|
||||
|
||||
# Extract artifacts from volume
|
||||
HELPER=$(docker create -v mod-build:/src alpine:3 true)
|
||||
docker cp $HELPER:/src/bin/Release/WelcomeScreen.dll ./WelcomeScreen.dll
|
||||
docker rm $HELPER >/dev/null
|
||||
docker volume rm mod-build >/dev/null
|
||||
|
||||
# Package ZIP
|
||||
mkdir -p dist
|
||||
cp WelcomeScreen.dll dist/
|
||||
cp resources/modinfo.json dist/
|
||||
cd dist && zip -j "../${OUTPUT}" WelcomeScreen.dll modinfo.json
|
||||
cd ..
|
||||
|
||||
echo "MOD_ZIP=${OUTPUT}" >> $GITHUB_ENV
|
||||
echo "MOD_VERSION=${VERSION}" >> $GITHUB_ENV
|
||||
echo "Built ${OUTPUT}"
|
||||
|
||||
- name: Create release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
# Generate a temporary token for the API call
|
||||
TOKEN=$(docker exec gitea gitea admin user generate-access-token \
|
||||
--user git --username calic --scopes write 2>&1 | grep -oPm1 '(?<=: ).*')
|
||||
|
||||
# Create release with artifact
|
||||
RELEASE_ID=$(curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Welcome Screen ${MOD_VERSION}\",\"body\":\"Release ${MOD_VERSION}\"}" \
|
||||
"http://gitea:3000/api/v1/repos/calic/vs-welcome-screen/releases" | grep -oPm1 '"id":\K[0-9]+')
|
||||
|
||||
# Upload artifact
|
||||
curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-F "attachment=@${MOD_ZIP}" \
|
||||
"http://gitea:3000/api/v1/repos/calic/vs-welcome-screen/releases/${RELEASE_ID}/assets"
|
||||
|
||||
# Clean up token
|
||||
TOKEN_ID=$(curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"http://gitea:3000/api/v1/users/calic/tokens" | grep -oPm1 '"id":\K[0-9]+')
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"http://gitea:3000/api/v1/users/calic/tokens/${TOKEN_ID}"
|
||||
|
||||
echo "Release ${TAG} created with artifact ${MOD_ZIP}"
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
19
WelcomeScreen.csproj
Normal file
19
WelcomeScreen.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>WelcomeScreen</AssemblyName>
|
||||
<RootNamespace>WelcomeScreen</RootNamespace>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="VintagestoryAPI">
|
||||
<HintPath>lib/VintagestoryAPI.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="protobuf-net">
|
||||
<HintPath>lib/protobuf-net.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
32
build.sh
Executable file
32
build.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build and package the Welcome Screen mod for Vintage Story
|
||||
# Usage: ./build.sh [--install]
|
||||
|
||||
VERSION=$(grep -oP '"version":\s*"\K[^"]+' resources/modinfo.json)
|
||||
MODID="welcomescreen"
|
||||
OUTPUT="${MODID}_${VERSION}.zip"
|
||||
MODS_DIR="/opt/gameservers/vintage-story/server/shared/Mods"
|
||||
|
||||
echo "Building ${MODID} v${VERSION}..."
|
||||
dotnet build -c Release
|
||||
|
||||
echo "Packaging ${OUTPUT}..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
cp bin/Release/WelcomeScreen.dll "$TMPDIR/"
|
||||
cp resources/modinfo.json "$TMPDIR/"
|
||||
cd "$TMPDIR"
|
||||
zip -j "/tmp/${OUTPUT}" WelcomeScreen.dll modinfo.json
|
||||
rm -rf "$TMPDIR"
|
||||
cd - > /dev/null
|
||||
|
||||
echo "Built: /tmp/${OUTPUT}"
|
||||
|
||||
if [[ "${1:-}" == "--install" ]]; then
|
||||
echo "Installing to ${MODS_DIR}..."
|
||||
rm -f "${MODS_DIR}/${MODID}_"*.zip
|
||||
cp "/tmp/${OUTPUT}" "${MODS_DIR}/"
|
||||
chown 1000:1000 "${MODS_DIR}/${OUTPUT}"
|
||||
echo "Installed. Restart the VS server to apply."
|
||||
fi
|
||||
BIN
lib/VintagestoryAPI.dll
Normal file
BIN
lib/VintagestoryAPI.dll
Normal file
Binary file not shown.
BIN
lib/protobuf-net.dll
Normal file
BIN
lib/protobuf-net.dll
Normal file
Binary file not shown.
11
resources/modinfo.json
Normal file
11
resources/modinfo.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "code",
|
||||
"modid": "welcomescreen",
|
||||
"name": "Welcome Screen",
|
||||
"version": "1.1.0",
|
||||
"description": "Shows a welcome screen with server rules and info when players join. Includes mod checker that compares client mods against recommended/blacklisted mods. Content loaded from Markdown, rendered as VTML. Press HOME to reopen.",
|
||||
"authors": ["Calic"],
|
||||
"side": "universal",
|
||||
"requiredOnClient": true,
|
||||
"requiredOnServer": true
|
||||
}
|
||||
128
src/MarkdownToVtml.cs
Normal file
128
src/MarkdownToVtml.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WelcomeScreen
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a subset of Markdown to VTML (Vintage Text Markup Language).
|
||||
///
|
||||
/// Supported Markdown syntax:
|
||||
/// # Heading 1 → large bold gold text
|
||||
/// ## Heading 2 → medium bold light-green text
|
||||
/// ### Heading 3 → small bold white text
|
||||
/// **bold** → <strong>bold</strong>
|
||||
/// *italic* → <i>italic</i>
|
||||
/// [text](url) → <a href="url">text</a>
|
||||
/// --- → ─────────── (horizontal rule)
|
||||
/// - list item → • list item
|
||||
/// 1. numbered item → 1. numbered item (preserved)
|
||||
/// > blockquote → indented italic text
|
||||
/// blank line → <br>
|
||||
/// </summary>
|
||||
public static class MarkdownToVtml
|
||||
{
|
||||
public static string Convert(string markdown)
|
||||
{
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
string[] lines = markdown.Replace("\r\n", "\n").Split('\n');
|
||||
bool lastWasBlank = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
string line = lines[i];
|
||||
string trimmed = line.Trim();
|
||||
|
||||
// Blank line
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
if (!lastWasBlank)
|
||||
sb.Append("<br><br>");
|
||||
lastWasBlank = true;
|
||||
continue;
|
||||
}
|
||||
lastWasBlank = false;
|
||||
|
||||
// Horizontal rule
|
||||
if (Regex.IsMatch(trimmed, @"^-{3,}$") || Regex.IsMatch(trimmed, @"^\*{3,}$"))
|
||||
{
|
||||
sb.Append("<br><font color=\"#666666\">────────────────────────────────</font><br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
if (trimmed.StartsWith("### "))
|
||||
{
|
||||
string text = ProcessInline(trimmed.Substring(4));
|
||||
sb.Append($"<font size=\"18\" weight=\"bold\" color=\"#dddddd\">{text}</font><br>");
|
||||
continue;
|
||||
}
|
||||
if (trimmed.StartsWith("## "))
|
||||
{
|
||||
string text = ProcessInline(trimmed.Substring(3));
|
||||
sb.Append($"<font size=\"20\" weight=\"bold\" color=\"#88bb88\">{text}</font><br>");
|
||||
continue;
|
||||
}
|
||||
if (trimmed.StartsWith("# "))
|
||||
{
|
||||
string text = ProcessInline(trimmed.Substring(2));
|
||||
sb.Append($"<font size=\"24\" weight=\"bold\" color=\"#c4a55a\">{text}</font><br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (trimmed.StartsWith("> "))
|
||||
{
|
||||
string text = ProcessInline(trimmed.Substring(2));
|
||||
sb.Append($"<font color=\"#aaaaaa\"><i> {text}</i></font><br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
|
||||
{
|
||||
string text = ProcessInline(trimmed.Substring(2));
|
||||
sb.Append($" • {text}<br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list (1. 2. 3. etc.)
|
||||
var olMatch = Regex.Match(trimmed, @"^(\d+)\.\s+(.+)$");
|
||||
if (olMatch.Success)
|
||||
{
|
||||
string num = olMatch.Groups[1].Value;
|
||||
string text = ProcessInline(olMatch.Groups[2].Value);
|
||||
sb.Append($" {num}. {text}<br>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph line
|
||||
sb.Append(ProcessInline(trimmed));
|
||||
sb.Append("<br>");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ProcessInline(string text)
|
||||
{
|
||||
// Bold: **text** or __text__
|
||||
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||
text = Regex.Replace(text, @"__(.+?)__", "<strong>$1</strong>");
|
||||
|
||||
// Italic: *text* or _text_ (but not inside bold markers)
|
||||
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<i>$1</i>");
|
||||
text = Regex.Replace(text, @"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", "<i>$1</i>");
|
||||
|
||||
// Links: [text](url)
|
||||
text = Regex.Replace(text, @"\[([^\]]+)\]\(([^)]+)\)", "<a href=\"$2\">$1</a>");
|
||||
|
||||
// Inline code: `code` → just bold it (VTML has no monospace)
|
||||
text = Regex.Replace(text, @"`([^`]+)`", "<strong>$1</strong>");
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
105
src/WelcomeScreenDialog.cs
Normal file
105
src/WelcomeScreenDialog.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Vintagestory.API.Client;
|
||||
|
||||
namespace WelcomeScreen
|
||||
{
|
||||
public class WelcomeScreenDialog : GuiDialog
|
||||
{
|
||||
private readonly string title;
|
||||
private readonly string vtmlContent;
|
||||
|
||||
public override string ToggleKeyCombinationCode => "welcomescreen";
|
||||
public override bool PrefersUngrabbedMouse => true;
|
||||
public override bool DisableMouseGrab => true;
|
||||
|
||||
public WelcomeScreenDialog(ICoreClientAPI capi, string title, string vtmlContent)
|
||||
: base(capi)
|
||||
{
|
||||
this.title = title ?? "Welcome";
|
||||
this.vtmlContent = vtmlContent ?? "";
|
||||
ComposeDialog();
|
||||
}
|
||||
|
||||
private void ComposeDialog()
|
||||
{
|
||||
int dialogWidth = 550;
|
||||
int dialogHeight = 450;
|
||||
int contentHeight = 2000; // Large enough for scrollable content
|
||||
int buttonHeight = 40;
|
||||
int padding = 15;
|
||||
|
||||
ElementBounds dialogBounds = ElementStdBounds.AutosizedMainDialog
|
||||
.WithAlignment(EnumDialogArea.CenterMiddle);
|
||||
|
||||
// Background bounds
|
||||
ElementBounds bgBounds = ElementBounds.Fixed(0, 0, dialogWidth, dialogHeight + 50)
|
||||
.WithFixedPadding(GuiStyle.ElementToDialogPadding);
|
||||
|
||||
// Inset area for scrollable content
|
||||
ElementBounds insetBounds = ElementBounds.Fixed(0, GuiStyle.TitleBarHeight + padding,
|
||||
dialogWidth, dialogHeight - buttonHeight - padding * 2);
|
||||
|
||||
// Scrollbar
|
||||
ElementBounds scrollbarBounds = insetBounds.RightCopy().WithFixedWidth(20);
|
||||
|
||||
// Clip area (inside inset)
|
||||
ElementBounds clipBounds = insetBounds.ForkContainingChild(
|
||||
GuiStyle.HalfPadding, GuiStyle.HalfPadding,
|
||||
GuiStyle.HalfPadding, GuiStyle.HalfPadding);
|
||||
|
||||
// Container for richtext content
|
||||
ElementBounds containerBounds = clipBounds.CopyOffsetedSibling(0, 0);
|
||||
containerBounds.fixedWidth = clipBounds.fixedWidth - 10;
|
||||
|
||||
// Richtext bounds inside container
|
||||
ElementBounds richtextBounds = ElementBounds.Fixed(0, 0,
|
||||
clipBounds.fixedWidth - 20, contentHeight);
|
||||
|
||||
// OK button
|
||||
ElementBounds buttonBounds = ElementBounds.FixedSize(100, buttonHeight)
|
||||
.FixedUnder(insetBounds, padding)
|
||||
.WithAlignment(EnumDialogArea.CenterFixed);
|
||||
|
||||
bgBounds.WithChildren(insetBounds, scrollbarBounds, buttonBounds);
|
||||
|
||||
SingleComposer = capi.Gui.CreateCompo("welcomeScreenDialog", dialogBounds)
|
||||
.AddShadedDialogBG(bgBounds)
|
||||
.AddDialogTitleBar(title, OnClose)
|
||||
.BeginChildElements(bgBounds)
|
||||
.AddInset(insetBounds, 3)
|
||||
.BeginClip(clipBounds)
|
||||
.AddRichtext(vtmlContent, CairoFont.WhiteSmallText(), containerBounds, "richtext")
|
||||
.EndClip()
|
||||
.AddVerticalScrollbar(OnScrollbarValue, scrollbarBounds, "scrollbar")
|
||||
.AddButton("OK", OnOkClicked, buttonBounds)
|
||||
.EndChildElements()
|
||||
.Compose();
|
||||
|
||||
// Set scrollbar height based on actual rendered content height
|
||||
var richtextElem = SingleComposer.GetRichtext("richtext");
|
||||
float totalHeight = richtextElem != null ? (float)richtextElem.Bounds.fixedHeight : contentHeight;
|
||||
SingleComposer.GetScrollbar("scrollbar")
|
||||
.SetHeights((float)clipBounds.fixedHeight, totalHeight);
|
||||
}
|
||||
|
||||
private void OnScrollbarValue(float value)
|
||||
{
|
||||
var richtext = SingleComposer.GetRichtext("richtext");
|
||||
if (richtext != null)
|
||||
{
|
||||
richtext.Bounds.fixedY = 3 - value;
|
||||
richtext.Bounds.CalcWorldBounds();
|
||||
}
|
||||
}
|
||||
|
||||
private bool OnOkClicked()
|
||||
{
|
||||
TryClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnClose()
|
||||
{
|
||||
TryClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
377
src/WelcomeScreenMod.cs
Normal file
377
src/WelcomeScreenMod.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using ProtoBuf;
|
||||
using Vintagestory.API.Client;
|
||||
using Vintagestory.API.Common;
|
||||
using Vintagestory.API.Server;
|
||||
|
||||
namespace WelcomeScreen
|
||||
{
|
||||
// --- Network Packets ---
|
||||
|
||||
[ProtoContract]
|
||||
public class WelcomePacket
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public string Title;
|
||||
|
||||
[ProtoMember(2)]
|
||||
public string VtmlContent;
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class ClientModInfo
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public string ModId;
|
||||
|
||||
[ProtoMember(2)]
|
||||
public string Version;
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class ClientModListPacket
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public List<ClientModInfo> Mods;
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
public class ModCheckResultPacket
|
||||
{
|
||||
[ProtoMember(1)]
|
||||
public string ModStatusVtml;
|
||||
}
|
||||
|
||||
// --- Config Classes ---
|
||||
|
||||
public class WelcomeConfig
|
||||
{
|
||||
public string Title { get; set; } = "Welcome";
|
||||
public bool ShowOnEveryJoin { get; set; } = true;
|
||||
}
|
||||
|
||||
public class RecommendedModEntry
|
||||
{
|
||||
public string ModId { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string ModDbUrl { get; set; }
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
|
||||
public class BlacklistedModEntry
|
||||
{
|
||||
public string ModId { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
|
||||
public class ModCheckConfig
|
||||
{
|
||||
public List<RecommendedModEntry> RecommendedMods { get; set; } = new List<RecommendedModEntry>();
|
||||
public List<BlacklistedModEntry> BlacklistedMods { get; set; } = new List<BlacklistedModEntry>();
|
||||
}
|
||||
|
||||
// --- Main Mod ---
|
||||
|
||||
public class WelcomeScreenMod : ModSystem
|
||||
{
|
||||
const string ChannelName = "welcomescreen";
|
||||
const string ConfigFile = "WelcomeScreenConfig.json";
|
||||
const string ModCheckConfigFile = "WelcomeScreenModCheck.json";
|
||||
const string ContentFile = "welcome.md";
|
||||
|
||||
public override void Start(ICoreAPI api)
|
||||
{
|
||||
api.Network
|
||||
.RegisterChannel(ChannelName)
|
||||
.RegisterMessageType<WelcomePacket>()
|
||||
.RegisterMessageType<ClientModListPacket>()
|
||||
.RegisterMessageType<ModCheckResultPacket>();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// SERVER SIDE
|
||||
// =====================
|
||||
|
||||
ICoreServerAPI sapi;
|
||||
IServerNetworkChannel serverChannel;
|
||||
|
||||
public override void StartServerSide(ICoreServerAPI api)
|
||||
{
|
||||
sapi = api;
|
||||
|
||||
// Ensure welcome config exists
|
||||
var config = api.LoadModConfig<WelcomeConfig>(ConfigFile);
|
||||
if (config == null)
|
||||
{
|
||||
config = new WelcomeConfig();
|
||||
api.StoreModConfig(config, ConfigFile);
|
||||
}
|
||||
|
||||
// Ensure mod check config exists with defaults
|
||||
var modCheckConfig = api.LoadModConfig<ModCheckConfig>(ModCheckConfigFile);
|
||||
if (modCheckConfig == null)
|
||||
{
|
||||
modCheckConfig = GetDefaultModCheckConfig();
|
||||
api.StoreModConfig(modCheckConfig, ModCheckConfigFile);
|
||||
api.Logger.Notification("[WelcomeScreen] Created default mod check config.");
|
||||
}
|
||||
|
||||
// Ensure welcome.md exists
|
||||
string contentPath = GetContentPath(api);
|
||||
if (!File.Exists(contentPath))
|
||||
{
|
||||
File.WriteAllText(contentPath, GetDefaultMarkdown());
|
||||
api.Logger.Notification("[WelcomeScreen] Created default welcome.md at: " + contentPath);
|
||||
}
|
||||
|
||||
serverChannel = api.Network.GetChannel(ChannelName);
|
||||
|
||||
// Handle client mod list reports
|
||||
serverChannel.SetMessageHandler<ClientModListPacket>(OnClientModListReceived);
|
||||
|
||||
// Send welcome content on join
|
||||
api.Event.PlayerNowPlaying += OnPlayerNowPlaying;
|
||||
|
||||
api.Logger.Notification("[WelcomeScreen] Server loaded. Content: " + contentPath);
|
||||
}
|
||||
|
||||
private void OnPlayerNowPlaying(IServerPlayer player)
|
||||
{
|
||||
var config = sapi.LoadModConfig<WelcomeConfig>(ConfigFile);
|
||||
string markdown = File.ReadAllText(GetContentPath(sapi));
|
||||
string vtml = MarkdownToVtml.Convert(markdown);
|
||||
|
||||
serverChannel.SendPacket(new WelcomePacket
|
||||
{
|
||||
Title = config?.Title ?? "Welcome",
|
||||
VtmlContent = vtml
|
||||
}, player);
|
||||
|
||||
sapi.Logger.Debug("[WelcomeScreen] Sent welcome screen to " + player.PlayerName);
|
||||
}
|
||||
|
||||
private void OnClientModListReceived(IServerPlayer player, ClientModListPacket packet)
|
||||
{
|
||||
var modCheckConfig = sapi.LoadModConfig<ModCheckConfig>(ModCheckConfigFile);
|
||||
if (modCheckConfig == null) return;
|
||||
|
||||
var clientModIds = new HashSet<string>(
|
||||
packet.Mods.Select(m => m.ModId.ToLowerInvariant()));
|
||||
|
||||
var clientModVersions = packet.Mods.ToDictionary(
|
||||
m => m.ModId.ToLowerInvariant(), m => m.Version);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
bool hasContent = false;
|
||||
|
||||
// --- Recommended Mods Section ---
|
||||
if (modCheckConfig.RecommendedMods != null && modCheckConfig.RecommendedMods.Count > 0)
|
||||
{
|
||||
hasContent = true;
|
||||
sb.Append("<br><font color=\"#666666\">────────────────────────────────</font><br>");
|
||||
sb.Append("<font size=\"20\" weight=\"bold\" color=\"#88bb88\">Empfohlene Mods</font><br><br>");
|
||||
|
||||
foreach (var rec in modCheckConfig.RecommendedMods)
|
||||
{
|
||||
string id = rec.ModId.ToLowerInvariant();
|
||||
bool installed = clientModIds.Contains(id);
|
||||
|
||||
if (installed)
|
||||
{
|
||||
string ver = clientModVersions.ContainsKey(id) ? clientModVersions[id] : "";
|
||||
sb.Append($"<font color=\"#66cc66\"> ✓ </font>");
|
||||
sb.Append($"<strong>{rec.DisplayName}</strong>");
|
||||
if (!string.IsNullOrEmpty(ver))
|
||||
sb.Append($" <font color=\"#888888\">v{ver}</font>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($"<font color=\"#cc6666\"> ✗ </font>");
|
||||
sb.Append($"<strong>{rec.DisplayName}</strong>");
|
||||
sb.Append($" <font color=\"#cc6666\">— nicht installiert</font>");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(rec.Reason))
|
||||
sb.Append($" <font color=\"#aaaaaa\">({rec.Reason})</font>");
|
||||
|
||||
if (!installed && !string.IsNullOrEmpty(rec.ModDbUrl))
|
||||
sb.Append($" <a href=\"{rec.ModDbUrl}\">Download</a>");
|
||||
|
||||
sb.Append("<br>");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Blacklisted Mods Section ---
|
||||
if (modCheckConfig.BlacklistedMods != null && modCheckConfig.BlacklistedMods.Count > 0)
|
||||
{
|
||||
var violations = modCheckConfig.BlacklistedMods
|
||||
.Where(b => clientModIds.Contains(b.ModId.ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
hasContent = true;
|
||||
sb.Append("<br><font size=\"20\" weight=\"bold\" color=\"#cc6666\">Nicht erlaubte Mods</font><br><br>");
|
||||
|
||||
foreach (var bl in violations)
|
||||
{
|
||||
sb.Append($"<font color=\"#ff4444\"> ⚠ </font>");
|
||||
sb.Append($"<strong>{bl.DisplayName ?? bl.ModId}</strong>");
|
||||
if (!string.IsNullOrEmpty(bl.Reason))
|
||||
sb.Append($" <font color=\"#cc6666\">— {bl.Reason}</font>");
|
||||
sb.Append("<br>");
|
||||
}
|
||||
|
||||
sb.Append("<br><font color=\"#cc6666\">Bitte entferne diese Mods um auf diesem Server zu spielen.</font><br>");
|
||||
}
|
||||
}
|
||||
|
||||
if (hasContent)
|
||||
{
|
||||
serverChannel.SendPacket(new ModCheckResultPacket
|
||||
{
|
||||
ModStatusVtml = sb.ToString()
|
||||
}, player);
|
||||
|
||||
sapi.Logger.Debug("[WelcomeScreen] Sent mod check results to " + player.PlayerName +
|
||||
" (" + packet.Mods.Count + " client mods reported)");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetContentPath(ICoreServerAPI api)
|
||||
{
|
||||
string dir = api.GetOrCreateDataPath("ModConfig/WelcomeScreen");
|
||||
return Path.Combine(dir, ContentFile);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// CLIENT SIDE
|
||||
// =====================
|
||||
|
||||
ICoreClientAPI capi;
|
||||
WelcomeScreenDialog dialog;
|
||||
string lastTitle;
|
||||
string lastWelcomeVtml;
|
||||
string lastModStatusVtml;
|
||||
|
||||
public override void StartClientSide(ICoreClientAPI api)
|
||||
{
|
||||
capi = api;
|
||||
|
||||
var channel = api.Network.GetChannel(ChannelName);
|
||||
channel.SetMessageHandler<WelcomePacket>(OnWelcomeReceived);
|
||||
channel.SetMessageHandler<ModCheckResultPacket>(OnModCheckReceived);
|
||||
|
||||
api.Input.RegisterHotKey("welcomescreen", "Open Welcome Screen", GlKeys.Home);
|
||||
api.Input.SetHotKeyHandler("welcomescreen", OnHotKeyPressed);
|
||||
}
|
||||
|
||||
private void OnWelcomeReceived(WelcomePacket msg)
|
||||
{
|
||||
lastTitle = msg.Title;
|
||||
lastWelcomeVtml = msg.VtmlContent;
|
||||
lastModStatusVtml = null;
|
||||
ShowDialog();
|
||||
|
||||
// Send our mod list to the server
|
||||
SendModList();
|
||||
}
|
||||
|
||||
private void OnModCheckReceived(ModCheckResultPacket msg)
|
||||
{
|
||||
lastModStatusVtml = msg.ModStatusVtml;
|
||||
// Refresh dialog with mod status appended
|
||||
ShowDialog();
|
||||
}
|
||||
|
||||
private void SendModList()
|
||||
{
|
||||
var mods = new List<ClientModInfo>();
|
||||
foreach (var mod in capi.ModLoader.Mods)
|
||||
{
|
||||
mods.Add(new ClientModInfo
|
||||
{
|
||||
ModId = mod.Info.ModID,
|
||||
Version = mod.Info.Version
|
||||
});
|
||||
}
|
||||
|
||||
capi.Network.GetChannel(ChannelName)
|
||||
.SendPacket(new ClientModListPacket { Mods = mods });
|
||||
}
|
||||
|
||||
private bool OnHotKeyPressed(KeyCombination comb)
|
||||
{
|
||||
if (lastWelcomeVtml != null)
|
||||
ShowDialog();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ShowDialog()
|
||||
{
|
||||
if (dialog != null && dialog.IsOpened())
|
||||
dialog.TryClose();
|
||||
|
||||
string combinedVtml = lastWelcomeVtml ?? "";
|
||||
if (!string.IsNullOrEmpty(lastModStatusVtml))
|
||||
combinedVtml += lastModStatusVtml;
|
||||
|
||||
dialog = new WelcomeScreenDialog(capi, lastTitle, combinedVtml);
|
||||
dialog.TryOpen();
|
||||
}
|
||||
|
||||
// =====================
|
||||
// DEFAULTS
|
||||
// =====================
|
||||
|
||||
private ModCheckConfig GetDefaultModCheckConfig()
|
||||
{
|
||||
return new ModCheckConfig
|
||||
{
|
||||
RecommendedMods = new List<RecommendedModEntry>
|
||||
{
|
||||
new RecommendedModEntry
|
||||
{
|
||||
ModId = "rpvoicechat",
|
||||
DisplayName = "RP Voice Chat",
|
||||
ModDbUrl = "https://mods.vintagestory.at/rpvoicechat",
|
||||
Reason = "Proximity Voice Chat"
|
||||
}
|
||||
},
|
||||
BlacklistedMods = new List<BlacklistedModEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
private string GetDefaultMarkdown()
|
||||
{
|
||||
return @"# Willkommen auf unserem Server!
|
||||
|
||||
Schön, dass du da bist. Bitte lies dir die folgenden Regeln durch.
|
||||
|
||||
## Regeln
|
||||
|
||||
1. **Respektvoller Umgang** miteinander
|
||||
2. **Kein Griefing** — zerstöre nicht die Bauwerke anderer
|
||||
3. **Kein Stealing** — nimm nichts aus fremden Kisten
|
||||
4. Halte mindestens **100 Blöcke Abstand** zu anderen Basen
|
||||
5. **PvP nur mit Einverständnis** beider Spieler
|
||||
|
||||
## Mods
|
||||
|
||||
Dieser Server nutzt zahlreiche Mods. Einige davon müsst ihr client-seitig installieren.
|
||||
Die empfohlenen Mods werden unten angezeigt.
|
||||
|
||||
## Kontakt
|
||||
|
||||
Bei Fragen wendet euch an den Admin **Calic** im Spiel oder auf Discord.
|
||||
|
||||
---
|
||||
|
||||
*Drücke HOME um dieses Fenster erneut zu öffnen.*";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user