Mọi hướng dẫn phát triển trò chơi đều bắt đầu theo cùng một cách: những hình chữ nhật đầy màu sắc trượt trên một khung nền xám. Điều đó ổn để học cú pháp, nhưng nó không dạy bạn bất cứ điều gì về cách một engine trò chơi thực sự vận hành. Tôi muốn thoát khỏi cái nghiệp làm hình chữ nhật. Tôi muốn xây dựng thứ gì đó mang lại cảm giác như một trò chơi thực thụ—một trò chơi dungeon crawler góc nhìn từ trên xuống với các tính năng di chuyển, chiến đấu, HUD và âm thanh.

Tôi đã quyết định sử dụng Phaser v4 và tự đặt ra cho mình một quy tắc cứng nhắc. Không sử dụng tài nguyên bên ngoài. Không tệp hình ảnh, không đoạn âm thanh, và không có các công cụ build như Webpack hay Vite. Toàn bộ dự án phải nằm gọn trong một tệp HTML duy nhất được viết bằng JavaScript thuần. Sự ràng buộc đó không phải là chủ nghĩa tối giản đơn thuần. Đó là việc loại bỏ mọi lý do bào chữa và mọi "hộp đen" (black box). Khi bạn không thể tải một bộ sprite về để lấp liếm lỗ hổng kiến thức của mình, bạn buộc phải học cách engine quản lý texture, animation, âm thanh và trạng thái (state) ở bên dưới lớp vỏ.

Vẽ thế giới từ mã nguồn

Trong một dự án Phaser thông thường, bạn gọi this.load.image() bên trong một hàm preload và trỏ engine đến một tệp PNG. Nếu không có tùy chọn đó, bạn sẽ chuyển sang sử dụng đối tượng Graphics. Bạn khởi tạo nó, vẽ các hình khối cơ bản—hình chữ nhật cho các ô sàn, các nét vẽ dày hơn cho tường, có thể là một hình tròn cho nhân vật người chơi—và sau đó gọi generateTexture. Phương thức đó sẽ chụp lại bộ đệm đồ họa (graphics buffer) và đăng ký nó với trình quản lý texture của Phaser dưới một khóa (key) mà bạn chọn.

Từ thời điểm đó, engine sẽ xử lý bitmap được tạo ra đó chính xác như một tệp hình ảnh đã được tải lên. Bạn có thể gán nó vào tilemap, cắt nó thành các sprite, hoặc tô màu (tint) nó. Đối với trò chơi dungeon crawler này, điều đó có nghĩa là tôi có thể tạo lưới sàn theo quy trình (procedurally generate), đóng các đoạn tường, và thay đổi bảng màu mà không cần rời khỏi trình soạn thảo mã nguồn. Bài học thực tế ở đây là một texture chỉ là một khối dữ liệu bitmap nằm trong bộ nhớ. Phaser không quan tâm liệu nó được truyền đến thông qua một yêu cầu HTTP hay một lệnh gọi Graphics được viết bằng tay.

Cách tiếp cận này cũng khiến bạn phải suy nghĩ kỹ lưỡng về thứ tự vẽ (draw order) và gom nhóm (batching). Khi mọi bức tường và ô sàn đều đến từ cùng một nhóm các texture được tạo ra, bạn sẽ bắt đầu chú ý đến cách Phaser nhóm các lệnh render. Bạn sẽ nhận thấy sự khác biệt giữa một lớp tilemap tĩnh và một spritemap của các đối tượng riêng lẻ vì chính bạn đang quyết định thủ công xem đối tượng nào xứng đáng tồn tại dưới dạng một thực thể texture.

Tạo chuyển động mà không cần Spritesheets

Những hình vuông tĩnh sẽ nhanh chóng trở nên nhàm chán, nhưng hệ thống animation của Phaser lại mong đợi một spritesheet—thường là một tệp PNG duy nhất với các khung hình (frames) được sắp xếp theo dạng lưới. Tôi đã mô phỏng dải hình ảnh đó bằng cách sử dụng một phần tử HTML canvas ngoại tuyến (offscreen). Với mỗi khung hình của một animation, tôi xóa canvas và vẽ một tư thế mới: một cú vung kiếm đơn giản, một chu kỳ đi bộ hai bước, hoặc một chuyển động nhấp nhô khi kẻ địch đứng yên. Sau khi dải hình ảnh hoàn tất, tôi đăng ký nó với Phaser dưới dạng một spritesheet, xác định chiều rộng và chiều cao của khung hình để engine biết nơi một tư thế kết thúc và tư thế tiếp theo bắt đầu.

Việc thực hiện thủ công này tiết lộ chính xác bản chất của một animation: một tập hợp các ranh giới khung hình trên một texture dùng chung. Thành phần animation của Phaser yêu cầu một khung hình bắt đầu, một khung hình kết thúc và tốc độ khung hình (frame rate). Sau đó, nó di chuyển một con trỏ qua các lát cắt hình chữ nhật đó trong mỗi nhịp (tick) của đồng hồ trò chơi. Bạn sẽ ngừng coi spritesheet là những tài nguyên ma thuật do các họa sĩ tạo ra và bắt đầu nhìn nhận chúng như những phép toán tọa độ. Góc nhìn đó cực kỳ giá trị sau này khi bạn cắt các tác phẩm nghệ thuật thực sự hoặc khi gỡ lỗi (debug) tại sao một animation lại chạy các khung hình không đúng thứ tự.

Âm thanh từ hư không

Các tệp âm thanh đã bị cấm, vì vậy tôi đã sử dụng trực tiếp Web Audio API. Chỉ với vài dòng JavaScript, bạn có thể tạo một oscillator node, thiết lập nó thành sóng vuông hoặc sóng sin, truyền qua một gain node và lập lịch cho một đoạn âm thanh ngắn. Tôi đã viết các hàm hỗ trợ nhỏ cho các sự kiện phổ biến: một tiếng bíp tần số thấp cho bước chân, một tiếng chíp cao dần khi nhặt vật phẩm, và một âm thanh sóng vuông chói tai khi chịu sát thương.

Những âm thanh tổng hợp này được thiết kế để làm vật thay thế (placeholder), nhưng chúng mang lại phản hồi cơ học cho trò chơi ngay lập tức. Bạn có thể cảm nhận được liệu thời điểm (timing) đã chuẩn chưa trước khi bắt tay vào thu âm hoặc tìm nguồn âm thanh thực. Thành quả thực sự nằm ở kiến trúc. Vì bạn đã bao bọc trình kích hoạt âm thanh trong một hàm đơn giản, việc thay thế tiếng bíp tổng hợp bằng một sound buffer đã tải sau này chỉ mất một dòng thay đổi. Phần còn lại của trò chơi—sự kiện va chạm, hiệu ứng nhấp nháy UI, việc tăng điểm số—vẫn được giữ nguyên. Bạn tạo mẫu (prototype) cảm giác trước, sau đó mới nâng cấp độ trung thực (fidelity) sau.

Quản lý nhiều thế giới

Một trò chơi thực thụ cần nhiều hơn một màn hình, vì vậy tôi đã chia dự án thành các scene Phaser riêng biệt: Menu, Game, UI và Pause. Scene UI chạy song song với scene Game, được khởi chạy đồng thời để thanh máu và bộ đếm điểm nằm trong vùng sandbox riêng của chúng trong khi quá trình khám phá hầm ngục diễn ra bên dưới. Chúng giao tiếp chặt chẽ thông qua các sự kiện (events). Khi người chơi chịu sát thương, scene Game sẽ phát ra một sự thay đổi. Scene UI lắng nghe và cập nhật các đối tượng văn bản của nó. Scene Game không import UI, không gọi các phương thức của nó, hay thậm chí không kiểm tra xem nó có tồn tại hay không. Nó chỉ đơn giản là gửi dữ liệu vào hư không. Sự tách biệt (decoupling) đó có nghĩa là bạn có thể rút HUD ra để kiểm thử, hoặc thay thế nó hoàn toàn mà không cần chạm vào vòng lặp trò chơi (game loop) cốt lõi.

Để tạm dừng, tôi sử dụng một scene Pause xếp chồng lên trên scene Game. Quan trọng là, việc gọi scene.pause() trên scene Game thực sự sẽ đóng băng thế giới vật lý và dừng các bộ đếm thời gian (timers). Scene Game ngừng cập nhật, nhưng scene Pause vẫn thức để hiển thị menu và chờ tín hiệu tiếp tục (unpause). Nếu bạn trước đây chỉ quản lý các trạng thái tạm dừng bằng một biến cờ (boolean flag) bên trong một vòng lặp update khổng lồ, điều này sẽ mang lại cảm giác như vừa khám phá ra một công tắc đèn vậy. Engine cung cấp cho bạn một vòng đời tạm dừng (pause lifecycle) thực thụ thay vì buộc bạn phải rải rác các câu lệnh bảo vệ if (isPaused) return khắp mã nguồn.

Những bài học xương máu từ các lỗi thực tế

Làm việc ở mức thấp (close to the metal) như thế này đã bộc lộ hai thói quen mà tôi cần phải thay đổi.

Đầu tiên, tôi đã cố gắng sử dụng một phương thức trong Phaser v4 trông có vẻ là public nhưng thực chất không thuộc API đã được tài liệu hóa. Nó đã thay đổi giữa các phiên bản và làm hỏng bản build của tôi. Tôi đã tái cấu trúc (refactor) để sử dụng getChildren(), một phương thức public ổn định và đã được tài liệu hóa, và sự mất ổn định đó đã biến mất. Bài học rất rõ ràng: nếu một phương thức không có trong tài liệu chính thức, đừng xây dựng trò chơi của bạn dựa trên nó. Các API nội bộ (Internal APIs) là nội bộ vì một lý do nhất định. Hãy bám sát các bề mặt công khai (public surface area) và dự án của bạn sẽ sống sót qua các bản cập nhật engine.

Thứ hai, tôi học được rằng đừng bao giờ tin tưởng hoàn toàn vào các sự kiện (events) cho mọi thứ. Các hàm callback của sự kiện hoạt động hoàn hảo cho các tương tác ít rủi ro như nhặt một đồng xu hoặc mở một chiếc rương. Nhưng đối với các chuyển đổi trạng thái quan trọng—đặc biệt là Game Over—tôi đã thêm một bước kiểm tra dư thừa bên trong vòng lặp update chính. Các sự kiện có thể bị lỗi nếu một listener bị xóa, một scene tạm dừng ở một microsecond không thuận lợi, hoặc một tình trạng tranh chấp (race condition) len lỏi giữa lúc phát (emission) và lúc xử lý (handling). Bằng cách kiểm tra trực tiếp máu của người chơi trong vòng lặp update và ép trạng thái game-over nếu nó chạm mức không, tôi đảm bảo trò chơi không bao giờ bị kẹt trong trạng thái lấp lửng nếu một sự kiện không được kích hoạt. Các sự kiện vẫn xử lý các hiệu ứng phụ—rung màn hình, âm thanh, gửi điểm số—nhưng logic mang tính quyết định (authoritative logic) nằm ở nơi mà đồng hồ trò chơi (game clock) vận hành.

Tại sao bạn nên thử cách này

Nếu bạn đang học phát triển trò chơi, hãy áp đặt chính xác ràng buộc này vào dự án tiếp theo của mình: không tài nguyên bên ngoài, chỉ một tệp HTML duy nhất. Nghe có vẻ hạn chế, nhưng nó loại bỏ mọi lời bào chữa. Bạn không cần cấu hình một bộ đóng gói (bundler), không phải vật lộn với lỗi CORS trên các tệp âm thanh cục bộ, hay dành cả buổi chiều để tuyển chọn các gói tài nguyên miễn phí. Bạn viết mã, bạn làm mới trình duyệt, và bạn thấy kết quả.

Quan trọng hơn, bạn sẽ hiểu tại sao engine lại hoạt động theo cách nó đang làm. Bạn sẽ biết cách một texture đi vào GPU vì bạn đã gọi generateTexture. Bạn sẽ biết cách các khung hình hoạt ảnh (animation frames) được lập chỉ mục vì bạn đã tự tay đăng ký các ranh giới. Bạn sẽ biết cách âm thanh truyền đến loa vì bạn đã kết nối bộ dao động (oscillator). Kiến thức đó sẽ được chuyển đổi trực tiếp sang các dự án lớn hơn có sử dụng tài nguyên bên ngoài, bởi vì các cơ chế nền tảng không bao giờ thay đổi—engine