0

Mô hình hóa màn hình kết quả cho game trình duyệt ngắn bằng máy trạng thái

Một game trình duyệt có phiên chơi ngắn thường không thiếu dữ liệu. Vấn đề khó hơn là biến dữ liệu sau mỗi phiên thành một câu trả lời rõ ràng cho người chơi: vừa xảy ra điều gì, tiến trình nào đã thay đổi và nên làm gì tiếp theo.

Nếu màn hình kết quả chỉ liệt kê điểm, cấp độ, nhiệm vụ, vật phẩm sưu tầm và thành tựu với cùng một mức ưu tiên, người chơi phải tự ghép chúng thành ý nghĩa. Bài viết này trình bày một mô hình kỹ thuật có thể áp dụng cho nhiều game web: coi màn hình kết quả là một chuỗi chuyển trạng thái, tạo một view model riêng cho giao diện và chỉ chọn một hành động tiếp theo làm trọng tâm.

Đây là mô hình thiết kế tham khảo, không phải mô tả mã nguồn hay công nghệ nội bộ của bất kỳ sản phẩm nào.

1. Màn hình kết quả là một chuyển trạng thái

Ta có thể mô tả vòng đời tối thiểu của một phiên như sau:

READY -> PLAYING -> COMPLETED -> SUMMARIZED -> NEXT_ACTION

  • READY: người chơi chọn hoặc bắt đầu một phiên.
  • PLAYING: hệ thống ghi nhận các sự kiện trong phiên.
  • COMPLETED: phiên đã có điều kiện kết thúc xác định.
  • SUMMARIZED: dữ liệu thô được chuyển thành thay đổi có ý nghĩa.
  • NEXT_ACTION: giao diện chọn một bước tiếp theo phù hợp để nhấn mạnh.

Điểm quan trọng là không nhảy trực tiếp từ COMPLETED sang một trang chứa mọi chỉ số. Trạng thái SUMMARIZED tạo một ranh giới rõ ràng: logic miền tính toán kết quả, còn giao diện chỉ nhận dữ liệu đã được tổ chức.

2. Tách kết quả của phiên và tiến trình tích lũy

Một cấu trúc dữ liệu tối thiểu có thể được mô hình hóa như sau:

type SessionResult = {
  sessionId: string;
  pointsDelta: number;
  completedTaskIds: string[];
  collectedItemIds: string[];
  unlockedAchievementIds: string[];
  grantedBonusIds: string[];
};

type ProgressSnapshot = {
  totalPoints: number;
  rank: string;
  level: number;
  activeTaskIds: string[];
  collectionProgress: Record<string, number>;
};

type NextAction = {
  kind: "retry" | "continue-task" | "inspect-collection" | "view-achievement";
  label: string;
  reason: string;
};

type ResultViewModel = {
  session: SessionResult;
  before: ProgressSnapshot;
  after: ProgressSnapshot;
  changedFields: string[];
  primaryAction: NextAction;
  secondaryActions: NextAction[];
};

SessionResult trả lời “phiên vừa rồi có gì”. Hai snapshot beforeafter trả lời “tiến trình dài hạn thay đổi thế nào”. Khi hai nhóm dữ liệu này bị trộn từ đầu, UI rất khó giải thích vì sao một con số xuất hiện.

3. Tạo view model thay vì để component tự suy luận

Component hiển thị không nên tự tính thứ hạng, tự tìm nhiệm vụ gần hoàn thành hoặc tự suy đoán thành tựu mới. Một hàm dựng view model giúp tập trung các quy tắc:

function buildResultView(
  session: SessionResult,
  before: ProgressSnapshot,
  after: ProgressSnapshot
): ResultViewModel {
  const changedFields = detectChanges(before, after);
  const candidates = buildNextActionCandidates(session, before, after);

  return {
    session,
    before,
    after,
    changedFields,
    primaryAction: rankActions(candidates)[0],
    secondaryActions: rankActions(candidates).slice(1, 3),
  };
}

Cách này có ba lợi ích:

  1. Có thể kiểm thử logic kết quả mà không cần render giao diện.
  2. Nhiều màn hình hoặc kích thước thiết bị dùng chung một nguồn dữ liệu.
  3. Thay đổi cách ưu tiên hành động không làm rải logic khắp component.

4. Chọn đúng một hành động chính

Một hành động tiếp theo có thể được chấm theo bốn tiêu chí:

  • Gần hoàn thành: mục tiêu có thể đạt trong một bước ngắn hay không.
  • Liên tục: hành động có nối trực tiếp với điều vừa xảy ra hay không.
  • Rõ ràng: nhãn và lý do có thể hiểu trong một câu hay không.
  • Tải nhận thức: hành động có mở thêm quá nhiều lựa chọn hay không.

Một công thức đơn giản để bắt đầu:

score = proximity + continuity + clarity - cognitiveLoad

Ví dụ, nếu một nhiệm vụ vừa tiến gần đến mốc hoàn thành, “Tiếp tục nhiệm vụ” có thể hợp lý hơn “Xem toàn bộ bộ sưu tập”. Nếu một thành tựu vừa được mở, “Xem thành tựu” có thể là bước tiếp theo tự nhiên hơn “Chơi lại”. Quy tắc không cần hoàn hảo ngay từ đầu; nó chỉ cần minh bạch và kiểm thử được.

5. Chống cộng lặp khi tải lại hoặc mất kết nối

Màn hình kết quả thường được mở lại do refresh, kết nối chập chờn hoặc người chơi quay lại tab cũ. Vì vậy, thao tác tổng kết nên có tính idempotent:

  • Mỗi phiên có một sessionId ổn định.
  • Điểm, bonus hoặc thành tựu chỉ được ghi nhận một lần cho cùng phiên.
  • API trả lại cùng một kết quả đã xác nhận thay vì cộng lại.
  • UI có thể dựng lại từ snapshot mà không phụ thuộc vào animation trước đó.

Một invariant hữu ích cho kiểm thử là:

summarize(sessionId) gọi nhiều lần -> cùng một kết quả miền

Animation có thể chạy lại hoặc bỏ qua, nhưng dữ liệu tiến trình không được thay đổi thêm chỉ vì trang được tải lại.

6. Thứ tự hiển thị nên đi theo thời gian

Sau khi có ResultViewModel, giao diện có thể trình bày theo thứ tự:

  1. Kết quả vừa đạt được: điểm và nội dung hoàn thành trong phiên.
  2. Thay đổi dài hạn: rank, level, nhiệm vụ, sưu tầm hoặc thành tựu đã đổi.
  3. Một hành động chính: bước gần và dễ hiểu nhất.
  4. Chi tiết phụ: các hành động thứ cấp hoặc lịch sử đầy đủ.

Thứ tự này đi từ “vừa xảy ra” đến “nên làm tiếp”, phù hợp với cách người chơi đọc kết quả. Nó cũng giúp responsive UI dễ hơn: trên màn hình nhỏ, ba khối đầu vẫn đủ để hiểu; phần chi tiết có thể thu gọn.

7. Dùng một trường hợp cụ thể để kiểm tra mô hình

Có thể quan sát Angel Arena của ARMCP như một trường hợp thực tế để đặt câu hỏi cho mô hình trên. Trải nghiệm có các phiên, điểm, rank hoặc level, nhiệm vụ, yếu tố sưu tầm, thành tựu, tiến trình và bonus. Thay vì chỉ kiểm tra xem các tính năng có tồn tại hay không, hãy kiểm tra quan hệ giữa chúng sau một phiên ngắn:

  • Điểm của phiên có tách khỏi tổng điểm hay không?
  • Một thay đổi về rank hoặc level có giải thích được nguyên nhân hay không?
  • Nhiệm vụ, sưu tầm và thành tựu có cạnh tranh sự chú ý với nhau hay không?
  • Hành động tiếp theo có nói rõ lý do hay chỉ ghi “thử lại”?
  • Khi tải lại trang kết quả, dữ liệu có giữ nguyên nghĩa hay không?

Liên kết ở đây là đối tượng kiểm tra cụ thể cho phương pháp, không phải bằng chứng rằng sản phẩm sử dụng đúng cấu trúc TypeScript ở trên.

8. Bộ kiểm thử tối thiểu

Trước khi tối ưu animation hoặc màu sắc, nên có các kiểm thử cho logic kết quả:

describe("buildResultView", () => {
  it("tách pointsDelta khỏi totalPoints");
  it("chỉ đánh dấu các trường thực sự thay đổi");
  it("chọn đúng một primaryAction");
  it("không chọn hành động bị vô hiệu hóa");
  it("giữ kết quả ổn định khi xử lý lại cùng sessionId");
});

Ngoài unit test, một bảng kiểm UX ngắn cũng hữu ích:

  • Người mới có nói lại được “tôi vừa nhận gì” sau 5 giây không?
  • Người chơi cũ có nhận ra tiến trình dài hạn nào thay đổi không?
  • Primary action có khác biệt rõ với secondary actions không?
  • Nếu không có thay đổi dài hạn, giao diện có tránh tạo cảm giác lỗi không?
  • Nếu có nhiều unlock cùng lúc, hệ thống có nhóm chúng thay vì hiển thị dồn dập không?

Kết luận

Màn hình kết quả không chỉ là trang cuối của một phiên. Nó là một lớp chuyển đổi giữa sự kiện miền, tiến trình tích lũy và quyết định tiếp theo của người chơi. Bằng cách thêm trạng thái SUMMARIZED, tạo ResultViewModel, xếp hạng hành động tiếp theo và bảo đảm idempotency theo sessionId, ta có thể làm cho một phiên ngắn trở nên dễ hiểu mà không cần nhồi thêm thông tin.

Giá trị lớn nhất của mô hình này là khả năng kiểm thử: đội ngũ có thể tranh luận về quy tắc và dữ liệu cụ thể, thay vì chỉ chỉnh giao diện theo cảm giác.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí