บทเรียนการพัฒนาเกมทุกบทมักจะเริ่มต้นเหมือนกันหมด นั่นคือรูปสี่เหลี่ยมสีๆ ที่เลื่อนไปมาบนผืนผ้าใบสีเทา มันอาจจะดีสำหรับการเรียนรู้ไวยากรณ์ (syntax) แต่ไม่ได้สอนอะไรคุณเลยเกี่ยวกับวิธีที่เอนจินเกมทำงานจริงๆ ผมอยากหลุดพ้นจากวงจรการสร้างแค่รูปสี่เหลี่ยม ผมต้องการสร้างบางอย่างที่ให้ความรู้สึกเหมือนเกมจริงๆ—เกมแนว top-down dungeon crawler ที่มีการเคลื่อนที่ การต่อสู้ มี HUD และมีเสียง
ผมตัดสินใจใช้ Phaser v4 และตั้งกฎเหล็กให้ตัวเองหนึ่งข้อ คือต้องไม่มี assets ภายนอกเลย ไม่ว่าจะเป็นไฟล์รูปภาพ คลิปเสียง หรือเครื่องมือ build อย่าง Webpack หรือ Vite โปรเจกต์ทั้งหมดต้องอยู่ในไฟล์ HTML เพียงไฟล์เดียวที่เขียนด้วย plain JavaScript ข้อจำกัดนี้ไม่ใช่เรื่องของความมินิมอลเพียงอย่างเดียว แต่มันคือการกำจัดทุกข้ออ้างและทุก "กล่องดำ" (black box) เมื่อคุณไม่สามารถดาวน์โหลด sprite pack มาเพื่อปกปิดช่องว่างในความรู้ของคุณได้ คุณจะถูกบังคับให้ต้องเรียนรู้วิธีที่เอนจินจัดการกับ textures, animations, audio และ state ที่อยู่เบื้องหลัง
การวาดโลกขึ้นมาจากโค้ด
ในโปรเจกต์ Phaser ปกติ คุณจะเรียก this.load.image() ภายในฟังก์ชัน preload และชี้เอนจินไปยังไฟล์ PNG แต่เมื่อไม่มีตัวเลือกนั้น คุณต้องหันไปใช้ Graphics object แทน คุณทำการ instantiate มัน วาดรูปทรงพื้นฐาน (primitive shapes)—เช่น สี่เหลี่ยมสำหรับพื้น (floor tiles), เส้นที่หนาขึ้นสำหรับกำแพง หรืออาจจะเป็นวงกลมสำหรับตัวละครผู้เล่น—แล้วจึงเรียก generateTexture เมธอดนี้จะทำการจับภาพ graphics buffer และลงทะเบียนมันไว้กับ texture manager ของ Phaser ภายใต้ key ที่คุณเลือก
ตั้งแต่วินาทีนั้นเป็นต้นไป เอนจินจะปฏิบัติกับ bitmap ที่สร้างขึ้นนั้นเหมือนกับไฟล์รูปภาพที่โหลดเข้ามาทุกประการ คุณสามารถนำมันไปใช้กับ tilemaps, ตัดแบ่งเป็น sprites หรือใส่สี (tint) ก็ได้ สำหรับเกม dungeon crawler นี้ หมายความว่าผมสามารถสร้างตารางพื้นแบบ procedural, วางชิ้นส่วนกำแพง และปรับเปลี่ยนพาเลทสีได้โดยไม่ต้องออกจาก code editor เลย บทเรียนที่ได้รับจากการลงมือทำจริงคือ texture ก็เป็นเพียงข้อมูล bitmap ก้อนหนึ่งที่อยู่ในหน่วยความจำ Phaser ไม่สนใจว่ามันจะมาจากการเรียกผ่าน HTTP request หรือมาจากการเรียกใช้ Graphics ที่เขียนขึ้นด้วยมือ
แนวทางนี้ยังทำให้คุณต้องคิดอย่างรอบคอบเกี่ยวกับลำดับการวาด (draw order) และการทำ batching เมื่อกำแพงและพื้นทุกแผ่นมาจากตระกูลของ textures ที่สร้างขึ้นเหมือนกัน คุณจะเริ่มให้ความสำคัญกับวิธีที่ Phaser จัดกลุ่มการเรียก render คุณจะสังเกตเห็นความแตกต่างระหว่างเลเยอร์ tilemap แบบ static กับ spritemap ของวัตถุแต่ละชิ้น เพราะคุณเป็นคนตัดสินใจเองว่าชิ้นไหนควรจะดำรงอยู่เป็น texture instance
การทำแอนิเมชันโดยไม่ต้องใช้ Spritesheets
รูปสี่เหลี่ยมที่อยู่นิ่งๆ นั้นน่าเบื่ออย่างรวดเร็ว แต่ระบบแอนิเมชันของ Phaser นั้นต้องการ spritesheet ซึ่งมักจะเป็นไฟล์ PNG ไฟล์เดียวที่มีเฟรมวางเรียงกันเป็นตาราง ผมจำลองแถบภาพนั้นขึ้นมาโดยใช้ HTML canvas element แบบ offscreen สำหรับแต่ละเฟรมของแอนิเมชัน ผมจะล้าง canvas และวาดท่าทางใหม่: เช่น การเหวี่ยงดาบง่ายๆ, ท่าเดินสองก้าว หรือท่าขยับตัวนิ่งๆ ของศัตรู เมื่อแถบภาพเสร็จสมบูรณ์ ผมก็นำมันไปลงทะเบียนกับ Phaser ในฐานะ spritesheet โดยกำหนดความกว้างและความสูงของเฟรม เพื่อให้เอนจินรู้ว่าท่าทางหนึ่งสิ้นสุดตรงไหนและท่าทางถัดไปเริ่มตรงไหน
การทำสิ่งนี้ด้วยตัวเองช่วยเผยให้เห็นว่าแท้จริงแล้วแอนิเมชันคืออะไรภายใต้พื้นผิว: มันคือชุดของขอบเขตเฟรมบน texture ที่ใช้ร่วมกัน คอมโพเนนต์แอนิเมชันของ Phaser จะถามหาเฟรมเริ่มต้น, เฟรมสิ้นสุด และ frame rate จากนั้นมันจะเลื่อน pointer ผ่านชิ้นส่วนสี่เหลี่ยมเหล่านั้นในทุกๆ tick ของนาฬิกาเกม คุณจะเลิกมองว่า spritesheet เป็น asset วิเศษที่สร้างโดยศิลปิน และเริ่มมองเห็นมันเป็นเพียงคณิตศาสตร์ของพิกัด (coordinate math) มุมมองนี้มีค่ามหาศาลในภายหลังเมื่อคุณต้องตัดแบ่งงานอาร์ตจริงๆ หรือต้องดีบั๊ก (debug) ว่าทำไมแอนิเมชันถึงเล่นเฟรมผิดลำดับ
เสียงที่สร้างจากความว่างเปล่า
ไฟล์เสียงถูกสั่งห้ามใช้ ผมจึงใช้ Web Audio API โดยตรง โค้ด JavaScript เพียงไม่กี่บรรทัดสามารถสร้าง oscillator node, ตั้งค่าให้เป็นคลื่นแบบ square หรือ sine wave, ส่งผ่าน gain node และกำหนดเวลาการปล่อยเสียงสั้นๆ ผมเขียนฟังก์ชันช่วย (helpers) เล็กๆ สำหรับเหตุการณ์ทั่วไป: เสียงบี๊บความถี่ต่ำสำหรับเสียงฝีเท้า, เสียงจิ๊บที่สูงขึ้นสำหรับการเก็บไอเทม และเสียงคลื่นแบบ square ที่รุนแรงสำหรับการได้รับความเสียหาย
เสียงสังเคราะห์เหล่านี้ถูกออกแบบมาให้เป็นเพียงตัวสำรอง (placeholders) แต่พวกมันให้การตอบสนองเชิงกลไก (mechanical feedback) แก่เกมได้ทันที คุณสามารถรู้สึกได้ว่าจังหวะมันถูกต้องหรือไม่ ก่อนที่คุณจะตัดสินใจบันทึกเสียงหรือหาไฟล์เสียงจริง ผลตอบแทนที่แท้จริงอยู่ที่โครงสร้าง (architecture) เพราะคุณห่อหุ้มการเรียกใช้เสียงไว้ในฟังก์ชันง่ายๆ การเปลี่ยนเสียงบี๊บสังเคราะห์เป็น sound buffer ที่โหลดมาในภายหลังจึงใช้การแก้ไขโค้ดเพียงบรรทัดเดียว ส่วนที่เหลือของเกม—ไม่ว่าจะเป็นเหตุการณ์การชน (collision event), การกะพริบของ UI หรือการเพิ่มคะแนน—ก็ยังคงเหมือนเดิม คุณสร้างต้นแบบของความรู้สึก (feel) ก่อน แล้วค่อยอัปเกรดความสมจริง (fidelity) ในภายหลัง
การจัดการโลกหลายใบ
A real game needs more than one screen, so I split the project into separate Phaser scenes: Menu, Game, UI, and Pause. The UI scene runs in parallel with the Game scene, launched simultaneously so the health bar and score counter live in their own sandbox while the dungeon crawls below. They communicate strictly through events. When the player takes damage, the Game scene emits a change. The UI scene listens and updates its text objects. The Game scene does not import the UI, call its methods, or even check if it exists. It simply sends data into the void. That decoupling means you can yank the HUD out for testing, or replace it entirely, without touching the core game loop.
For pausing, I used a stacked Pause scene that sits on top of the Game scene. Crucially, calling scene.pause() on the Game scene actually freezes the physics world and halts timers. The Game scene stops updating, but the Pause scene remains awake to render a menu and wait for an unpause signal. If you have only ever managed pause states with a boolean flag inside one giant update loop, this feels like discovering a light switch. The engine gives you a real pause lifecycle rather than forcing you to pepper your code with if (isPaused) return guards.
Hard Lessons from Real Bugs
Working this close to the metal exposed two habits I needed to change.
First, I tried to use a method in Phaser v4 that looked public but was not part of the documented API. It changed between versions and broke my build. I refactored to use getChildren(), which is a stable, documented public method, and the instability vanished. The lesson is blunt: if a method is not in the official documentation, do not build your game on it. Internal APIs are internal for a reason. Stick to the public surface area and your project will survive engine updates.
Second, I learned never to trust events for everything. Event callbacks work perfectly well for low-stakes interactions like picking up a coin or opening a chest. But for critical state transitions—especially Game Over—I added a redundant check inside the main update loop. Events can misfire if a listener is removed, a scene pauses at an awkward microsecond, or a race condition sneaks in between emission and handling. By checking the player’s health directly in the update loop and forcing the game-over state if it hits zero, I ensured the game could never get stuck in a limbo state if an event failed to fire. The events still handle the secondary effects—screen shake, sound cues, score submission—but the authoritative logic lives where the game clock lives.
Why You Should Try This
If you are learning game development, impose this exact constraint on your next project: no external assets, one HTML file. It sounds restrictive, but it removes every excuse. You do not need to configure a bundler, wrestle with CORS errors on local audio files, or spend an afternoon curating free asset packs. You write code, you refresh the browser, and you see results.
More importantly, you will understand why the engine behaves the way it does. You will know how a texture enters the GPU because you called generateTexture. You will know how animation frames are indexed because you registered the boundaries by hand. You will know how audio reaches the speakers because you wired the oscillator. That knowledge transfers directly to larger projects that do use external assets, because the underlying mechanics never change—the engine
