Extra — เล่นข้ามบอร์ด 2-player

Pong ผ่าน WiFi + MQTT

โปรเจกต์เสริม — ต่อยอดจากคาบ WiFi/MQTT
2 บอร์ด · 2 คน · เล่นเกมเดียวกันคนละจอ

คาบที่แล้วเราส่ง "คะแนน" คุยกันได้แล้ว วันนี้เราจะส่ง "ตำแหน่งไม้ตี" แบบสด ๆ — สองบอร์ดเล่น Pong ด้วยกันจริง ๆ คนละเครื่อง

ดูของจริงก่อน — 2 บอร์ดเล่น Pong คู่กัน บนบอร์ด BENTO

เดโมรันบนบอร์ด BENTO จริง — โดย อ.วิรุฬห์ ศรีบริรักษ์ (YouTube)

เป้าหมาย + ใช้จริงที่ไหน

เราจะทำให้ 2 บอร์ดเล่น Pong ด้วยกัน โดยแต่ละคนคุมไม้ตีของตัวเอง แล้วเห็นไม้ของอีกฝั่งขยับตามแบบเรียลไทม์

  • P1 คุมไม้ฝั่งซ้าย · P2 คุมไม้ฝั่งขวา
  • ต่างคน "ส่ง" ตำแหน่งไม้ตัวเองขึ้น broker และ "รับ" ของอีกฝั่งมาวาด
  • ข้อความสั้นมาก แค่ y:120 — เบา ส่งได้บ่อย ไม่หน่วง

เล็กแค่ไหน? ข้อความ "y:120" ยาวแค่ราว 5 ไบต์ ถ้าเทียบกับการส่งภาพทั้งจอ (800×480 พิกเซล) ในแต่ละเฟรม จะต่างกันมหาศาล —

"y:120"5 ไบต์    800×480×2768,000 ไบต์/เฟรม\underbrace{\texttt{"y:120"}}_{\approx\,5\ \text{ไบต์}} \;\lll\; \underbrace{800\times480\times2}_{\approx\,768{,}000\ \text{ไบต์/เฟรม}}

เราไม่ได้ส่ง "ภาพ" ข้ามเน็ต เราส่งแค่ ตัวเลขเดียว แล้วให้บอร์ดอีกฝั่งวาดเอง นี่คือเหตุผลที่มันลื่นพอจะเล่นได้ (เดี๋ยวสไลด์เรื่อง rate จะคิดเลขต่อว่าทำไมยัง "ส่งเฉพาะตอนเปลี่ยน" อีก)

ทำไมเรื่องนี้สำคัญในโลกจริง: รูปแบบ "ส่ง state ของฉัน รับ state ของคนอื่น" คือหัวใจเดียวกับ multiplayer game ออนไลน์ และ Industry 4.0 ที่เครื่องจักรหลายตัวรายงานสถานะเข้า broker กลางแล้วมองเห็นกันทั้งสาย

ขวา: งาน LAN party (DreamHack) — คนนับพันต่อเครื่องเข้าเครือข่ายเดียว เล่นเกมเดียวกันพร้อมกัน คือภาพจริงของ "หลาย node ส่ง state ของตัวเอง เห็น state ของคนอื่น" · ที่มา: "Winter 2004 DreamHack LAN Party" — Toffelginkgo, CC BY-SA 3.0, Wikimedia Commons

เกร็ด: ทำไม embedded สำคัญ — PlayStation: I/O เปิดโลกใหม่

ทุกครั้งที่เครื่องเกมเพิ่มช่องทาง I/O ใหม่ (CD-ROM บน PlayStation, WiFi บน DS) มันเปิดเกมแบบใหม่ทั้งหมดที่เครื่องเดิมทำไม่ได้

เชื่อมกับวันนี้: การที่บอร์ดน้องคุยกันผ่าน network (WiFi/MQTT) ก็เปิดเกม multiplayer ที่เครื่องเดี่ยวทำไม่ได้

PlayStation รุ่นแรก (SCPH-1000, ปี 1994) — ฝาด้านบนคือช่อง CD-ROM ซึ่งเป็น I/O ใหม่ที่จุเกมได้ใหญ่กว่าตลับมาก เปิดทางให้เกมมีหนัง เสียงพากย์ และฉาก 3D · ที่มา: "PlayStation SCPH-1000 with Controller" — Evan-Amos, Public domain, Wikimedia Commons

The Original PlayStation Changed Gaming Forever! — RES - Retrospective · ดูว่าการเปลี่ยนจากตลับมาเป็น CD-ROM (I/O ใหม่) เปิดเกมแบบใหม่ทั้งหมดได้อย่างไร — หลักการเดียวกับที่ network เปิดเกม multiplayer ให้บอร์ดเรา · ที่มา: en.wikipedia.org/wiki/PlayStation_technical_specifications

ย้อนไปจุดเริ่ม: Pong ปี 1972 — 2 คนเล่นบนเครื่องเดียว

นี่คือตู้ PONG ของ Atari ตัวจริง เกมที่เราจะทำวันนี้ก็คือเกมนี้แหละ สังเกตแผงควบคุม: มี PLAYER 1 กับ PLAYER 2 อยู่ติดกันบนตู้เดียว ปุ่มหมุนคนละอันคุมไม้ตีคนละข้าง

ที่มา: "Atari Pong arcade game front" — spablab, CC BY 2.0, Wikimedia Commons

สมัยนั้นสองคนต้องมายืน ข้างกัน หน้าตู้เดียว สิ่งที่เราจะทำต่างออกไปนิดเดียวแต่สำคัญมาก คือย้าย P1 กับ P2 ไปอยู่ คนละบอร์ด คนละจอ แล้วให้คุยกันผ่าน network — หลักการเล่นเหมือนเดิมเป๊ะ เปลี่ยนแค่ "ตำแหน่งไม้" เดินทางผ่านสายแทนที่จะอยู่ในเครื่องเดียว

Pong (1972 Atari Arcade Game) — Gameplay, 50th Anniversary — AlphaYellow · ไม้สองข้างกับลูกบอลที่เด้งไปมา — นี่คือการเคลื่อนไหวที่เราจะสร้างขึ้นใหม่ให้เล่นข้ามสองบอร์ด

ไดอะแกรม pub/sub: ใครส่ง ใครฟัง

หัวใจคือ topic สลับฝั่ง: P1 ส่งเข้า /p1 และฟัง /p2, ส่วน P2 ทำสลับกันเป๊ะ

บอร์ด P1 ไม้ตีฝั่งซ้าย Broker test.mosquitto.org :1883 บอร์ด P2 ไม้ตีฝั่งขวา publish game/room1/p1 รับ game/room1/p2 publish game/room1/p2 รับ game/room1/p1 payload สั้น ๆ: "y:120" (y ของไม้ตี)

ถ้าจำสองบรรทัดนี้ได้ก็เข้าใจทั้งโปรเจกต์: ฉันส่งเข้า topic ของฉัน, ฉันฟัง topic ของอีกคน

ลองดูข้อความ วิ่งจริง — แพ็กเก็ต y:120 เดินทางจากบอร์ดหนึ่งผ่าน broker ไปอีกบอร์ด แล้วของอีกฝั่งก็สวนกลับมาในเส้นทางสลับฝั่ง:

บอร์ด P1 ส่ง /p1 · ฟัง /p2 Broker test.mosquitto.org:1883 บอร์ด P2 ส่ง /p2 · ฟัง /p1 y:120 y:84

ก้อนน้ำเงินคือไม้ของ P1 วิ่งไปขึ้นจอ P2, ก้อนชมพูคือไม้ของ P2 สวนกลับมาขึ้นจอ P1 — คนละ topic แต่ broker ตัวเดียว เป็นตัวกลางส่งต่อให้

test.mosquitto.org ไม่ใช่กล่องในรูปวาด แต่เป็น เซิร์ฟเวอร์จริง ที่รันอยู่ใน data center แบบนี้ คอยรับ-ส่งข้อความให้ใครก็ได้บนอินเทอร์เน็ต · ที่มา: "Datacenter Server Racks" — Carl Lender, CC BY 2.0, Wikimedia Commons

MQTT QoS - Quality of Service | MQTT Essentials Part 7 — HiveMQ — อธิบายระดับ QoS 0/1/2 ว่าต่างกันอย่างไร เลือกใช้แบบไหนตอนส่งตำแหน่งไม้ตีให้คู่ต่อสู้ทันเกม

เดินโค้ด (1): ต่อเน็ต + ขอฟังอีกฝั่ง

ลำดับเหมือนคาบที่แล้ว — ต่อ WiFi ก่อน แล้วค่อย MQTT แล้ว subscribe topic ของคู่ต่อสู้

    TOPIC_ME  = "game/room1/p1"        # P1 ส่งตำแหน่งไม้ตัวเองที่นี่
    TOPIC_YOU = "game/room1/p2"        # P1 ฟังไม้ของ P2 ที่นี่
    CLIENT_ID = "bento-pong-p1"        # ต้องไม่ซ้ำกับอีกบอร์ด

    # ต่อ WiFi (ถ้า auto-connect อยู่แล้วจะผ่านเร็ว)
    for _ in range(6):
        if wifi.is_connected() or wifi.connect(SSID, PASSWORD):
            break
        time.sleep(2)

    # ต่อ MQTT แล้วขอฟังไม้ของอีกฝั่ง
    mqtt.connect(BROKER, PORT, client_id=CLIENT_ID)
    mqtt.subscribe(TOPIC_YOU)

ลำดับ 3 ขั้นนี้ ห้ามสลับ — ต้องมี IP จาก WiFi ก่อน แล้วค่อยต่อ MQTT แล้วจึงขอ subscribe ฟังไม้ของคู่ต่อสู้:

1 · wifi.connect() ต่อวิทยุ → ได้ IP ก่อน 2 · mqtt.connect() เปิด session กับ broker 3 · subscribe(TOPIC_YOU) ขอฟัง /p2 = ไม้ของคู่ต่อสู้ สลับลำดับ = mqtt.connect() ล้มเหลว เพราะยังไม่มีเน็ตให้วิ่ง

ทางลัด: ต่อ WiFi ผ่านเมนู Wi-Fi บนจอ ครั้งเดียว (เซฟรหัสไว้) แล้ว wifi.connect() จะผ่านทันทีเพราะบอร์ด auto-connect ให้แล้ว

เดินโค้ด (2): ส่งไม้ตัวเอง + วาดไม้คู่ต่อสู้

หัวใจอยู่ใน update() ที่ game.run เรียกทุกเฟรม: คุมไม้ตัวเอง → ส่งเมื่อ เปลี่ยน → รับของเพื่อนมาวาด

    def update():
        global last_sent
        k = game.keys()
        if k.up:   my_paddle.move(0, -SPEED)
        if k.down: my_paddle.move(0,  SPEED)

        # ส่งเฉพาะตอน y เปลี่ยน จะได้ไม่ถล่ม broker ทุกเฟรม
        y = int(my_paddle.y)
        if y != last_sent:
            mqtt.publish(TOPIC_ME, "y:%d" % y)
            last_sent = y

        # รับไม้ของอีกฝั่ง (ไม่บล็อก: ไม่มีของก็คืน None)
        msg = mqtt.get_message()
        if msg is not None:
            _topic, data = msg
            text = data.decode()                # payload เป็น bytes
            if text.startswith("y:"):
                opp_paddle.move_to(opp_paddle.x, int(text[2:]))

    game.run(update, fps=30)

ไฟล์ p2 คือกระจกเงา: แค่สลับ TOPIC_ME/TOPIC_YOU และวางไม้ตัวเองไว้ฝั่งขวา

มองภาพรวม update() หนึ่งรอบ

โค้ดเมื่อกี้อ่านเป็นบรรทัด ๆ อาจจะยังนึกภาพไม่ออกว่ามันวนยังไง ลองดูเป็นแผนภาพ — ทุกเฟรม game.run เรียก update() หนึ่งครั้ง แล้วทำ 3 อย่างตามนี้

ลองดูลำดับ หนึ่งเฟรม เดินทีละขั้น (กล่องที่ "ทำงานอยู่" จะสว่างขึ้น) แล้ววนกลับไปเฟรมถัดไป:

อ่านปุ่ม game.keys() y เปลี่ยน? y != last_sent ใช่ → publish "y:NNN" เข้า TOPIC_ME รับ get_message() (ไม่บล็อก) ว่าง = None → วาดไม้คู่ต่อสู้

จุดที่อยากให้สังเกตคือ ทางแยกตรงกลาง: เราไม่ได้ publish ทุกเฟรม แต่ publish เฉพาะตอน y เปลี่ยนเท่านั้น ส่วนการรับใช้ get_message() ที่ ไม่บล็อก — ถ้ายังไม่มีข้อความเข้ามาก็คืน None เกมเดินต่อได้ทันที ไม่ค้างรอเพื่อน

คณิตเล็ก ๆ: ทำไมต้อง "ส่งเฉพาะตอนเปลี่ยน"

ลองคิดเลขกัน ถ้าเรา publish ทุกเฟรมที่ fps = 30 จำนวนข้อความต่อวินาทีคือ

ข้อความ/วินาที=fps×จำนวนผู้เล่น=30×2=60\text{ข้อความ/วินาที} = \text{fps} \times \text{จำนวนผู้เล่น} = 30 \times 2 = 60

ทั้งที่ไม้ตี ส่วนใหญ่อยู่นิ่ง ไม่ได้ขยับ ค่าที่ส่งซ้ำ ๆ ก็เหมือนเดิมเปล่าประโยชน์ พอใส่เงื่อนไข if y != last_sent: จำนวนข้อความจริงจะเหลือเท่ากับ "จำนวนเฟรมที่ขยับ" เท่านั้น

Nส่งจริงfps×tขยับfps×tทั้งหมดN_{\text{ส่งจริง}} \approx \text{fps} \times t_{\text{ขยับ}} \ll \text{fps} \times t_{\text{ทั้งหมด}}

ลองดูแบบ เคลื่อนไหว — เส้นกวาดเวลาวิ่งจากซ้ายไปขวา: เส้นแดงเต็ม 30 ตลอด ส่วนเส้นเขียว (ของเรา) พุ่งขึ้นเฉพาะช่วงที่ไม้ขยับ แล้วหล่นกลับมา 0 ทันทีที่ปล่อยนิ้ว:

30 0 เวลา → (ไม้ขยับเฉพาะช่วงกลาง) ส่งทุกเฟรม = 30/วิ ตลอด ส่งเฉพาะตอน y เปลี่ยน

เส้นแดงคือถ้าส่งทุกเฟรม (เต็ม 30 ตลอด), เส้นเขียวคือของเราที่พุ่งขึ้นเฉพาะตอนนิ้วกดขยับ แล้วกลับมา 0 ตอนปล่อย นี่คือเทคนิคเดียวกับที่ระบบ IoT จริงใช้ลด traffic เรียกว่า send-on-change (ส่งเมื่อค่าเปลี่ยน) ไม่ใช่ส่งตลอดเวลา

เกร็ดสำคัญ: ทำไมไม้เพื่อน "ตามมาช้านิดนึง"

พอเล่นจริงน้องจะสังเกตว่า ไม้ของเราขยับ ทันที (เพราะอ่านปุ่มในเครื่องเรา) แต่ไม้ของเพื่อนบนจอเรา ตามมาช้ากว่านิดหน่อย — ไม่ใช่บั๊ก แต่เป็นธรรมชาติของ network

ตำแหน่งไม้ของเพื่อนต้องเดินทาง บอร์ดเขา → broker → บอร์ดเรา กว่าจะถึงก็ใช้เวลาหลักสิบมิลลิวินาที สิ่งที่เราวาดจึงเป็น "ค่าที่เพิ่งรับมา" ซึ่งช้ากว่าตำแหน่งจริงของเขาตอนนี้นิดนึง (เงาจางในภาพ)

ดูเส้นทางเป็นไทม์ไลน์: ดีเลย์ที่เราเห็นคือ ผลรวมของทุกช่วงต่อ ไม่ใช่จุดเดียว

บอร์ดเขา ไม้ขยับ (t=0) broker ส่งต่อ บอร์ดเรา วาดบนจอ uplink ~สิบ ms downlink ~สิบ ms + เวลาประมวลผลใน broker

tเห็น    tuplink+tbroker+tdownlinkt_{\text{เห็น}} \;\approx\; t_{\text{uplink}} + t_{\text{broker}} + t_{\text{downlink}}

แต่ละช่วงราวหลักมิลลิวินาทีถึงหลักสิบ พอบวกกันก็เลยเห็นไม้เพื่อน "ตามมาช้านิดนึง" เป็นธรรมดา — เกมออนไลน์ตัวจริงเจอเรื่องนี้เหมือนกัน เขาถึงมีเทคนิคอย่าง interpolation กับ prediction มากลบดีเลย์ให้เนียนขึ้น

Lag Compensation in Online Games Explained — Netduma · เกมใหญ่ใช้ interpolation/prediction กลบดีเลย์ของ network แบบเดียวกับที่อธิบายข้างบนอย่างไร

วิธีรัน + ต่อยอด

ต้องใช้ 2 บอร์ด (อย่างน้อย 2 เครื่อง คนละไฟล์):

  1. บอร์ดที่ 1 — เปิด ex_a2_pong_p1.py กด Program to Device
  2. บอร์ดที่ 2 — เปิด ex_a2_pong_p2.py กด Program to Device
  3. ทั้งคู่ต้องอยู่ WiFi ที่ออกเน็ตได้ และตั้ง CLIENT_ID ไม่ซ้ำกัน
  4. ขยับไม้ของเรา แล้วดูไม้อีกฝั่งบนจอเราขยับตาม — เล่นด้วยกันได้แล้ว

ต่อยอด (เลือก 1):

  • ส่งทั้งตำแหน่งลูกบอลด้วย เช่น "y:120;bx:400;by:200" ให้ P1 เป็นเจ้าของลูก แล้ว P2 วาดตาม
  • เพิ่มคะแนนเมื่อลูกหลุดขอบ แล้ว publish คะแนนขึ้นอีก topic
  • เปลี่ยน room1 เป็นชื่อห้องของกลุ่มตัวเอง จะได้แข่งหลายโต๊ะพร้อมกันไม่ชนกัน

รูปแบบ "ส่ง state ของฉัน–รับ state ของคนอื่น" นี้ขยายไปได้ถึงเกมออนไลน์หลายคน และระบบ IoT/Industry 4.0 ที่อุปกรณ์หลายตัวมองเห็นสถานะกันผ่าน broker กลางตัวเดียว

เชื่อมโยงรากฐาน · Pong ข้ามบอร์ดโยงไปถึงอะไรบ้าง

เกมเล็ก ๆ นี้ดูเหมือนแค่ "ขยับไม้ตี" แต่จริง ๆ มันคือการฝึกรากฐานวิศวกรรมที่ใช้ในงานจริงครบทั้งสี่ด้าน

Pong ข้ามบอร์ด Embedded / MCU WiFi radio → TCP → MQTT (I/O จริง) Python callback · decode() · int(text[2:]) Algorithms send-on-change · รับแบบ non-blocking Graphics map ค่า y → พิกัดจอ · กระจกเงาข้ามแกน

Embedded/MCU — การสื่อสารผ่าน network stack (WiFi radio → TCP → MQTT) คือ I/O แบบเดียวกับที่เซนเซอร์อุตสาหกรรมส่งค่าขึ้น cloud ส่วน CYW55500 ที่ทำ WiFi บนบอร์ดก็คือชิป radio จริงที่เรากำลังโปรแกรมคุม

Pythonupdate() ที่ถูกเรียกทุกเฟรมคือ callback; การแกะ "y:120" เป็นตัวเลขคือ string indexing + แปลง type (int(text[2:])); การจำค่าเก่าด้วย last_sent คือการใช้ตัวแปร state ข้ามรอบการเรียก

Algorithms — เทคนิค send-on-change (ส่งเมื่อค่าเปลี่ยน) กับการรับแบบ non-blocking คือหัวใจของระบบ real-time ที่ต้องประหยัด bandwidth และห้ามค้างรอ I/O

Graphics — การวาดไม้คู่ต่อสู้คือการ map ค่าตัวเลข y กลับเป็น พิกัดบนจอ และการที่ P2 เป็น "กระจกเงา" ของ P1 คือการสะท้อนระบบพิกัดข้ามแกน

ขยับไม้ตีให้สนุกก่อน แล้วค่อย ๆ เห็นว่าเบื้องหลังมันคือ network + callback + พิกัด — พื้นฐานชุดเดียวกับที่ใช้สร้างผลิตภัณฑ์ IoT จริง

fit-css

← Extra Index