2 คะแนน โดย GN⁺ 2024-10-19 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • เป็นบทช่วยสอนที่ขยาย สถาปัตยกรรม ECS และการเขียนโปรแกรมเชิงเมตาภาษาบนสภาพแวดล้อมพัฒนาเกมด้วย Common Lisp ไปสู่ตัวอย่างดันเจียนครอว์เลอร์จริง
  • อ่านแผนที่ Tiled XML ด้วย cl-tiled แล้วไม่ใช้วัตถุ CLOS โดยตรง แต่ย้ายไปเป็นคอมโพเนนต์ ECS เพื่อแยกการเรนเดอร์ การชน และการจัดการหน่วยความจำออกจากกัน
  • ผสาน tile prefab, พอยน์เตอร์รูปภาพ, ดัชนี parent-child และ finalizer เพื่อหลีกเลี่ยง การโหลดซ้ำและ double free พร้อมใช้ custom properties ของ Tiled เหมือนเป็นข้อมูล
  • ผู้เล่นและศัตรูทำการเคลื่อนที่ สลับแอนิเมชัน และจัดการการชนผ่านระบบ ECS ส่วนศัตรูไล่ตามโดยหลบกำแพงด้วยการค้นหาเส้นทาง A* บนพื้นฐาน cl-astar
  • ทำตัวอย่างดันเจียนครอว์เลอร์ขนาดเล็กประมาณ 500 บรรทัดให้เสร็จ โดยเพิ่ม UI บนพื้นฐาน Nuklear, วัตถุเชิงเรื่องเล่า, การหยุดชั่วคราว และเงื่อนไขชัยชนะ

การเริ่มโปรเจกต์และการรันพื้นฐาน

  • สร้างดันเจียนครอว์เลอร์ขนาดเล็กที่มี UI โดยใช้สถาปัตยกรรม Entity-Component-System และเทคนิคการเขียนโปรแกรมเชิงเมตาภาษาที่กล่าวถึงใน Part 1
  • ไบนารีเดโมที่รันได้และซอร์สโค้ดอยู่ใน ที่เก็บ GitHub ecs-tutorial-2
  • สภาพแวดล้อมการพัฒนาตั้งอยู่บนสภาพแวดล้อมพัฒนาเกม Common Lisp จาก Part 1 และอัปเดตดิสทริบิวชัน Quicklisp ใน SBCL REPL
    • (ql-util:without-prompting (ql:update-all-dists))
  • สร้างโปรเจกต์ใหม่ ecs-tutorial-2 ด้วยเทมเพลต cookiecutter-lisp-game และในตัวอย่างเลือก liballegro เป็นแบ็กเอนด์
  • เชื่อมไดเรกทอรีโปรเจกต์เข้ากับ local-projects ของ Quicklisp แล้วเปลี่ยนขนาดหน้าต่างใน src/main.lisp เป็น 1280×800
  • เมื่อรัน (ql:quickload :ecs-tutorial-2) และ (ecs-tutorial-2:main) จะแสดงหน้าต่างสีดำตามความละเอียดที่กำหนดพร้อมตัวนับ FPS

แผนที่ Tiled และที่เก็บ ECS

  • การสร้างแผนที่ดันเจียนใช้ตัวแก้ไขแผนที่โอเพนซอร์ส Tiled
    • Tiled เป็นเครื่องมือข้ามแพลตฟอร์มและข้ามเอนจิน และบันทึกข้อมูลแผนที่เป็น XML
    • ใน Common Lisp cl-tiled จะโหลดไฟล์ Tiled เป็นวัตถุ Lisp
  • tileset ตัวอย่างใช้ Dungeon Tileset II - Extended
    • เนื่องจากไทล์ต้นฉบับขนาด 16×16 เล็ก จึงใช้ ImageMagick ขยาย 200% ให้เป็นไทล์ 32×32
    • สามารถรับ level1.tmx และไฟล์ tileset ได้จาก Resources.zip ที่บทช่วยสอนจัดเตรียมไว้
  • เพิ่ม dependency cl-tiled ใน ecs-tutorial-2.asd และสร้าง src/map.lisp ใหม่เพื่อแยกโค้ดโหลดและแสดงแผนที่
  • ใน src/package.lisp ลงทะเบียน cl-tiled เป็น local nickname ชื่อ tiled

เหตุผลที่ย้ายวัตถุ CLOS ไปเป็นคอมโพเนนต์ ECS

  • cl-tiled ส่งคืนข้อมูลแผนที่เป็น วัตถุ CLOS จึงสำรวจใน REPL ได้สะดวก
  • หากใช้วัตถุนี้โดยตรงใน game loop อาจมีต้นทุน runtime dispatch สูง
    • หากเติมหน้าต่าง 1280×800 ด้วยไทล์ 32×32 ต้องใช้ไทล์อย่างน้อย 40×25 = 1000 ชิ้น
    • ในเดโมแยกต่างหาก เมื่อเปิดการเรนเดอร์แผนที่บน Ryzen 5 3600 แบบ 12 คอร์ FPS ลดจาก 20,000 เหลือ 600
    • เพิ่มขึ้นประมาณ 1/600 - 1/20000 = 0.0016 วินาทีต่อเฟรม หรือมากกว่า 1.5ms
  • เมื่อนำข้อมูลที่ cl-tiled อ่านได้ไปไว้ในที่เก็บ cl-fast-ecs จะลด dispatch และปรับปรุงการใช้ CPU cache ได้
  • เพิ่ม dependency cl-fast-ecs และเรียก ecs:make-storage ใน init, เรียก ecs:run-systems ใน update

คอมโพเนนต์แผนที่ ไทล์ และ prefab

  • map เป็น tag component ที่แสดงเอนทิตีแผนที่ที่โหลดแล้ว
  • map-tile แทนไทล์แต่ละชิ้น และมีสล็อต Boolean ชื่อ obstacle เพื่อระบุว่าเป็นสิ่งกีดขวาง เช่น กำแพงหรือประตูที่ปิดอยู่หรือไม่
  • คอมโพเนนต์ parent ระบุว่าไทล์และวัตถุที่เกี่ยวข้องกับแผนที่เป็นลูกของเอนทิตีแผนที่ใด
    • ระบุ :index children ในสล็อต entity เพื่อค้นหาเอนทิตีลูกของ parent เฉพาะได้อย่างรวดเร็ว
    • ดัชนีอิง open-address hash table จึงให้การค้นหาเฉลี่ย O(1) แต่มีต้นทุนการอัปเดตเมื่อสร้างหรือลบ
  • เพิ่ม hook ใน ecs:*entity-deleting-hook* เพื่อให้เมื่อเอนทิตี parent ถูกลบ เอนทิตีลูกที่พบผ่านดัชนี children ถูกลบไปด้วย
  • คอมโพเนนต์ image เก็บเฉพาะ C pointer ไปยัง ALLEGRO_BITMAP
    • รูปภาพ tileset ถูกตัดเป็นชิ้น 32×32 ด้วย al_create_sub_bitmap แล้วเก็บพอยน์เตอร์ไว้
  • map-tile-prefab เป็น tile prefab ที่มี global ID gid ของไทล์ Tiled
    • กำหนด :index map-tile-prefab :unique t ให้ gid เพื่อค้นหาเอนทิตี prefab เดี่ยวตาม ID
    • ไทล์จริงบนแผนที่จะคัดลอก image ฯลฯ จาก prefab แต่มีตำแหน่งเป็นคอมโพเนนต์ position แยกต่างหาก
  • finalizer ของ image เรียก al_destroy_bitmap เฉพาะเมื่อเอนทิตีเป็น map-tile-prefab
    • เพื่อหลีกเลี่ยง double free เพราะไทล์บนแผนที่หลายชิ้นแชร์พอยน์เตอร์ ALLEGRO_BITMAP เดียวกัน
  • position และ size เก็บพิกัดหน้าจอและขนาดเป็น single-float
    • เนื่องจาก liballegro จัดการพิกัดหน้าจอเป็นเลขทศนิยมความแม่นยำเดี่ยวเพื่อความเข้ากันได้กับ OpenGL จึงใช้แนวทางเดียวกัน

การเรนเดอร์รูปภาพและการโหลดแผนที่

  • ระบบ render-images เรนเดอร์เอนทิตีที่มี position และ image
    • เปิดและปิด sprite batching ด้วย al_hold_bitmap_drawing
    • วาดรูปภาพที่พิกัดที่กำหนดด้วย al_draw_bitmap
    • prefab ไม่มี position จึงไม่ถูกระบบนี้ประมวลผล
  • load-bitmap เป็นฟังก์ชันโหลดรูปภาพที่ห่อ al_load_bitmap ด้วย al:ensure-loaded
  • tile->spec สร้างสเปกวัตถุ ECS สำหรับสร้าง tile prefab
    • เอนทิตีแผนที่ parent
    • ชิ้นส่วนรูปภาพของไทล์
    • global tile ID ของ Tiled
    • ขนาดไทล์
  • load-tile-prefab ตรวจสอบผ่านดัชนี map-tile-prefab ว่าเป็น prefab ที่โหลดไว้แล้วหรือไม่ และถ้าไม่มีก็สร้างด้วย make-object
  • load-tile เมื่อสร้างเอนทิตีไทล์จริงบนแผนที่จะคัดลอกคอมโพเนนต์จาก prefab แล้วเพิ่ม position
  • load-map วนผ่าน tileset และเลเยอร์จากวัตถุ CLOS ที่อ่านด้วย tiled:load-map
    • โหลดรูปภาพ tileset และสร้างแต่ละไทล์เป็น prefab
    • สร้างแต่ละเซลล์ของเลเยอร์ไทล์เป็นเอนทิตี แล้วคัดลอกข้อมูล prefab
  • ลำดับเลเยอร์ของ Tiled ถูกรักษาตามลำดับใน editor และ make-entity รับประกันหมายเลขเอนทิตีที่เพิ่มขึ้น
    • เนื่องจากระบบประมวลผลเอนทิตีเก่าก่อน ไทล์ของเลเยอร์บนจะถูกวาดทีหลังและทับเลเยอร์ล่าง
  • วิธีเก็บไทล์ทั้งหมดเป็นเอนทิตีแยกกันไม่ใช่คำตอบเดียวเท่านั้น และสามารถเรนเดอร์แผนที่แบบ static ลงบัฟเฟอร์ไว้ล่วงหน้าได้เช่นกัน

แอนิเมชันไทล์

  • Tiled รองรับไทล์แอนิเมชัน จึงสามารถแสดงองค์ประกอบอย่างคบเพลิงหรือน้ำพุเวทมนตร์ได้
  • เพิ่ม common.lisp และ animation.lisp เพื่อแยกคอมโพเนนต์ทั่วไปออกจากคอมโพเนนต์และระบบที่เกี่ยวกับแอนิเมชัน
  • คอมโพเนนต์ animation-frame แทนหนึ่งเฟรมของแอนิเมชัน
    • sequence คือชื่อแอนิเมชัน และจัดเก็บเป็นชนิด keyword
    • ใช้ดัชนี sequence-frames เพื่อค้นหาเฟรมต่าง ๆ ของแอนิเมชันที่ระบุ
    • duration คือระยะเวลาของเฟรมในหน่วยวินาที
  • animation-state เก็บสถานะปัจจุบันของไทล์แอนิเมชันบนแผนที่จริง
    • sequence ปัจจุบัน
    • frame ปัจจุบัน
    • duration ของเฟรมปัจจุบัน
    • เวลา elapsed ที่เฟรมปัจจุบันถูกแสดงไปแล้ว
  • เพิ่ม dependency let-plus เพื่อเขียนโค้ดสลับเฟรมให้กระชับ
  • ระบบ update-animations เพิ่มค่า elapsed ตาม dt และเมื่อเกินระยะเวลา ก็จะสลับไปยังเฟรมถัดไป
    • เนื่องจากเวลาเฟรมอาจน้อยกว่า dt ที่มีค่ามาก จึงใช้ floor คำนวณว่าต้องข้ามไปกี่เฟรม
    • ใช้ truncate เพื่อให้เมื่อหมายเลขเฟรมเกินความยาวลิสต์แล้ววนกลับไปเริ่มต้น
    • เปลี่ยนพอยน์เตอร์ bitmap ของ image ให้เป็น bitmap ของ prefab เฟรมถัดไป
  • ระยะเวลาแอนิเมชันถูกเก็บใน Tiled เป็นมิลลิวินาที จึงแปลงเป็นหน่วยวินาทีใน animation->spec
  • instantiate-animation สร้าง animation-state ให้กับเอนทิตีไทล์จริง และกำหนดค่าเริ่มต้นของ elapsed เป็นค่าสุ่มระหว่าง 0 ถึง duration เพื่อไม่ให้แอนิเมชันเดียวกันซิงก์กันสมบูรณ์
  • ไทล์แอนิเมชันต้องมีพร็อพเพอร์ตี "sequence" ใน Tiled
    • หากไม่มีพร็อพเพอร์ตีนี้ จะถูกโหลดด้วยชื่อ NIL ทำให้ค้นหาด้วยชื่อแอนิเมชันที่คาดไว้ไม่พบ และอาจเกิด type error ได้

ตัวละครผู้เล่นและการควบคุม

  • เพิ่ม character.lisp และนิยามคอมโพเนนต์ character สำหรับตัวละครที่เคลื่อนที่ได้
    • speed คือความเร็วเป็นพิกเซลต่อวินาที
    • target-x, target-y คือพิกัดเป้าหมายการเคลื่อนที่
    • ค่าเป้าหมายเริ่มต้นตั้งเป็น single-float-nan เพื่อไม่ให้ตัวละครใหม่เคลื่อนที่ไปมุมซ้ายบนโดยไม่มีเหตุผล
  • คอมโพเนนต์แท็ก player ใช้สล็อต bit และ :index player-entity :unique t
    • เป็นโครงสร้างสำหรับหาเอนทิตีผู้เล่นด้วย (player-entity 1) ในเวลา O(1)
    • ไม่เก็บเอนทิตีผู้เล่นไว้ในตัวแปร global
  • ในการใช้งานครั้งแรก ตัดภาพออร์กจาก tileset มาทำเป็น player.png และสร้างผู้เล่นแบบ hardcode ด้วย load-player
    • ตำแหน่งคือ (64.0, 64.0)
    • ขนาดคือ 32×32
    • ความเร็วคือ 100.0
  • ระบบ move-characters เคลื่อนตัวละครไปยังจุดเป้าหมาย
    • หากมีพิกัดเป้าหมายเป็น NaN จะกำหนดค่าเริ่มต้นเป็นตำแหน่งปัจจุบัน
    • ใช้ approx-equal แทนการเปรียบเทียบ floating point โดยตรง
    • คำนวณพิกัดใหม่ด้วย atan, cos, sin, ความเร็ว และ dt
  • ระบบ control-player อ่านอินพุตปุ่ม W, A, S, D แล้วอัปเดตพิกัดเป้าหมาย
    • ใช้ al:with-current-keyboard-state และ al:key-down
    • ใช้ clamp เพื่อไม่ให้ออกนอกขอบหน้าจอ
    • รันตามหลังระบบเคลื่อนที่ด้วย :after (move-characters) เพื่อหลีกเลี่ยงปัญหาการกำหนดค่าเริ่มต้น NaN

การโหลดการชนและออบเจ็กต์ด้วยพร็อพเพอร์ตีของ Tiled

  • ตอนแรกกำแพงเป็นภาพทั่วไปเหมือนไทล์พื้น ทำให้ผู้เล่นเดินทะลุกำแพงได้
  • สร้างคลาส map-tile เป็น custom type ของ Tiled และเพิ่มสมาชิก Boolean ชื่อ obstacle
    • เพิ่ม map-tile เป็นพร็อพเพอร์ตีของไทล์กำแพง และติ๊ก obstacle
  • ฟังก์ชัน properties->spec แปลง hash table ของพร็อพเพอร์ตี Tiled เป็นสเปกออบเจ็กต์ ECS
    • custom class ของ Tiled ถูกจัดการเป็นคอมโพเนนต์
    • สมาชิกของคลาสถูกจัดการเป็นสล็อตของคอมโพเนนต์
    • ตัวอย่างอยู่ในรูป ((:map-tile :obstacle t))
  • load-tile-prefab รวมผลลัพธ์จาก properties->spec เข้าไปในสเปก prefab
    • หากไม่มีพร็อพเพอร์ตี จะเพิ่มคอมโพเนนต์ map-tile พื้นฐานด้วย spec-adjoin และ obstacle มีค่าเริ่มต้นเป็น nil
  • เพิ่มสล็อต tile-hash และดัชนี tiles ให้คอมโพเนนต์ position
    • tile-hash ทำให้ x, y เป็นจำนวนเต็ม แล้วแพ็กเป็นจำนวนเต็ม 64 บิตค่าเดียว
    • ค้นหาเอนทิตีทั้งหมดที่อยู่บนพิกัดมุมซ้ายบนของไทล์ที่ระบุด้วยดัชนี tiles
  • tile-start คืนค่าพิกัดมุมซ้ายบนของไทล์ในกริดที่พิกัดใด ๆ สังกัดอยู่
  • tile-obstacle-p ตรวจว่ามีไทล์ที่เป็น map-tile และมี obstacle เป็นจริงในหมู่เอนทิตีที่พิกัดเดียวกันหรือไม่
  • obstaclep ตรวจว่าไทล์นั้นเป็นสิ่งกีดขวางหรือไม่สำหรับพิกัดใด ๆ
  • control-player ตรวจไทล์ที่มุมที่เกี่ยวข้องของสี่เหลี่ยมตัวละครตามทิศทางการเคลื่อนที่ และถ้ามีสิ่งกีดขวางก็ย้อนพิกัดเป้าหมายกลับเป็นตำแหน่งปัจจุบัน
  • วิธีการชนนี้ยังไม่สมบูรณ์
    • หากออกแบบโดยอิงพิกัดศูนย์กลางของตัวละคร คณิตศาสตร์และโค้ดอาจเรียบง่ายขึ้นได้ แต่ตัวอย่างนี้คงวิธีปัจจุบันไว้เพื่อหลีกเลี่ยงความซับซ้อน

การโหลดผู้เล่นและตัวละครแอนิเมชันจากแผนที่

  • เพิ่ม custom class character และ player ใน Tiled
    • character มีเฉพาะสมาชิก float ชื่อ speed
    • ละ target-x, target-y ไว้เพื่อให้ใช้ค่าเริ่มต้น
    • player มีสมาชิก int ชื่อ player ที่ค่าเริ่มต้นเป็น 1
  • วางตัวละครผู้เล่นเป็น tile object ใน object layer ของ Tiled และกำหนดพร็อพเพอร์ตี character กับ player
  • ขยาย load-map ให้รองรับ tiled:object-layer ด้วย
    • แปลงพร็อพเพอร์ตีของออบเจ็กต์เป็นคอมโพเนนต์ ECS ด้วย properties->spec
    • tiled:tile-object คัดลอกข้อมูลไทล์และแอนิเมชันด้วย load-tile แล้วตั้งตำแหน่ง
    • พิกัดออบเจ็กต์ของ Tiled อ้างอิงจากมุมซ้ายล่าง จึงลบความสูงของออบเจ็กต์ออกจาก y เพื่อปรับให้เป็นการอ้างอิงจากมุมซ้ายบน
  • ลบการเรียกและฟังก์ชัน load-player ที่ hardcode ไว้
  • โครงสร้างนี้เป็นวิธีอ่านข้อมูลแผนที่ Tiled เข้ามาเป็นออบเจ็กต์ ECS โดยตรง และเข้าใกล้การเขียนโปรแกรมแบบ data-driven มากขึ้น
  • แอนิเมชันตัวละครใช้ sequence orc-idle และ orc-run ของออร์กที่นิยามไว้ใน tileset
  • change-animation-sequence เปลี่ยนแอนิเมชันปัจจุบันของเอนทิตี
    • หากเป็น sequence เดิมอยู่แล้วจะไม่ทำอะไร
    • ค้นหาเฟรมแรกของ sequence ใหม่ด้วยดัชนี sequence-frames แล้วอัปเดต animation-state และ image-bitmap
  • move-characters สลับเป็น :orc-idle เมื่อตัวละครหยุดอยู่ และเป็น :orc-run เมื่อเคลื่อนที่

ศัตรู, เกมโอเวอร์, การค้นหาเส้นทาง A*

  • คอมโพเนนต์ enemy มีสล็อตสองตัวที่จำเป็นต่อพฤติกรรมของศัตรู
    • vision-range: ระยะที่ศัตรูมองเห็นผู้เล่นและเริ่มตอบสนอง
    • attack-range: ระยะโจมตี
  • เพิ่มตัวแปรโกลบอล *should-quit* สำหรับจบเกม และลูปหลักจะสิ้นสุดเมื่อค่านี้เป็นจริง
  • ระบบ handle-enemies จะดึงพิกัดผู้เล่นมาเปรียบเทียบกับศัตรู
    • หากผู้เล่นอยู่ในระยะมองเห็น จะตั้งพิกัดเป้าหมายของศัตรูเป็นตำแหน่งของผู้เล่น
    • หากผู้เล่นอยู่ในระยะโจมตี จะตั้ง *should-quit* เป็นจริง และแสดงกล่องข้อความเนทีฟ You died
  • แอนิเมชันของศัตรูใช้ซีเควนซ์ demon-idle, demon-run
    • move-characters จะเลือกแอนิเมชันออร์กสำหรับผู้เล่น และแอนิเมชันเดมอนสำหรับศัตรู ตามผลลัพธ์ของ has-player-p
  • ในวิธีไล่ตามโดยตรง ศัตรูก็จะเดินทะลุกำแพงด้วย จึงเพิ่มการค้นหาเส้นทาง A*
  • เพิ่ม cl-astar เป็น dependency
    • ไลบรารีนี้สร้างฟังก์ชันค้นหาเส้นทางที่ปรับแต่งให้เหมาะกับปัญหาด้วยแมโคร
  • เส้นทางไม่ได้เก็บเป็นอาร์เรย์ในสล็อตของคอมโพเนนต์ แต่แสดงจุดแต่ละจุดของเส้นทางเป็นเอนทิตีแยกต่างหาก
    • path-point มี x, y, traveller และใน traveller มีอินเด็กซ์ path-points
    • path เก็บปลายทางสุดท้าย destination-x, destination-y
    • พิกัดเป้าหมายของ character คือจุดเส้นทางถัดไป ส่วน path แทนปลายทางสุดท้าย
  • ระบบ follow-path จะดึงจุดเส้นทางแรกมาและย้ายตัวละครไปยังจุดนั้น
    • เมื่อไปถึงจุดแล้ว จะลบเอนทิตี path-point นั้น
    • หากไม่มีจุดเหลือแล้ว จะลบคอมโพเนนต์ path
  • find-path ถูกนิยามด้วย a*:define-path-finder
    • คำนวณขนาดโลกโดยหารขนาดหน้าต่างด้วยขนาดไทล์
    • ใช้ตัวทำดัชนีแบบ row-major
    • ตัดสินว่าถึงเป้าหมายหรือไม่จากการที่พิกัดไทล์เหมือนกัน
    • ไล่แจกแจงเพื่อนบ้านใน 8 ทิศทาง
    • ทำให้สิ่งกีดขวางหรือการเคลื่อนที่แนวทแยงที่ตัดผ่านสิ่งกีดขวางแทบเป็นไปไม่ได้ โดยกำหนดต้นทุนเป็น most-positive-single-float
    • heuristic ใช้ octile distance
    • หากมีเส้นทางเดิมอยู่ จะลบจุดเส้นทางเหล่านั้นและจัดสรร path ใหม่
    • แต่ละจุดของเส้นทางผลลัพธ์จะถูกสร้างเป็นเอนทิตีที่มี path-point และ parent
  • เมื่อศัตรูมองเห็นผู้เล่น handle-enemies จะเรียก find-path หากปลายทางของเส้นทางเดิมต่างจากตำแหน่งผู้เล่น
  • หลังการเปลี่ยนแปลง ศัตรูจะไล่ตามผู้เล่นแต่เคลื่อนที่หลบสิ่งกีดขวาง

UI เกมที่ใช้ Nuklear

  • ต้องมี GUI สำหรับองค์ประกอบเชิงเนื้อเรื่อง แต่ไลบรารี GUI แบบดั้งเดิมอย่าง Qt หรือ GTK ไม่เหมาะกับ UI เกมที่วาดลงในกราฟิกคอนเท็กซ์ของ liballegro
  • ใช้ Nuklear เป็นไลบรารี UI
    • มี Common Lisp binding cl-liballegro-nuklear สำหรับใช้ร่วมกับ liballegro
    • binding นี้ยังมี DSL สำหรับอินเทอร์เฟซแบบ declarative ด้วย
  • เพิ่ม dependency cl-liballegro-nuklear/declarative และเพิ่ม src/narrative.lisp เป็นไฟล์ใหม่
  • ลงทะเบียน local nickname ui ในแพ็กเกจ เพื่ออ้างถึง cl-liballegro-nuklear/declarative แบบสั้น ๆ
  • ใช้ Alegreya จาก Google Fonts เป็นฟอนต์ UI และเปลี่ยนชื่อไฟล์เป็น alegreya-sc.ttf
  • ui:defwindow narrative นิยามฟังก์ชันหน้าต่างเนื้อเรื่อง
    • คำนวณตำแหน่งหน้าต่างให้อยู่บริเวณกึ่งกลางหน้าจอ
    • แสดงข้อความที่ตัดบรรทัดอัตโนมัติด้วย ui:label-wrap
    • ui:button-label "Ok" จะคืนค่าเป็นจริงเมื่อถูกคลิก
  • Nuklear เป็นไลบรารี UI แบบ immediate mode
    • ไม่ใช่ retained mode ที่คงอ็อบเจ็กต์วิดเจ็ตไว้ในหน่วยความจำ แต่ทำการเรนเดอร์และประมวลผลทุกเฟรม
    • การคลิกปุ่มไม่ได้จัดการด้วย callback แต่จัดการด้วยค่าที่คืนมาและเงื่อนไขในแต่ละเฟรม
  • main.lisp โหลดฟอนต์ UI และเริ่มต้น UI context ด้วย nk:allegro-init
    • ใน event loop จะเรียก nk:input-begin, nk:allegro-handle-event, nk:input-end
    • ตอนเรนเดอร์จะเรียก nk:allegro-render
    • ตอนปิดจะเรียก nk:allegro-shutdown, nk:allegro-font-del

สกิน UI และอ็อบเจ็กต์เนื้อเรื่อง

  • เนื่องจาก UI พื้นฐานดูเรียบเกินไป จึงใช้แอสเซ็ตรูปภาพ fantasy-ui-borders ของ Kenney มาใส่สไตล์
  • เก็บรูปภาพ UI ไว้ในตัวแปรโกลบอล *window-background*, *button-normal-background*, *button-hover-background*, *button-active-background*
  • load-ui โหลดรูปภาพด้วย nk:allegro-create-image และ unload-ui ปล่อยทรัพยากรรูปภาพฝั่ง C ด้วย nk:allegro-del-image
  • เรียก load-ui ใน init และเรียก unload-ui เมื่อสิ้นสุดลูปหลัก
  • ระบุพื้นหลัง รูปภาพตามสถานะของปุ่ม และสีข้อความผ่านอาร์กิวเมนต์ :styles ของ ui:defwindow
  • คอมโพเนนต์ narrative แทนอ็อบเจ็กต์สำหรับการเล่าเรื่องผ่านสภาพแวดล้อม
    • text: ข้อความที่จะแสดง
    • shown: ระบุว่าเคยแสดงไปแล้วหนึ่งครั้งหรือไม่
    • active: ระบุว่าหน้าต่างกำลังแอ็กทีฟอยู่หรือไม่
    • active มีอินเด็กซ์ active-narratives
  • ระบบ show-narrative จะแสดงหน้าต่างเมื่อผู้เล่นอยู่ใกล้อ็อบเจ็กต์เนื้อเรื่อง
    • ระยะโต้ตอบคำนวณจาก +interact-distance-factor+ และขนาดไทล์ของผู้เล่น
    • แสดงหน้าต่างหากหน้าต่างแอ็กทีฟอยู่แล้ว, ยังไม่เคยแสดงมาก่อน, หรือกดปุ่ม E
    • ปิดหน้าต่างด้วยปุ่ม Ok, Esc, Space หรือ Enter อย่างใดอย่างหนึ่ง
  • ใน Tiled สร้างชนิดกำหนดเอง narrative และเพิ่มสมาชิก string ชื่อ text
  • เพื่อให้ตรงกับการตรวจจับการชน อ็อบเจ็กต์ที่เดินผ่านไม่ได้ต้องจัดพิกัดให้ตรงกับกริดไทล์
  • ปัญหาที่ดันเจียนยังคงเคลื่อนไหวขณะที่หน้าต่างเนื้อเรื่องเปิดอยู่ ถูกป้องกันด้วยเงื่อนไขการรันของระบบ
    • เพิ่ม :when (null (active-narratives t)) ให้กับ move-characters และ control-player
    • หากมีเนื้อเรื่องที่แอ็กทีฟอยู่ ระบบเคลื่อนที่และควบคุมจะไม่ถูกเรียกใช้
  • เพิ่มเงื่อนไขชนะด้วยคอมโพเนนต์แท็ก win
    • เมื่อปิดหน้าต่างจากอ็อบเจ็กต์ที่ติด narrative ร่วมอยู่ด้วย จะตั้ง *should-quit* เป็นจริงและจบเกม

สรุปและขอบเขต

  • ตัวอย่างสุดท้ายใช้ cl-fast-ecs, cl-tiled, cl-astar, cl-liballegro-nuklear เพื่อสร้างดันเจียนครอว์เลอร์สไตล์ Souls-like ที่มีการเล่าเรื่องผ่านสภาพแวดล้อม, AI ศัตรู และ GUI
  • ขนาดการใช้งานอยู่ที่ประมาณ 500 บรรทัดโค้ด
  • โค้ดทั้งหมดอยู่ใน GitHub repository และนอกจากโค้ดบทเรียนแล้วยังมีการประกาศชนิดแบบเลือกใช้ด้วย declaim
  • ไม่ครอบคลุม sound design, cutscene, main menu, level transition, “door problem” และอื่น ๆ
  • Autumn Lisp Game Jam 2024 จะจัดขึ้นที่ itch.io วันที่ 25 ตุลาคม 2024 เป็นงานที่ให้สร้างเกมด้วยภาษาในตระกูล Lisp เป็นเวลา 10 วัน แล้วประเมินและให้ฟีดแบ็กกันและกัน
  • ส่วนนี้อิงจากผลงาน Thoughtbound ที่ส่งเข้าประกวด Spring Lisp Game Jam 2023
  • ตอนถัดไปเกริ่นว่าจะท้าทายด้วยการขยายสเกลและเพิ่ม AI ขั้นสูงขึ้นเพื่อสร้างเกมกลยุทธ์แบบเรียลไทม์

1 ความคิดเห็น

 
GN⁺ 2024-10-19
ความเห็นจาก Hacker News
  • อยากให้บทสอนเทคนิคทุกชิ้นเป็นแบบนี้ โครงสร้างบทความดีมาก แทบไม่มีข้อผิดพลาดทางไวยากรณ์เลย และทุกครั้งที่แนะนำหัวข้อใหม่ก็อธิบายในปริมาณที่พอดี พร้อมทั้งมี ตัวอย่างโค้ดฉบับเต็ม และ สื่อภาพ ที่แสดงให้เห็นว่าโค้ดทำอะไรจริง ๆ
    มันยาวพอที่จะลงลึกกับเนื้อหา แต่ก็เป็นงานที่อ่านแยกเดี่ยวได้ดีพอที่แม้จะไม่ได้อ่านภาคแรก และเคยลองจับ Common Lisp แค่ไม่กี่เดือนเมื่อหลายปีก่อนก็ยังตามได้ ผมเองเคยใช้ Clojure และ Emacs Lisp ค่อนข้างมาก
    Bravo, awkravchuk/Andrew :^)
    (Crossposted from https://mxjn.me/2024/10/17/1)

    • ที่สำคัญที่สุดคือมันเป็น บทความ ไม่ใช่วิดีโอ คัดลอกไปวางได้ อ่านได้ชัดเจน ทำตามได้ตามจังหวะของตัวเอง และเสพได้แบบเงียบ ๆ
      อีกทั้งยังบันทึกเก็บไว้ใช้แบบออฟไลน์หรือเก็บถาวรได้ง่าย ใส่คำอธิบายประกอบได้ และค้นหาก็สะดวก
  • มีไม่กี่อย่างในสายเทคนิคที่ทำให้รู้สึกประทับใจได้เท่ากับโปรเจกต์หรือบทความ Common Lisp ดี ๆ นี่เป็นงานเขียนที่เหมือนของขวัญชิ้นใหญ่จริง ๆ
    ผมอ่านภาคแรกตอนที่ออกมา และตื่นเต้นมากที่จะได้อ่านภาคนี้ด้วย ขอชื่นชมผู้เขียนอย่างยิ่ง

  • แค่ package.sh กับการจัดการ การบิลด์ข้าม 3 ระบบปฏิบัติการ โดยรวมก็ถือเป็นมาสเตอร์คลาสในตัวเองแล้ว แค่ไล่ดู GitHub repository ก็ได้เรียนรู้อะไรไปเยอะมาก
    ปกติผมมักบิลด์แอปบรรทัดคำสั่ง Common Lisp ด้วย SBCL หรือ LispWorks แต่ครั้งหน้าอาจลอง ECL ดูบ้าง การมีทั้งบิลด์สำหรับ macOS และ Linux นั้นเจ๋งดี และก็น่าจะสนุกที่ได้ลองอะไรใหม่ ๆ

    • ผมสร้าง คอนฟิก CI นั้นทับลงบนอินฟราของ CL มาหลายปีแล้ว และมันก็ยังพังอยู่เรื่อย ๆ :D
  • บทความดีมาก ผมกำลังพัฒนา เกมยิงมุมมองบุคคลที่สามแบบหลายผู้เล่นที่ใช้เวทมนตร์เป็นหลัก ด้วย Lisp หรือจะให้แม่นกว่านั้นคือ ClojureScript เป็นเกม 3D บนเว็บ และผมก็ตั้งใจจะเขียนบล็อกเล่าเส้นทางนี้รวมถึงเครื่องมือและ abstraction ที่ทำขึ้นสำหรับโปรเจกต์นี้ด้วย
    ถ้าสนใจ เดโมอยู่ที่นี่: https://wizardmasters.io

    • Jon Blow ก็เคยพยายามทำเกมแบบนี้เมื่อนานมาแล้ว การดูว่าเขาล้มเหลวอย่างไรและเพราะอะไรอาจให้บทเรียนที่น่าสนใจได้
  • ตัวบทความเองแข็งแรงมาก แต่พอเห็นขั้นตอนเซ็ตอัปในภาคแรกที่โยงไปทั้ง Common Lisp เอง, Python, C และอีกหลายสเต็ป ก็พอจะเห็นได้ว่าทำไม CL ถึงไม่ได้เป็นที่นิยมมากนัก โดยเฉพาะในหมู่นักเขียนโปรแกรมรุ่นใหม่
    น่าเสียดาย และคงดีถ้ามีใครสักคนช่วยทุ่มเทเพื่อ ทำให้ภาษาเข้าถึงง่ายขึ้นในแง่การติดตั้ง

    • ถึงจะไม่ใช่ว่าเล็งปัญหาเดียวกันแบบตรง ๆ เสียทีเดียว แต่ https://ciel-lang.org/ ก็อย่างน้อยพยายามแก้ปัญหา มีหลายขั้นตอนเกินไป อยู่บางส่วน
      เท่าที่เข้าใจ มันโฟกัสมากกว่าที่ปัญหาเรื่องมีตัวเลือกเยอะเกินไป และค่าเริ่มต้นเก่า ๆ ที่ดูเชยแล้ว
  • event loop เป็นตัวอย่างที่ยอดเยี่ยมว่า loop นั้นจริงจังแค่ไหนในฐานะ ภาษาย่อยเฉพาะทางสำหรับการวนซ้ำ จะชอบหรือไม่ชอบก็ตาม ;)

    • ใช้ https://iterate.common-lisp.dev/ แทน loop ไม่ได้หรือ? จะได้ไม่มีไวยากรณ์ประหลาดที่ไม่ใช่ S-expression และก็ไม่ต้องมี do เพื่อย้อนกลับไปใช้ไวยากรณ์แบบ Lisp
      ใช้ if/when ปกติได้โดยไม่มี else/end ที่ชวนปวดตา และโดยรวมก็เพิ่มความสามารถที่มีประโยชน์เข้าไปด้วย
    • ตอนแรกผมก็หัวเราะเยาะมัน แต่หลังจากเขียน Common Lisp มาหลายปี loop กลับกลายเป็นหนึ่งในองค์ประกอบของ CL ที่ผมชอบที่สุด
  • บทความนี้ทำให้นึกถึง "Caves of Clojure": https://stevelosh.com/blog/2012/07/caves-of-clojure-01/

  • บังเอิญว่าผมเพิ่งเริ่มพัฒนา roguelike ด้วย Python ในสัปดาห์นี้พอดี ลองทำด้วย Lisp ก็น่าจะเท่ดี

  • รู้สึกเหมือนโดนหลอกเลย ผมเข้ามาเพื่อเรียนรู้วิธีทำเกมง่าย ๆ แต่กลับได้เรียนรู้เรื่อง การคำนวณโดยรวมทั้งวงการ ไปมหาศาล
    ดีมากจริง ๆ