用 ASP.NET Core + SignalR 做一个带实时对战房间的打字练习平台

一直想把打字速度练一练,但现成的练习站要么满屏广告,要么必须注册登录,统计也只给一个速度数字,看不错在哪、也看不出进步。干脆自己写一个:文章练习、指法课程、打字小游戏、多人实时对战、成绩趋势图,数据全部存在浏览器本地。

技术上分两条线:练习判定和统计全在前端,实时对战用 SignalR 的房间模型。下面记录几个关键实现。

【插入截图:练习主界面(左侧参考文章、右侧打字区、顶部统计条)】

一、需求拆解

先把模块列清楚,后面每个模块都能独立改:

模块 技术要点
文章打字练习 隐藏 input 捕获输入、逐字符比对、实时统计速度/准确率/进度
键位指法练习 19 个渐进课程,虚拟键盘高亮 + 手指引导
趣味输入游戏 字母掉落与整词输入两种玩法,连击/生命值/等级加速
对战房间 SignalR,6 位房间号,进度实时同步,聊天,大屏看板
历史成绩 localStorage 存储,Canvas 手绘速度趋势图

前端不引框架、不引图表库,全部原生 JS 按职责拆分模块;后端只做页面渲染和对战 Hub。

二、打字判定:中文输入法是最容易踩的坑

练习判定的核心是「每输入一个字符,就跟原文对应位置比一次」,实现上用一个隐藏 input 接收输入,逐字符交给判定引擎:

javascript 复制代码
function feedText(v) {
    if (!v) return;
    for (var i = 0; i < v.length; i++) {
        engine.handleKey(v[i]);
    }
    if (window.DZSound) window.DZSound.play();
}

真正麻烦的是中文输入法。拼音、五笔在组字过程中会往 input 里写入中间态字符,如果这时照常判定,就会把拼音当成错字,准确率直接崩掉。

解决办法是组字期间不参与判定,等 compositionend 拿到上屏的最终结果再统一喂给引擎:

javascript 复制代码
function inComposition(e) {
    return !!(e && (e.isComposing || e.keyCode === 229));
}

// 只拦截功能键,可打印字符不拦,保证中文 IME 能正常启动组字
input.addEventListener("keydown", function (e) {
    if (inComposition(e)) return;
    if (e.key === "Backspace") { e.preventDefault(); engine.handleKey("Backspace"); }
    else if (e.key === "Enter") { e.preventDefault(); engine.handleKey("\n"); }
});

input.addEventListener("compositionstart", function () {
    input.dataset.composing = "1";
});

input.addEventListener("compositionend", function () {
    input.dataset.composing = "0";
    var v = input.value;
    if (v) { input.value = ""; feedText(v); }   // 上屏完成后再判定
});

input.addEventListener("input", function (e) {
    if (inComposition(e)) return;
    var v = input.value;
    if (v) { input.value = ""; feedText(v); }
});

要点有三条:keyCode === 229 是各家浏览器在组字过程中的统一信号;keydown 里只 preventDefault 功能键、绝不拦可打印字符,否则输入法无法启动组字;组字结束后立刻清空 input,避免重复判定。

三、数据层:文章、历史、偏好全在 localStorage

不登录就意味着没有服务端存储,所有数据落在浏览器本地。存储层做一层薄封装,好处是后面要换成 IndexedDB 只用改这一个文件:

javascript 复制代码
var KEYS = {
    ARTICLES: "dz_articles",      // 我的文章
    HISTORY: "dz_history",        // 练习历史
    PREFS: "dz_prefs",            // 偏好设置
    LAST_ARTICLE: "dz_last_article",
    PANEL_RATIO: "dz_panel_ratio",// 双栏宽度比例
    PAPER_SIZE: "dz_paper_size"   // A4 / A3
};

function safeGet(key, fallback) {
    try {
        var raw = localStorage.getItem(key);
        return raw ? JSON.parse(raw) : fallback;
    } catch (e) { return fallback; }
}

function safeSet(key, value) {
    try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}

safeGet / safeSet 都把异常吞掉:localStorage 在隐私模式下可能直接抛异常,读写失败不应该让整个练习流程中断。历史记录每次练习追加一条(速度、准确率、用时、字数、类型、文章 ID),趋势图就是基于这个数组渲染的。

四、实时对战:SignalR 房间设计

这是最花时间的部分。需求是创建房间拿到 6 位房间号、好友加入、对局中进度实时同步、结束后统一排名。

1. 房间与玩家状态

房间和玩家都放在内存里,用 ConcurrentDictionary 承载,天然支持多连接并发写入:

cs 复制代码
public class RoomStore
{
    private readonly ConcurrentDictionary<string, Room> _rooms = new();
    private readonly ConcurrentDictionary<string, byte> _activeConnections = new();
    private static readonly Random _rnd = new();

    public void MarkConnected(string connectionId) => _activeConnections[connectionId] = 1;
    public void MarkDisconnected(string connectionId) => _activeConnections.TryRemove(connectionId, out _);

    // 6 位数字房间号,保证不重复
    private string GenerateCode()
    {
        string code;
        do { code = _rnd.Next(100000, 999999).ToString(); }
        while (_rooms.ContainsKey(code));
        return code;
    }

    // 惰性清理:无玩家且创建超过 2 小时的空房间
    private void CleanupStaleRooms()
    {
        var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
        foreach (var kv in _rooms)
        {
            if (kv.Value.Players.IsEmpty && now - kv.Value.CreatedAt > 2 * 3600 * 1000)
                _rooms.TryRemove(kv.Key, out _);
        }
    }
}

房间不落库、不做持久化。清理策略是惰性的:每次创建房间时顺手扫一遍,把「没有玩家且超过 2 小时」的空房间删掉------用最小的代价避免内存泄漏,也不需要额外的后台定时任务。

2. Hub:创建与加入

SignalR 的 Group 语义和房间天然契合,房间号直接当 Group 名用:

cs 复制代码
public class TypingHub : Hub
{
    private readonly RoomStore _store;

    public TypingHub(RoomStore store) => _store = store;

    // 创建房间,返回房间号
    public async Task<object> CreateRoom(string nickname, string articleTitle, string articleContent)
    {
        var room = _store.CreateRoom(Context.ConnectionId, nickname, articleTitle, articleContent);
        await Groups.AddToGroupAsync(Context.ConnectionId, room.Code);
        return new
        {
            ok = true,
            code = room.Code,
            article = new { title = room.ArticleTitle, content = room.ArticleContent },
            isCreator = true
        };
    }

    // 加入房间(昵称不可重复)
    public async Task<object> JoinRoom(string code, string nickname)
    {
        var (ok, message, room) = _store.JoinRoom(code, Context.ConnectionId, nickname);
        if (!ok || room == null) return new { ok = false, message };

        await Groups.AddToGroupAsync(Context.ConnectionId, room.Code);

        // 房主已断线时,把房主身份转移给当前加入者,避免房间变成"无主"
        if (string.IsNullOrEmpty(room.CreatorConnectionId) ||
            !room.Players.ContainsKey(room.CreatorConnectionId))
        {
            room.CreatorConnectionId = Context.ConnectionId;
        }

        // 只通知房间内其他人,不用广播给所有人
        var me = room.Players[Context.ConnectionId];
        await Clients.OthersInGroup(room.Code).SendAsync("PlayerJoined", new
        {
            me.ConnectionId, me.Nickname, me.Wpm,
            me.Accuracy, me.Progress, me.Finished
        });

        return new { ok = true, players = /* 完整玩家列表 */ };
    }
}

两个设计取舍:加入时只通知 OthersInGroup,房主断线后由新加入者接管房主身份。后者一开始没做,结果房主关掉页面后,房间里的「开始游戏」按钮再也没人能点。

3. 进度同步与结算

打字过程中客户端按固定节奏上报进度,服务端只做转发、不参与计算,避免每敲一个键都发消息:

cs 复制代码
public async Task UpdateProgress(int wpm, double accuracy, double progress, int pos)
{
    var room = _store.GetRoomByConnection(Context.ConnectionId);
    if (room == null) return;

    var p = _store.GetPlayer(Context.ConnectionId);
    if (p != null)
    {
        p.Wpm = wpm; p.Accuracy = accuracy; p.Progress = progress; p.Pos = pos;
        p.LastSeen = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    }

    // 转发给房间内其他人,自己不需要收到自己的进度
    await Clients.OthersInGroup(room.Code).SendAsync(
        "ProgressUpdated", Context.ConnectionId, wpm, accuracy, progress, pos);
}

完成后调用 Finish 上报成绩,服务端记录该玩家的完成时间;当房间内所有玩家都完成时,统一计算排名并广播 GameFinished,客户端据此展示结算榜。

大屏看板单独走 WatchRoom,以观众身份加入 Group 但不占用玩家名额,所以投影的排行榜不会影响正常对局人数。

【插入截图:对战房间(多人实时进度条)或大屏看板(实时排名)】

五、趋势图:用 Canvas 手绘,不引图表库

历史页需要「柱形 + 折线 + 虚线」的组合图,为一个页面引入图表库不划算,直接用 Canvas 画更可控。

高分屏适配是必踩的一步:Canvas 的 width/height 是物理像素,CSS 尺寸是逻辑像素,不做 DPR 缩放会导致图表发虚:

javascript 复制代码
function setupCanvas(canvas, cssHeight) {
    var dpr = window.devicePixelRatio || 1;
    var w = canvas.clientWidth || canvas.parentElement.clientWidth;
    var h = cssHeight || 260;

    canvas.width = Math.round(w * dpr);      // 物理像素
    canvas.height = Math.round(h * dpr);
    canvas.style.height = h + "px";          // 逻辑像素

    var ctx = canvas.getContext("2d");
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);  // 之后按逻辑像素坐标绘制即可
    return { ctx: ctx, w: w, h: h };
}

配色不写死,从 CSS 变量读取,这样深色主题切换时图表自动跟随:

javascript 复制代码
function cssVar(name, fallback) {
    var v = getComputedStyle(document.documentElement).getPropertyValue(name);
    return (v || "").trim() || fallback;
}

折线部分用相邻点作为控制点做平滑路径,比直接 lineTo 连直线观感好很多:

javascript 复制代码
function smoothPath(ctx, pts) {
    if (pts.length === 0) return;
    ctx.moveTo(pts[0].x, pts[0].y);
    if (pts.length === 1) { ctx.lineTo(pts[0].x, pts[0].y); return; }
    for (var i = 0; i < pts.length - 1; i++) {
        var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1];
        // 以 p1、p2 为锚点绘制二次贝塞尔,形成平滑波动曲线
        ctx.quadraticCurveTo(p1.x, p1.y, (p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
    }
}

【插入截图:历史成绩页(速度柱形 + 波动曲线 + 准确率虚线)】

六、踩坑记录

  • 输入法组字 :不处理 compositionstart/compositionend,中文用户的准确率会直接失真。别用 keydown 里的 key 判断字符,拼音场景拿到的不是上屏结果。

  • keyCode === 229 :不同浏览器在组字期的表现不一致,把它和 e.isComposing 一起判断才稳。

  • localStorage 异常:隐私模式下读写会抛异常,必须 try/catch 兜底,否则一个存储失败就白屏。

  • 房主断线:房间状态里房主 ID 指向一个已断开的连接,会导致房间没人能开始游戏,需要在加入时做身份转移。

  • 空房间泄漏:内存房间不做清理,服务跑久了就是内存泄漏。惰性清理比定时任务简单,代价也可接受。

  • Canvas 发虚 :忘了按 devicePixelRatio 缩放,高分屏上图表边缘会糊。

七、小结

整个项目没有复杂的架构,四件事做对就够了:前端用隐藏 input + 组字事件处理把打字判定做准;存储层做薄封装,让本地数据可迁移;对战用 SignalR Group 映射房间,状态放内存、惰性清理;图表用 Canvas 手绘,顺带避开高分屏和主题适配的坑。

线上演示:

复制代码
https://dazi666.com

如果你也在做类似的实时对战或打字判定功能,欢迎在评论区交流。

相关推荐
OpsEye42 分钟前
大量无效 Token 消耗,是很多企业被忽略的隐形成本
javascript·ai编程
鬼手点金2 小时前
单轮对话:最简单的 API 调用
java·服务器·前端·javascript·react.js·servlet·tomcat
罗丹丹Luna3 小时前
JavaScript计算任务峰值并发:同一时刻先结束还是先开始
javascript
古夕3 小时前
同样的 font-weight,为什么这个项目的字体就是更细?
前端·javascript
牛肉胡辣汤4 小时前
Blender + Three.js 开发讲解:从 .blend 到能转的产品页
开发语言·javascript·blender
程序员大阳4 小时前
副队长JavaScript教程(20)–三元运算符、逗号运算符
javascript·三元运算符·逗号运算符
Ai-_Man6 小时前
我需要批量导出Poe左边栏所有对话,而不是一条具体的对话内容,你们AI导出鸭可以吗?
开发语言·前端·javascript·人工智能·小程序
u0111026757 小时前
Vue 页面离开后还在轮询?统一清理计时器、监听器与观察器
前端·javascript·vue.js
大猩猩X7 小时前
vue 表格组件 vxe-table 实现多行编辑,支持增删改查与校验
前端·javascript·vue.js·vxe-table