🦎 Animals that move by themselves · สัตว์ที่ขยับเองได้ · 自己会动的动物

Procedural animation in Python. One small rule, used again and again, makes a snake, a fish and a lizard that walks. Then an AI model picks the lizard's moves. Eleven short files. Nothing is drawn by hand. · โพรซีเจอรัลแอนิเมชันด้วย Python กฎเล็ก ๆ ข้อเดียว ใช้ซ้ำไปเรื่อย ๆ กลายเป็นงู ปลา และกิ้งก่าที่เดินได้ จากนั้นโมเดล AI เป็นผู้เลือกท่าของกิ้งก่า ไฟล์สั้น ๆ สิบเอ็ดไฟล์ ไม่มีอะไรวาดด้วยมือ · 用 Python 做程序化动画。一条小规则,反复地用,就成了蛇、鱼和会走路的蜥蜴。然后由一个 AI 模型来挑蜥蜴的动作。十一个短文件,没有一笔是手画的。

🇬🇧 English

In most cartoons, an artist draws every picture. In procedural animation, nobody draws the movement. You write a few rules. The computer works out every picture from them.

This page follows one video: A simple procedural animation technique, by argonaut. It is eight and a half minutes long. Watch it first. The video is written in Java. Here you build the same animals in Python.

You install nothing. tkinter draws the window, and it comes with Python. Each step is one short file, and each file runs by itself.

The last four files go past the video. In them, an AI model picks an animal's moves. Two files ask the model over the internet. The last two run the model themselves.

The page ends with a bigger game, and all its code.

🇹🇭 ไทย

การ์ตูนส่วนใหญ่ต้องมีคนวาดทุกภาพ แต่ในโพรซีเจอรัลแอนิเมชัน ไม่มีใครวาดการเคลื่อนไหวเลย คุณเขียนกฎไม่กี่ข้อ แล้วคอมพิวเตอร์คำนวณทุกภาพจากกฎนั้นเอง

หน้านี้เดินตามวิดีโอหนึ่งเรื่อง คือ A simple procedural animation technique ของ argonaut ยาวแปดนาทีครึ่ง ดูวิดีโอก่อน วิดีโอเขียนด้วย Java ส่วนที่นี่คุณจะสร้างสัตว์ตัวเดียวกันด้วย Python

ไม่ต้องติดตั้งอะไรเพิ่ม tkinter เป็นตัววาดหน้าต่าง และมันมากับ Python อยู่แล้ว แต่ละขั้นคือไฟล์สั้น ๆ หนึ่งไฟล์ และทุกไฟล์รันได้ด้วยตัวเอง

ไฟล์สี่ไฟล์สุดท้ายไปไกลกว่าวิดีโอ ในไฟล์พวกนี้ โมเดล AI เป็นผู้เลือกท่าของสัตว์ สองไฟล์ถามโมเดลผ่านอินเทอร์เน็ต ส่วนสองไฟล์สุดท้ายรันโมเดลเอง

ท้ายหน้านี้มีเกมที่ใหญ่กว่า พร้อมโค้ดทั้งหมดของมัน

🇨🇳 中文

大多数动画片里,每一张画都要有人去画。程序化动画不一样,没有人去画动作。你写几条规则,电脑根据规则把每一张画算出来。

这一页跟着一个视频走:argonaut 的 A simple procedural animation technique,八分半钟。请先看视频。视频用的是 Java,在这里你用 Python 做出同样的动物。

什么都不用装。窗口由 tkinter 来画,它是 Python 自带的。每一步是一个短文件,每个文件都能单独运行。

最后四个文件走得比视频更远。在这些文件里,由一个 AI 模型来挑动物的动作。两个文件通过互联网去问模型,最后两个文件自己运行模型。

这一页的最后是一个更大的游戏,还有它的全部代码。

Fileไฟล์文件The rule it addsกฎที่เพิ่มเข้ามา它加上的规则What you seeสิ่งที่คุณเห็น你看到什么
1 · leash.pyStay this far from that point.อยู่ห่างจากจุดนั้นเท่านี้跟那个点保持这么远。A dot that follows.จุดที่เดินตาม一个会跟着走的点。
2 · chain.pyDo it again for every point.ทำแบบเดิมกับทุกจุด每个点都照做一遍。A worm.หนอน一条虫。
3 · body.pyEvery point has a width.ทุกจุดมีความกว้าง每个点有一个宽度。A snake with skin and eyes.งูที่มีผิวและตา有皮有眼睛的蛇。
4 · snake.pyDo not bend too far.อย่างอมากเกินไป不许弯得太厉害。A snake that cannot fold.งูที่พับตัวไม่ได้折不起来的蛇。
5 · fish.pyOther numbers, and fins.เปลี่ยนตัวเลข แล้วเพิ่มครีบ换一组数字,加上鳍。A fish.ปลา一条鱼。
6 · arm.pyPull from both ends.ดึงจากทั้งสองปลาย从两头拉。An arm that reaches.แขนที่เอื้อมไปหา会伸过去的手臂。
7 · lizard.pyA foot stays until it is too far behind.เท้าอยู่กับที่จนกว่าจะตามหลังไกลเกินไป脚不动,直到落后太多。Legs that walk.ขาที่เดินได้会走路的腿。
8 · ask.pySay it in words, and ask a model.พูดออกมาเป็นคำ แล้วถามโมเดล用文字说出来,再去问模型。An answer in the terminal.คำตอบในเทอร์มินัล终端里的一个回答。
9 · hunt.pyThe model picks the move.โมเดลเป็นผู้เลือกท่า由模型来挑动作。A gecko that hunts your mouse.ตุ๊กแกที่ล่าเมาส์ของคุณ追捕你鼠标的壁虎。
10 · decide.pyLoad the model, and ask it once.โหลดโมเดล แล้วถามมันหนึ่งครั้ง加载模型,问它一次。One answer: jump.คำตอบหนึ่งข้อ: jump一个回答:jump。
11 · agent.pySee, ask, act. Again and again.มอง ถาม ลงมือ วนซ้ำไปเรื่อย ๆ看、问、行动,一遍又一遍。Four questions, and a catch.คำถามสี่ข้อ และจับได้หนึ่งครั้ง四个问题,抓到一次。
YOU NEED A SCREEN, AND PYTHON 3.8 OR NEWER tkinter opens a real window. Over plain SSH there is nowhere to put one, so sit at the computer. On Windows and on a Mac with Python from python.org, tkinter is already there. On Linux, type sudo apt install python3-tk. With Homebrew on a Mac, type brew install python-tk. The files use math.dist, and that needs Python 3.8 or newer. tkinter เปิดหน้าต่างจริง ถ้าต่อผ่าน SSH ธรรมดาจะไม่มีที่ให้วางหน้าต่าง ให้นั่งหน้าเครื่องจริง บน Windows และบนแมคที่ลง Python จาก python.org จะมี tkinter อยู่แล้ว บน Linux ให้พิมพ์ sudo apt install python3-tk ถ้าใช้ Homebrew บนแมคให้พิมพ์ brew install python-tk ไฟล์พวกนี้ใช้ math.dist ซึ่งต้องใช้ Python 3.8 ขึ้นไป tkinter 会开一个真的窗口。走普通 SSH 没地方放它,请坐到电脑前。Windows 上,以及从 python.org 装 Python 的 Mac 上,tkinter 已经有了。Linux 上输入 sudo apt install python3-tk。Mac 上用 Homebrew 的话,输入 brew install python-tk。这些文件用到 math.dist,需要 Python 3.8 或更新的版本。
Wordคำ词What it means on this pageความหมายในหน้านี้在这一页里的意思
procedural animationMovement that comes from rules. Nobody draws it.การเคลื่อนไหวที่เกิดจากกฎ ไม่มีใครวาด程序化动画:由规则算出来的动作,没有人去画。
constraintA rule that a point must always obey.ข้อบังคับ: กฎที่จุดต้องทำตามเสมอ约束:一个点必须一直遵守的规则。
anchorThe point that leads. Another point follows it.จุดยึด: จุดที่นำ และมีอีกจุดตามมัน锚点:带头的点,另一个点跟着它。
chainPoints in a row. Each one is tied to the next.โซ่: จุดที่เรียงต่อกัน แต่ละจุดผูกกับจุดถัดไป链:排成一串的点,一个拴着一个。
spineThe chain down the middle of an animal.สันหลัง: โซ่ที่อยู่กลางตัวสัตว์脊柱:动物身体正中间的那条链。
jointA place where the body can bend.ข้อต่อ: จุดที่ลำตัวงอได้关节:身体能弯的地方。
radianHow Python measures an angle. Half a turn is π, about 3.14.เรเดียน: หน่วยที่ Python ใช้วัดมุม ครึ่งรอบคือ π ประมาณ 3.14弧度:Python 量角度的单位。半圈是 π,大约 3.14。
frameOne picture. Many pictures each second look like movement.เฟรม: ภาพหนึ่งภาพ หลายภาพต่อวินาทีดูเป็นการเคลื่อนไหว帧:一张画。每秒很多张,看起来就在动。
inverse kinematicsFinding how an arm must bend so the hand reaches a place.จลนศาสตร์ผกผัน: การหาว่าแขนต้องงออย่างไร มือจึงจะไปถึงจุดที่ต้องการ逆向运动学:算出手臂该怎么弯,手才能够到某个地方。
AI modelA program that learned from examples. This one reads sentences and picks an answer.โมเดล AI: โปรแกรมที่เรียนรู้จากตัวอย่าง ตัวนี้อ่านประโยคแล้วเลือกคำตอบAI 模型:从例子里学会做事的程序。这一个读句子,然后挑一个答案。
agentA program that looks, asks and acts, again and again.เอเจนต์: โปรแกรมที่มอง ถาม แล้วลงมือทำ วนซ้ำไปเรื่อย ๆ智能体:一遍又一遍地看、问、行动的程序。
threadA second line of work inside one program.เธรด: งานสายที่สองที่ทำไปพร้อมกันในโปรแกรมเดียว线程:同一个程序里的第二条工作线。
serverA program that waits for questions and sends answers.เซิร์ฟเวอร์: โปรแกรมที่รอรับคำถาม แล้วส่งคำตอบกลับไป服务器:等着接收问题、再送出回答的程序。
GPUA part of a computer. It does a lot of maths at once.GPU: ชิ้นส่วนของคอมพิวเตอร์ที่คำนวณได้ครั้งละมาก ๆ พร้อมกันGPU:电脑里能同时做大量运算的部件。

📏 Tutorial 1 — One rule: stay this far away · บทที่ 1 — กฎข้อเดียว: อยู่ห่างเท่านี้ · 教程 1 —— 一条规则:保持这么远

🇬🇧 English

Everything on this page is built from one rule. A point must stay a fixed distance from another point.

The video calls it a distance constraint. Here it is a function with five lines, called pull.

🇹🇭 ไทย

ทุกอย่างในหน้านี้สร้างจากกฎข้อเดียว คือจุดหนึ่งต้องอยู่ห่างจากอีกจุดหนึ่งเป็นระยะคงที่

วิดีโอเรียกมันว่า distance constraint ส่วนที่นี่มันคือฟังก์ชันห้าบรรทัด ชื่อ pull

🇨🇳 中文

这一页的所有东西都来自一条规则:一个点必须和另一个点保持固定的距离。

视频里把它叫作距离约束。在这里它是一个五行的函数,名字叫 pull。

1 A dot on a leash · จุดที่ผูกสายจูง · 拴着绳子的点

🇬🇧 English

There are two dots. The white dot is the anchor. It goes where your mouse goes. The orange dot has one rule: stay 120 pixels from the anchor.

So in every frame we do three things. We look from the anchor to the dot. We measure how far it is. Then we move the dot along that line until it is 120 pixels away.

Move the anchor and the dot follows it. Nobody told it to follow. The rule did that.

🇹🇭 ไทย

มีจุดสองจุด จุดสีขาวคือจุดยึด มันไปตามเมาส์ของคุณ จุดสีส้มมีกฎข้อเดียว คืออยู่ห่างจากจุดยึด 120 พิกเซล

ดังนั้นทุกเฟรมเราทำสามอย่าง มองจากจุดยึดไปที่จุดสีส้ม วัดว่ามันห่างแค่ไหน แล้วเลื่อนจุดไปตามเส้นนั้นจนห่าง 120 พิกเซลพอดี

ขยับจุดยึดแล้วจุดสีส้มก็ตามมา ไม่มีใครสั่งให้มันตาม กฎเป็นคนทำ

🇨🇳 中文

有两个点。白点是锚点,你的鼠标去哪它就去哪。橙点只有一条规则:离锚点 120 像素。

所以每一帧我们做三件事:从锚点看向橙点,量一量有多远,再把橙点沿着这条线挪到正好 120 像素的地方。

挪动锚点,橙点就跟过来。没有人叫它跟,是规则让它跟的。

▶ Live. Move your mouse over the picture, or tap it. This runs in your browser, in JavaScript. It uses the same maths as the Python file below. ▶ ภาพสด เลื่อนเมาส์บนภาพ หรือแตะที่ภาพ ภาพนี้รันในเบราว์เซอร์ของคุณด้วย JavaScript และใช้คณิตศาสตร์เดียวกับไฟล์ Python ข้างล่าง ▶ 实时演示。把鼠标移到图上,或者点一下。它在你的浏览器里用 JavaScript 运行,用的数学和下面的 Python 文件一样。

💻 RUN IT

python leash.py

📄 NEW FILE · leash.py

"""leash.py - a dot on a leash.

    python leash.py

Move the mouse over the window. The white dot goes where the mouse goes.
The orange dot must stay LEASH pixels away from it, so it gets pulled along.
"""
import math
import tkinter as tk

WIDE = 800           # the window, in pixels
HIGH = 600
LEASH = 120          # how far the orange dot stays from the white dot
WATER = "#12263a"    # the colour behind everything


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


class Leash:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("leash")
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        # Every time the mouse moves over the canvas, tkinter calls mouse_moved.
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.anchor = [WIDE / 2, HIGH / 2]
        self.dot = [WIDE / 2 + LEASH, HIGH / 2]

    def mouse_moved(self, event):
        self.anchor = [event.x, event.y]

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        self.circle(self.anchor, LEASH, "", "#3d5a80")    # "" is no fill: a ring
        self.canvas.create_line(self.anchor, self.dot, fill="#3d5a80", width=2)
        self.circle(self.anchor, 7, "white", "white")
        self.circle(self.dot, 12, "#ee6c4d", "white")

    def frame(self):
        self.dot = pull(self.anchor, self.dot, LEASH)
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    leash = Leash()
    leash.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
far = math.dist(anchor, point)The distance between two points, in a straight line.ระยะห่างระหว่างสองจุด วัดเป็นเส้นตรง两个点之间的直线距离。
scale = gap / farThe dot is 240 away and the leash is 120. Then scale is 0.5: come half as far.ถ้าจุดอยู่ห่าง 240 และสายยาว 120 scale จะเป็น 0.5 คือเข้ามาเหลือครึ่งเดียว点在 240 远,绳子长 120,那么 scale 是 0.5:只走一半那么远。
self.canvas.bind("<Motion>", self.mouse_moved)tkinter calls your method each time the mouse moves. event.x and event.y say where it is.tkinter เรียกเมธอดของคุณทุกครั้งที่เมาส์ขยับ event.x กับ event.y บอกว่าเมาส์อยู่ตรงไหน鼠标每动一次,tkinter 就调用你的方法。event.x 和 event.y 说它在哪。
self.canvas.delete("all")Wipe the old picture, then draw the new one. That is all an animation is.ลบภาพเก่า แล้ววาดภาพใหม่ แอนิเมชันมีแค่นี้เอง擦掉旧画,再画新的。动画就是这么回事。
self.root.after(16, self.frame)Call frame again in 16 milliseconds. frame books itself, so it never stops.เรียก frame อีกครั้งในอีก 16 มิลลิวินาที frame จองตัวเองไว้ทุกครั้ง มันจึงไม่หยุด16 毫秒后再调用一次 frame。frame 每次都给自己预约下一次,所以不会停。
YOU GET A dot that follows, from a rule that never says "follow". Keep that idea. Every animal below is this rule, used many times. จุดที่เดินตาม จากกฎที่ไม่เคยพูดคำว่า "ตาม" เลย จำแนวคิดนี้ไว้ สัตว์ทุกตัวข้างล่างคือกฎข้อนี้ที่ใช้ซ้ำหลายครั้ง 一个会跟着走的点,而规则里从没说过“跟”这个字。记住这个想法。下面每一种动物,都是把这条规则用很多遍。
16 MILLISECONDS IS A WISH, NOT A PROMISE Sixteen milliseconds would be about 60 frames a second. On the Windows laptop used for this page, tkinter gave 34 to 39. That was counted, not guessed. It still looks smooth. The live picture above runs at 60, so your window may be a little slower than it. 16 มิลลิวินาทีควรจะได้ประมาณ 60 เฟรมต่อวินาที แต่บนแล็ปท็อป Windows ที่ใช้ทำหน้านี้ tkinter ให้ได้ 34 ถึง 39 ตัวเลขนี้นับจริง ไม่ได้เดา ภาพก็ยังลื่นอยู่ ภาพสดข้างบนรันที่ 60 หน้าต่างของคุณจึงอาจช้ากว่านิดหน่อย 16 毫秒算下来大约每秒 60 帧。在做这一页用的 Windows 笔记本上,tkinter 只给了 34 到 39 帧。这是数出来的,不是猜的。看起来仍然流畅。上面的实时演示是 60 帧,所以你的窗口可能比它慢一点。
YOUR TURN
  1. Run it. Move the mouse fast, then slowly.
  2. Change LEASH to 40. Then to 300.
  3. Put a # in front of self.canvas.delete("all"). What do you see?
  4. Change 16 to 200. Why does it look bad now?
  1. รันดู ขยับเมาส์เร็ว ๆ แล้วลองช้า ๆ
  2. เปลี่ยน LEASH เป็น 40 แล้วลอง 300
  3. ใส่ # หน้า self.canvas.delete("all") คุณเห็นอะไร
  4. เปลี่ยน 16 เป็น 200 ทำไมตอนนี้มันดูไม่ดี
  1. 运行它。先快速移动鼠标,再慢慢移动。
  2. 把 LEASH 改成 40,再改成 300。
  3. 在 self.canvas.delete("all") 前面加一个 #。你看到了什么?
  4. 把 16 改成 200。为什么现在不好看了?

2 Many dots: a chain · หลายจุด: โซ่ · 很多点:一条链

🇬🇧 English

Now use the same rule many times. Point 1 stays 16 pixels from point 0. Point 2 stays 16 pixels from point 1. And so on, down the chain.

Only the head knows about the mouse. Every other point knows only the point in front of it. Still, the whole chain follows, like a worm.

The order matters. We start at the head and work back, so the head leads.

🇹🇭 ไทย

ทีนี้ใช้กฎเดิมหลาย ๆ ครั้ง จุดที่ 1 อยู่ห่างจากจุดที่ 0 เป็นระยะ 16 พิกเซล จุดที่ 2 อยู่ห่างจากจุดที่ 1 เป็นระยะ 16 พิกเซล แล้วก็ทำต่อไปตลอดโซ่

มีแค่หัวที่รู้จักเมาส์ จุดอื่นรู้จักแค่จุดที่อยู่ข้างหน้ามัน แต่ทั้งโซ่ก็ยังตามไป เหมือนหนอน

ลำดับสำคัญ เราเริ่มจากหัวแล้วไล่ไปข้างหลัง หัวจึงเป็นตัวนำ

🇨🇳 中文

现在把同一条规则用很多遍。第 1 个点离第 0 个点 16 像素,第 2 个点离第 1 个点 16 像素,就这样一路排下去。

只有头知道鼠标在哪。别的点只认识它前面那个点。可是整条链还是跟着走,像一条虫。

顺序很重要。我们从头开始,往后算,所以是头在带路。

▶ Live. The white dots are the 24 points. The thick line through them makes the worm. ▶ ภาพสด จุดสีขาวคือจุดทั้ง 24 จุด เส้นหนาที่ลากผ่านมันทำให้เป็นหนอน ▶ 实时演示。白点就是那 24 个点。穿过它们的粗线让它成了一条虫。

📄 NEW FILE · chain.py

"""chain.py - many dots, each one on a leash to the dot in front.

    python chain.py

Move the mouse over the window. The first point goes to the mouse.
Every other point is pulled by the one in front of it. You get a worm.
"""
import math
import tkinter as tk

WIDE = 800           # the window, in pixels
HIGH = 600
COUNT = 24           # how many points are in the chain
GAP = 16             # pixels from one point to the next
WATER = "#12263a"    # the colour behind everything


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


class Chain:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("chain")
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2, HIGH / 2]
        # Start as a straight line. Point 0 is the head, in the middle of the window.
        self.points = []
        for i in range(COUNT):
            self.points.append([WIDE / 2 - i * GAP, HIGH / 2])

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def move(self):
        self.points[0] = self.mouse
        # Start at 1: the head has nothing in front of it to pull it.
        for i in range(1, COUNT):
            self.points[i] = pull(self.points[i - 1], self.points[i], GAP)

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        # One thick line through every point. Round ends and corners make it a worm.
        self.canvas.create_line(self.points, fill="#ee6c4d", width=18,
                                capstyle="round", joinstyle="round")
        for point in self.points:
            self.circle(point, 3, "white", "white")

    def frame(self):
        self.move()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    chain = Chain()
    chain.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
for i in range(1, COUNT):Start at 1. Point 0 is the head, and nothing pulls it.เริ่มที่ 1 จุดที่ 0 คือหัว ไม่มีอะไรดึงมัน从 1 开始。第 0 个点是头,没有东西拉它。
pull(self.points[i - 1], self.points[i], GAP)The anchor is now the point in front. That is the only change from step 1.จุดยึดตอนนี้คือจุดที่อยู่ข้างหน้า นี่คือสิ่งเดียวที่ต่างจากขั้นที่ 1锚点现在是前面那个点。和第 1 步相比只改了这一处。
self.canvas.create_line(self.points, ...)tkinter takes the whole list of points and joins them with one line.tkinter รับลิสต์ของจุดทั้งหมด แล้วลากเส้นเดียวเชื่อมทุกจุดtkinter 接过整个点的列表,用一条线把它们连起来。
YOUR TURN
  1. Change COUNT to 5. Then to 60.
  2. Change GAP to 4. Then to 40.
  3. Change width=18 to width=40. A fat worm.
  4. Draw circles of radius 9, not 3. Now you can see every point.
  1. เปลี่ยน COUNT เป็น 5 แล้วลอง 60
  2. เปลี่ยน GAP เป็น 4 แล้วลอง 40
  3. เปลี่ยน width=18 เป็น width=40 จะได้หนอนอ้วน
  4. วาดวงกลมรัศมี 9 แทน 3 ทีนี้คุณจะเห็นทุกจุด
  1. 把 COUNT 改成 5,再改成 60。
  2. 把 GAP 改成 4,再改成 40。
  3. 把 width=18 改成 width=40。一条胖虫。
  4. 把圆的半径从 3 改成 9。现在每个点都看得见了。

🐍 Tutorial 2 — From a chain to an animal · บทที่ 2 — จากโซ่เป็นสัตว์ · 教程 2 —— 从链到动物

🇬🇧 English

The chain becomes the spine. We put a body round it, then stop it bending too far.

🇹🇭 ไทย

โซ่กลายเป็นสันหลัง เราใส่ลำตัวรอบมัน แล้วห้ามไม่ให้มันงอมากเกินไป

🇨🇳 中文

链变成脊柱。我们在它外面包上身体,再让它不能弯得太厉害。

3 Give it a body · ใส่ลำตัวให้มัน · 给它一个身体

🇬🇧 English

A worm is one thick line. A real animal is wide in some places and thin in others. So give every point its own width.

Now we must find the skin. Stand on a point of the spine. Face the way the body is going. Turn a quarter turn to the left and walk out. You are on the skin. Turn right for the other side.

walk does this. It is the one piece of maths on this page: cos gives the step across, and sin gives the step down. The video calls it the parametric equation of a circle.

Join the skin points and you have a body. Use walk twice more for the eyes.

🇹🇭 ไทย

หนอนคือเส้นหนาเส้นเดียว แต่สัตว์จริงกว้างบางที่และแคบบางที่ เราจึงให้ทุกจุดมีความกว้างของตัวเอง

ทีนี้เราต้องหาผิว ยืนบนจุดหนึ่งของสันหลัง หันไปทางที่ลำตัวกำลังไป หมุนซ้ายหนึ่งในสี่รอบแล้วเดินออกไป คุณอยู่บนผิวแล้ว หมุนขวาสำหรับอีกด้าน

walk ทำสิ่งนี้ มันคือคณิตศาสตร์ชิ้นเดียวของหน้านี้ cos ให้ระยะที่ก้าวไปด้านข้าง และ sin ให้ระยะที่ก้าวลง วิดีโอเรียกมันว่าสมการอิงตัวแปรเสริมของวงกลม

ลากเส้นเชื่อมจุดบนผิว คุณก็ได้ลำตัว แล้วใช้ walk อีกสองครั้งสำหรับตา

🇨🇳 中文

虫只是一条粗线。真的动物有的地方宽,有的地方窄。所以给每个点一个自己的宽度。

现在要找到皮肤。站在脊柱的一个点上,面朝身体前进的方向,向左转四分之一圈,走出去,你就站在皮肤上了。另一边就向右转。

walk 做的就是这件事。它是这一页唯一的一点数学:cos 给出横着走多少,sin 给出往下走多少。视频里把它叫作圆的参数方程。

把皮肤上的点连起来,就有了身体。再用两次 walk 画眼睛。

▶ Live. Pull the head back across its own neck. The skin crosses over itself. Step 4 fixes that. ▶ ภาพสด ลากหัวย้อนกลับข้ามคอของมันเอง ผิวจะไขว้ทับกัน ขั้นที่ 4 จะแก้เรื่องนี้ ▶ 实时演示。把头往回拉,越过它自己的脖子。皮肤会交叉在一起。第 4 步来修它。

📄 NEW FILE · body.py

"""body.py - give the chain a body: a width at every point, a skin, two eyes.

    python body.py

Move the mouse over the window. The chain is now the spine of a snake.
Turn it sharply and the skin crosses over itself. The next file fixes that.
"""
import math
import tkinter as tk

WIDE = 800                # the window, in pixels
HIGH = 600
GAP = 14                  # pixels from one point of the spine to the next
QUARTER = math.pi / 2     # a quarter turn. Python measures angles in radians.
WATER = "#12263a"         # the colour behind everything

# How far the skin is from the spine at each point, from the head to the tail.
# There is one number for each point, so this list also says how long the snake is.
WIDTHS = [18, 20, 17, 16, 16, 16, 16, 16, 16, 16, 16, 15, 15, 15,
          14, 14, 13, 13, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4]


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


def angle_to(start, end):
    """The direction you face when you stand at start and look at end."""
    return math.atan2(end[1] - start[1], end[0] - start[0])


def walk(start, angle, far):
    """Stand at start, face angle, walk far pixels. Give back where you are."""
    return [start[0] + math.cos(angle) * far, start[1] + math.sin(angle) * far]


class Body:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("body")
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2, HIGH / 2]
        # Start as a straight line. Point 0 is the head, in the middle of the window.
        self.points = []
        self.angles = []      # which way the body faces at each point
        for i in range(len(WIDTHS)):
            self.points.append([WIDE / 2 - i * GAP, HIGH / 2])
            self.angles.append(0)       # 0 is "facing right"

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def move(self):
        self.points[0] = self.mouse
        for i in range(1, len(WIDTHS)):
            self.points[i] = pull(self.points[i - 1], self.points[i], GAP)
            # Each point faces the point in front of it.
            self.angles[i] = angle_to(self.points[i], self.points[i - 1])
        # The head has no point in front, so it faces the same way as its neck.
        self.angles[0] = self.angles[1]

    def outline(self):
        """The points of the skin, in order: down one side and back up the other."""
        left = []
        right = []
        for i in range(len(WIDTHS)):
            # A quarter turn from "forward" is "sideways". Walk out to the skin.
            left.append(walk(self.points[i], self.angles[i] - QUARTER, WIDTHS[i]))
            right.append(walk(self.points[i], self.angles[i] + QUARTER, WIDTHS[i]))
        right.reverse()       # the right side runs from the tail back to the head
        nose = walk(self.points[0], self.angles[0], WIDTHS[0])
        return [nose] + left + right

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        # smooth=True rounds every corner, so the dots become one curved skin.
        self.canvas.create_polygon(self.outline(), fill="#ee6c4d",
                                   outline="white", width=3, smooth=True)
        # The eyes. 1.2 is most of a quarter turn (1.57), so one sits on each side.
        head = self.points[0]
        facing = self.angles[0]
        self.circle(walk(head, facing - 1.2, 9), 3, "white", "white")
        self.circle(walk(head, facing + 1.2, 9), 3, "white", "white")

    def frame(self):
        self.move()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    body = Body()
    body.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
math.atan2(end[1] - start[1], end[0] - start[0])The angle from one point to another. Mind the order: y first, then x.มุมจากจุดหนึ่งไปอีกจุดหนึ่ง ระวังลำดับ: y ก่อน แล้วค่อย x从一个点到另一个点的角度。注意顺序:先 y,后 x。
math.cos(angle) * far math.sin(angle) * farHow far across and how far down, for a walk of far pixels at this angle.ไปด้านข้างเท่าไร และลงเท่าไร เมื่อเดิน far พิกเซลในมุมนี้朝这个角度走 far 像素,横着走了多少,往下走了多少。
QUARTER = math.pi / 2A full turn is 2π radians. So a quarter turn is π / 2.หนึ่งรอบเต็มคือ 2π เรเดียน หนึ่งในสี่รอบจึงเป็น π / 2一整圈是 2π 弧度,所以四分之一圈是 π / 2。
right.reverse()The outline must go round the body one way: down the left side, back up the right.เส้นขอบต้องวนรอบลำตัวทางเดียว: ลงทางซ้าย แล้วย้อนขึ้นทางขวา轮廓必须朝一个方向绕身体一圈:沿左边下去,再沿右边回来。
smooth=Truetkinter bends the line between the points. Without it the snake has corners.tkinter ทำให้เส้นระหว่างจุดโค้ง ถ้าไม่ใส่ งูจะมีมุมเหลี่ยมtkinter 把点和点之间的线弯成曲线。不加它,蛇身上全是棱角。
DOWN IS PLUS On a screen, y = 0 is the top, and y grows as you go down. So an angle that gets bigger turns clockwise. That is the other way from your maths book. Nothing here breaks, because left and right are mirror pictures of each other. บนหน้าจอ y = 0 อยู่ด้านบน และ y เพิ่มขึ้นเมื่อลงข้างล่าง มุมที่ใหญ่ขึ้นจึงหมุนตามเข็มนาฬิกา ซึ่งกลับทางกับในหนังสือคณิตศาสตร์ แต่ที่นี่ไม่มีอะไรพัง เพราะซ้ายกับขวาเป็นภาพสะท้อนของกันและกัน 在屏幕上,y = 0 在最上面,越往下 y 越大。所以角度变大是顺时针转,和数学课本正好相反。这里什么都不会出错,因为左边和右边互为镜像。
YOUR TURN
  1. Change the first number in WIDTHS to 40. A big head.
  2. Delete the last ten numbers. A short snake.
  3. Change smooth=True to smooth=False.
  4. The eyes use 1.2. Try 0.4. Where did they go?
  1. เปลี่ยนเลขตัวแรกใน WIDTHS เป็น 40 จะได้หัวโต
  2. ลบเลขสิบตัวสุดท้าย จะได้งูตัวสั้น
  3. เปลี่ยน smooth=True เป็น smooth=False
  4. ตาใช้ค่า 1.2 ลองเปลี่ยนเป็น 0.4 ตาไปอยู่ตรงไหน
  1. 把 WIDTHS 的第一个数改成 40。一个大脑袋。
  2. 删掉最后十个数。一条短蛇。
  3. 把 smooth=True 改成 smooth=False。
  4. 眼睛用的是 1.2。试试 0.4。它们跑到哪去了?

4 A spine that cannot fold, and a head that steers · สันหลังที่พับไม่ได้ และหัวที่เลี้ยวเอง · 折不起来的脊柱,会转弯的头

🇬🇧 English

There are two changes. Both use one new function, limit.

First, the spine. A joint may bend only 22.5 degrees from the joint in front. If it wants more, it gets 22.5. So pull goes away. We find the angle, limit it, then walk back one gap. The distance is still right, because walk always goes exactly one gap.

Second, the head. It no longer jumps to the mouse. It turns toward the mouse, a little in each frame, and swims forward. When it is near the mouse, it stops. That is the same limit, used on the head.

🇹🇭 ไทย

มีการเปลี่ยนสองอย่าง ทั้งสองอย่างใช้ฟังก์ชันใหม่ตัวเดียว คือ limit

อย่างแรกคือสันหลัง ข้อต่อหนึ่งงอได้แค่ 22.5 องศาจากข้อที่อยู่ข้างหน้า ถ้ามันอยากงอมากกว่านั้น มันได้แค่ 22.5 ดังนั้น pull จึงหายไป เราหามุม จำกัดมุม แล้วเดินถอยหลังหนึ่งช่วง ระยะห่างยังถูกต้อง เพราะ walk เดินหนึ่งช่วงพอดีเสมอ

อย่างที่สองคือหัว มันไม่กระโดดไปที่เมาส์แล้ว มันหันเข้าหาเมาส์ทีละนิดในแต่ละเฟรม แล้วว่ายไปข้างหน้า พอเข้าใกล้เมาส์มันก็หยุด นี่คือ limit ตัวเดิม ใช้กับหัว

🇨🇳 中文

有两处改动,都用到同一个新函数 limit。

第一处是脊柱。一个关节相对前面那个关节最多只能弯 22.5 度。它想弯得更多,也只给 22.5 度。所以 pull 不用了。我们先算角度,限制它,再往后走一格。距离仍然是对的,因为 walk 每次都正好走一格。

第二处是头。它不再直接跳到鼠标上,而是每一帧朝鼠标转一点点,然后往前游。靠近鼠标时,它就停下。这还是同一个 limit,用在了头上。

▶ Live. Bring the mouse round behind the head. The neck bends as far as it may, and no further. ▶ ภาพสด ลองพาเมาส์อ้อมไปข้างหลังหัว คอจะงอเท่าที่มันงอได้ และไม่มากกว่านั้น ▶ 实时演示。把鼠标绕到头的后面去。脖子弯到它能弯的最大角度,就不再弯了。

📄 NEW FILE · snake.py

"""snake.py - a snake that swims to the mouse and cannot fold in half.

    python snake.py

Move the mouse over the window. The snake turns toward it and swims there.
Every joint of the spine has a limit: it can bend only BEND from the one in front.
"""
import math
import tkinter as tk

WIDE = 800                # the window, in pixels
HIGH = 600
GAP = 14                  # pixels from one point of the spine to the next
SPEED = 4                 # pixels the head moves in one frame
TURN = 0.15               # the most the head can turn in one frame
NEAR = 30                 # the head stops when it is this close to the mouse
BEND = math.pi / 8        # the most one joint can bend: 22.5 degrees
QUARTER = math.pi / 2     # a quarter turn. Python measures angles in radians.
WATER = "#12263a"         # the colour behind everything

# How far the skin is from the spine at each point, from the head to the tail.
# There is one number for each point, so this list also says how long the snake is.
WIDTHS = [18, 20, 17, 16, 16, 16, 16, 16, 16, 16, 16, 15, 15, 15,
          14, 14, 13, 13, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4]


def angle_to(start, end):
    """The direction you face when you stand at start and look at end."""
    return math.atan2(end[1] - start[1], end[0] - start[0])


def walk(start, angle, far):
    """Stand at start, face angle, walk far pixels. Give back where you are."""
    return [start[0] + math.cos(angle) * far, start[1] + math.sin(angle) * far]


def limit(angle, middle, most):
    """Give back angle, or the nearest angle that is within most of middle."""
    # How far angle is from middle, going the short way round the circle.
    # The answer is from -pi to pi: minus is one way round, plus is the other.
    away = (angle - middle + math.pi) % (2 * math.pi) - math.pi
    if away > most:
        return middle + most
    if away < -most:
        return middle - most
    return angle


class Snake:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("snake")
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2, HIGH / 2]
        # Start as a straight line. Point 0 is the head, in the middle of the window.
        self.points = []
        self.angles = []      # which way the body faces at each point
        for i in range(len(WIDTHS)):
            self.points.append([WIDE / 2 - i * GAP, HIGH / 2])
            self.angles.append(0)       # 0 is "facing right"

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def move(self):
        head = self.points[0]
        if math.dist(head, self.mouse) < NEAR:
            return            # it has arrived, so it waits for the mouse to move
        # The head wants to face the mouse, but it can turn only TURN in one frame.
        wanted = angle_to(head, self.mouse)
        self.angles[0] = limit(wanted, self.angles[0], TURN)
        self.points[0] = walk(head, self.angles[0], SPEED)
        for i in range(1, len(WIDTHS)):
            # Each point wants to face the point in front of it, within BEND.
            wanted = angle_to(self.points[i], self.points[i - 1])
            self.angles[i] = limit(wanted, self.angles[i - 1], BEND)
            # Then it stands GAP behind that point. Minus GAP is a walk backwards.
            self.points[i] = walk(self.points[i - 1], self.angles[i], -GAP)

    def outline(self):
        """The points of the skin, in order: down one side and back up the other."""
        left = []
        right = []
        for i in range(len(WIDTHS)):
            # A quarter turn from "forward" is "sideways". Walk out to the skin.
            left.append(walk(self.points[i], self.angles[i] - QUARTER, WIDTHS[i]))
            right.append(walk(self.points[i], self.angles[i] + QUARTER, WIDTHS[i]))
        right.reverse()       # the right side runs from the tail back to the head
        nose = walk(self.points[0], self.angles[0], WIDTHS[0])
        return [nose] + left + right

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        # smooth=True rounds every corner, so the dots become one curved skin.
        self.canvas.create_polygon(self.outline(), fill="#ee6c4d",
                                   outline="white", width=3, smooth=True)
        # The eyes. 1.2 is most of a quarter turn (1.57), so one sits on each side.
        head = self.points[0]
        facing = self.angles[0]
        self.circle(walk(head, facing - 1.2, 9), 3, "white", "white")
        self.circle(walk(head, facing + 1.2, 9), 3, "white", "white")

    def frame(self):
        self.move()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    snake = Snake()
    snake.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
away = (angle - middle + math.pi) % (2 * math.pi) - math.piAngles go round in a circle. 350° and 10° are only 20° apart. This line finds the short way round.มุมวนเป็นวงกลม 350° กับ 10° ห่างกันแค่ 20° บรรทัดนี้หาทางที่สั้นกว่า角度是绕着圈走的。350° 和 10° 只差 20°。这一行找出近的那条路。
if math.dist(head, self.mouse) < NEAR: returnNear enough. Leave move now, so nothing moves in this frame.ใกล้พอแล้ว ออกจาก move ตรงนี้เลย เฟรมนี้จึงไม่มีอะไรขยับ够近了。现在就离开 move,这一帧什么都不动。
self.angles[0] = limit(wanted, self.angles[0], TURN)The head may turn only TURN in one frame.หัวหันได้แค่ TURN ในหนึ่งเฟรม头在一帧里最多只能转 TURN。
self.angles[i] = limit(wanted, self.angles[i - 1], BEND)A joint may differ from the joint in front by only BEND.ข้อต่อหนึ่งต่างจากข้อที่อยู่ข้างหน้าได้แค่ BEND一个关节和前面那个关节最多只能差 BEND。
walk(self.points[i - 1], self.angles[i], -GAP)A minus distance walks backwards. The point stands one gap behind.ระยะติดลบคือการเดินถอยหลัง จุดจึงยืนอยู่ข้างหลังหนึ่งช่วง距离是负数就是倒着走。这个点站在后面一格的地方。
YOU GET A snake whose skin never crosses itself. One function does both jobs: it limits the spine, and it steers the head. งูที่ผิวไม่ไขว้ทับตัวเองอีกเลย ฟังก์ชันเดียวทำสองงาน คือจำกัดสันหลัง และบังคับหัวให้เลี้ยว 一条皮肤再也不会交叉的蛇。一个函数做两件事:限制脊柱,也给头掌舵。
WHY THE HEAD HAS A LIMIT TOO The video sends the head straight at the mouse. That was tried here first. When the mouse jumped behind the snake, one point of the body moved 117 pixels in a single frame. With TURN at 0.15, the biggest move was 7 pixels. With TURN at 0.05, the head turned so gently that no joint ever reached BEND, and the spine's limit did nothing. NEAR is 30 for a reason too. The head's tightest circle has a radius of 4 ÷ 0.15, about 27 pixels. A mouse inside that circle would never be reached, so the head stops before it gets there. ในวิดีโอ หัวพุ่งตรงไปหาเมาส์เลย ที่นี่ก็ลองแบบนั้นก่อน พอเมาส์กระโดดไปอยู่ข้างหลังงู จุดหนึ่งของลำตัวขยับไป 117 พิกเซลในเฟรมเดียว เมื่อ TURN เป็น 0.15 การขยับที่มากที่สุดคือ 7 พิกเซล เมื่อ TURN เป็น 0.05 หัวหันเบามากจนไม่มีข้อต่อไหนงอถึง BEND เลย ขีดจำกัดของสันหลังจึงไม่ได้ทำอะไร ส่วน NEAR เป็น 30 ก็มีเหตุผล วงกลมที่แคบที่สุดของหัวมีรัศมี 4 ÷ 0.15 หรือประมาณ 27 พิกเซล เมาส์ที่อยู่ในวงนั้นหัวจะไปไม่ถึงเลย หัวจึงหยุดก่อนจะถึงตรงนั้น 视频里,头是直直冲向鼠标的。这里一开始也这么试过。鼠标一跳到蛇的身后,身体上有一个点在一帧里挪了 117 像素。TURN 是 0.15 时,最大的一次只挪了 7 像素。TURN 是 0.05 时,头转得太轻,没有一个关节弯到 BEND,脊柱的限制等于没用。NEAR 取 30 也有原因:头能转的最小的圈,半径是 4 ÷ 0.15,大约 27 像素。鼠标要是在这个圈里,头永远够不着,所以头在到那里之前就停下。
YOUR TURN
  1. Change BEND to math.pi / 2. Turn it sharply. The skin crosses again.
  2. Change BEND to math.pi / 40. A stiff snake.
  3. Change NEAR to 0. Hold the mouse still. What does the snake do?
  4. Change TURN to 1. Watch the body when the head snaps round.
  1. เปลี่ยน BEND เป็น math.pi / 2 แล้วให้มันเลี้ยวหักศอก ผิวจะไขว้กันอีกครั้ง
  2. เปลี่ยน BEND เป็น math.pi / 40 จะได้งูตัวแข็ง
  3. เปลี่ยน NEAR เป็น 0 แล้ววางเมาส์นิ่ง ๆ งูทำอะไร
  4. เปลี่ยน TURN เป็น 1 แล้วดูลำตัวตอนที่หัวสะบัดกลับ
  1. 把 BEND 改成 math.pi / 2,让它急转弯。皮肤又交叉了。
  2. 把 BEND 改成 math.pi / 40。一条僵硬的蛇。
  3. 把 NEAR 改成 0,让鼠标停住不动。蛇会怎么做?
  4. 把 TURN 改成 1。看头猛地转回来时,身体怎么动。

🐟 Tutorial 3 — A second animal · บทที่ 3 — สัตว์ตัวที่สอง · 教程 3 —— 第二种动物

🇬🇧 English

The spine does not care what animal it is in. Change the numbers and draw other parts, and you have a new animal.

🇹🇭 ไทย

สันหลังไม่สนใจว่ามันอยู่ในสัตว์ชนิดไหน เปลี่ยนตัวเลข แล้ววาดส่วนอื่นเพิ่ม คุณก็ได้สัตว์ตัวใหม่

🇨🇳 中文

脊柱并不在乎自己长在哪种动物身上。换一组数字,再画些别的部件,就是一种新动物。

5 A fish, and one class for both · ปลา และคลาสเดียวสำหรับทั้งคู่ · 一条鱼,两种动物共用一个类

🇬🇧 English

A fish is the same spine with other numbers. It has ten points, not twenty-eight. It is fat in the middle. Then it gets fins.

Do not copy the class and change the copy. Give the class a word. Animal("fish") and Animal("snake") are the same class. The word picks the numbers. Later, draw asks the word if this animal has fins.

Every fin is an oval. oval makes one from twelve calls to walk. A fin sits on the skin and leans back. It copies the angle of the point in front of it, so the fins turn a moment before the body does.

🇹🇭 ไทย

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

อย่าคัดลอกคลาสแล้วไปแก้ตัวที่คัดลอกมา ให้ส่งคำหนึ่งคำเข้าไปในคลาสแทน Animal("fish") กับ Animal("snake") เป็นคลาสเดียวกัน คำนั้นเป็นตัวเลือกตัวเลข จากนั้น draw จะถามคำนั้นว่าสัตว์ตัวนี้มีครีบไหม

ครีบทุกอันเป็นวงรี oval สร้างวงรีจากการเรียก walk สิบสองครั้ง ครีบอยู่บนผิวและเอนไปข้างหลัง มันลอกมุมของจุดที่อยู่ข้างหน้ามัน ครีบจึงหันก่อนลำตัวนิดหนึ่ง

🇨🇳 中文

鱼就是同一根脊柱,换了一组数字。它有十个点,不是二十八个。它中间胖。然后给它加上鳍。

不要把类复制一份再去改。给类传一个词。Animal("fish") 和 Animal("snake") 是同一个类。这个词决定用哪组数字。后面 draw 再问这个词:这种动物有没有鳍。

每片鳍都是一个椭圆。oval 调用十二次 walk 做出一个椭圆。鳍长在皮肤上,向后斜着。它照抄前面那个点的角度,所以鳍比身体早一点点转过去。

▶ Live. Press a button to hand in the other word. ▶ ภาพสด กดปุ่มเพื่อส่งอีกคำหนึ่งเข้าไป ▶ 实时演示。按按钮,把另一个词传进去。

📄 NEW FILE · fish.py

"""fish.py - one class, two animals. The word you hand in picks which one.

    python fish.py

Move the mouse over the window. A fish swims to it.
In main(), change "fish" to "snake" and run it again.
"""
import math
import tkinter as tk

WIDE = 800                # the window, in pixels
HIGH = 600
SPEED = 4                 # pixels the head moves in one frame
TURN = 0.15               # the most the head can turn in one frame
NEAR = 30                 # the head stops when it is this close to the mouse
BEND = math.pi / 8        # the most one joint can bend: 22.5 degrees
QUARTER = math.pi / 2     # a quarter turn. Python measures angles in radians.
WATER = "#12263a"         # the colour behind everything
FIN = "#81c3d7"           # the colour of the fins

# How far the skin is from the spine at each point, from the head to the tail.
# There is one number for each point, so each list also says how long the animal is.
SNAKE = [18, 20, 17, 16, 16, 16, 16, 16, 16, 16, 16, 15, 15, 15,
         14, 14, 13, 13, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4]
FISH = [20, 25, 27, 27, 25, 21, 17, 12, 9, 6]


def angle_to(start, end):
    """The direction you face when you stand at start and look at end."""
    return math.atan2(end[1] - start[1], end[0] - start[0])


def walk(start, angle, far):
    """Stand at start, face angle, walk far pixels. Give back where you are."""
    return [start[0] + math.cos(angle) * far, start[1] + math.sin(angle) * far]


def limit(angle, middle, most):
    """Give back angle, or the nearest angle that is within most of middle."""
    # How far angle is from middle, going the short way round the circle.
    # The answer is from -pi to pi: minus is one way round, plus is the other.
    away = (angle - middle + math.pi) % (2 * math.pi) - math.pi
    if away > most:
        return middle + most
    if away < -most:
        return middle - most
    return angle


def oval(centre, angle, long, wide):
    """Twelve points round the edge of an oval. Its long side points along angle."""
    edge = []
    for k in range(12):
        around = k * math.pi / 6        # twelve of these make one full turn
        # Go along the oval's long side, then out to the edge.
        along = walk(centre, angle, math.cos(around) * long)
        edge.append(walk(along, angle + QUARTER, math.sin(around) * wide))
    return edge


class Animal:
    def __init__(self, kind):
        self.kind = kind                # draw() reads this later
        if kind == "snake":
            self.widths = SNAKE
            self.gap = 14               # pixels from one point of the spine to the next
            self.colour = "#ee6c4d"
        if kind == "fish":
            self.widths = FISH
            self.gap = 20
            self.colour = "#3a7ca5"
        self.root = tk.Tk()
        self.root.title(kind)
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2, HIGH / 2]
        # Start as a straight line. Point 0 is the head, in the middle of the window.
        self.points = []
        self.angles = []      # which way the body faces at each point
        for i in range(len(self.widths)):
            self.points.append([WIDE / 2 - i * self.gap, HIGH / 2])
            self.angles.append(0)       # 0 is "facing right"

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def move(self):
        head = self.points[0]
        if math.dist(head, self.mouse) < NEAR:
            return            # it has arrived, so it waits for the mouse to move
        # The head wants to face the mouse, but it can turn only TURN in one frame.
        wanted = angle_to(head, self.mouse)
        self.angles[0] = limit(wanted, self.angles[0], TURN)
        self.points[0] = walk(head, self.angles[0], SPEED)
        for i in range(1, len(self.widths)):
            # Each point wants to face the point in front of it, within BEND.
            wanted = angle_to(self.points[i], self.points[i - 1])
            self.angles[i] = limit(wanted, self.angles[i - 1], BEND)
            # Then it stands one gap behind that point. Minus is a walk backwards.
            self.points[i] = walk(self.points[i - 1], self.angles[i], -self.gap)

    def outline(self):
        """The points of the skin, in order: down one side and back up the other."""
        left = []
        right = []
        for i in range(len(self.widths)):
            # A quarter turn from "forward" is "sideways". Walk out to the skin.
            wide = self.widths[i]
            left.append(walk(self.points[i], self.angles[i] - QUARTER, wide))
            right.append(walk(self.points[i], self.angles[i] + QUARTER, wide))
        right.reverse()       # the right side runs from the tail back to the head
        nose = walk(self.points[0], self.angles[0], self.widths[0])
        return [nose] + left + right

    def shape(self, edge, colour):
        # smooth=True rounds every corner, so the points become one curved shape.
        self.canvas.create_polygon(edge, fill=colour, outline="white",
                                   width=3, smooth=True)

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def fin(self, spot, side, long, wide):
        """One fin on the skin beside point spot. side is -1 for left, 1 for right."""
        sideways = self.angles[spot] + side * QUARTER
        centre = walk(self.points[spot], sideways, self.widths[spot])
        # The fin leans out and back. It copies the angle of the point in front
        # of it, so it starts to turn a moment before the body under it does.
        lean = self.angles[spot - 1] + side * math.pi / 4
        self.shape(oval(centre, lean, long, wide), FIN)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        if self.kind == "fish":
            # The fins go on first, so the body covers the place where they join.
            self.fin(3, -1, 26, 10)
            self.fin(3, 1, 26, 10)
            self.fin(7, -1, 15, 5)
            self.fin(7, 1, 15, 5)
            # The tail is one more oval. It sits behind the last point of the spine.
            tail = len(self.widths) - 1
            centre = walk(self.points[tail], self.angles[tail], -16)
            self.shape(oval(centre, self.angles[tail], 20, 5), FIN)
        self.shape(self.outline(), self.colour)
        if self.kind == "fish":
            # The fin on its back goes on after the body, so it sits on top.
            self.shape(oval(self.points[4], self.angles[4], 22, 4), FIN)
        # The eyes. 1.2 is most of a quarter turn (1.57), so one sits on each side.
        head = self.points[0]
        facing = self.angles[0]
        far = self.widths[0] / 2
        self.circle(walk(head, facing - 1.2, far), 3, "white", "white")
        self.circle(walk(head, facing + 1.2, far), 3, "white", "white")

    def frame(self):
        self.move()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    animal = Animal("fish")
    animal.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
self.kind = kindKeep the word. draw reads it later.เก็บคำไว้ draw จะอ่านมันทีหลัง把这个词留着。draw 之后要读它。
if kind == "fish": self.widths = FISHThe word picks the widths, the gap and the colour.คำนั้นเลือกความกว้าง ระยะห่าง และสี这个词决定宽度、间距和颜色。
around = k * math.pi / 6Twelve equal steps round one full turn.สิบสองก้าวเท่า ๆ กัน รอบหนึ่งรอบเต็ม绕一整圈,分成相等的十二步。
lean = self.angles[spot - 1] + side * math.pi / 4The angle of the point in front, plus 45 degrees out to the side.มุมของจุดที่อยู่ข้างหน้า บวกอีก 45 องศาออกไปด้านข้าง前面那个点的角度,再往外侧偏 45 度。
animal = Animal("fish")Build it into a name. Then call animal.run() on that name.สร้างแล้วเก็บไว้ในชื่อหนึ่ง จากนั้นเรียก animal.run() กับชื่อนั้น造出来,放进一个名字里。然后对这个名字调用 animal.run()。
A WORD THE CLASS DOES NOT KNOW Type Animal("fsh") and Python stops with AttributeError: 'Animal' object has no attribute 'widths'. No if matched, so nothing set self.widths. The error names the missing thing, not the wrong word. Check the word first. ถ้าพิมพ์ Animal("fsh") Python จะหยุดพร้อมข้อความ AttributeError: 'Animal' object has no attribute 'widths' เพราะไม่มี if ข้อไหนตรง จึงไม่มีอะไรตั้งค่า self.widths ข้อความบอกชื่อสิ่งที่หายไป ไม่ได้บอกว่าคำผิด ให้ตรวจคำก่อน 输入 Animal("fsh"),Python 会停下来,说 AttributeError: 'Animal' object has no attribute 'widths'。没有一个 if 对得上,所以没有人给 self.widths 赋值。错误说的是缺了什么,不是哪个词写错了。先检查那个词。
WHAT THE VIDEO DOES BETTER In the video, the tail fin and the back fin get wider when the fish bends. It adds up the bend of every joint to do that. Here they are plain ovals, to keep the file short. His Java is in Fish.pde, if you want to try it. ในวิดีโอ ครีบหางกับครีบหลังจะกว้างขึ้นเมื่อปลางอตัว เขาเอาการงอของทุกข้อต่อมาบวกกันเพื่อทำแบบนั้น ส่วนที่นี่เป็นวงรีธรรมดา เพื่อให้ไฟล์สั้น โค้ด Java ของเขาอยู่ใน Fish.pde ถ้าคุณอยากลอง 在视频里,鱼一弯身,尾鳍和背鳍就变宽。他把每个关节弯了多少加在一起来做到这一点。这里只用普通的椭圆,为了让文件短一些。想试的话,他的 Java 代码在 Fish.pde。
YOUR TURN
  1. In main, change "fish" to "snake". Run it again.
  2. Change the numbers in FISH. Make a whale.
  3. Change 26 to 50 in the two big fins. Wings.
  4. Add a third pair of fins at point 5.
  1. ใน main เปลี่ยน "fish" เป็น "snake" แล้วรันอีกครั้ง
  2. เปลี่ยนตัวเลขใน FISH ทำให้เป็นวาฬ
  3. เปลี่ยน 26 เป็น 50 ในครีบใหญ่สองอัน จะได้ปีก
  4. เพิ่มครีบคู่ที่สามที่จุดที่ 5
  1. 在 main 里,把 "fish" 改成 "snake"。再运行一次。
  2. 改 FISH 里的数字。做一头鲸鱼。
  3. 把两片大鳍里的 26 改成 50。成了翅膀。
  4. 在第 5 个点上加第三对鳍。

🦎 Tutorial 4 — Legs · บทที่ 4 — ขา · 教程 4 —— 腿

🇬🇧 English

A leg is a chain too. But this chain is held at both ends, and that needs one more idea.

🇹🇭 ไทย

ขาก็เป็นโซ่เหมือนกัน แต่โซ่นี้ถูกยึดไว้ทั้งสองปลาย จึงต้องมีแนวคิดเพิ่มอีกหนึ่งอย่าง

🇨🇳 中文

腿也是一条链。但这条链两头都被固定住了,所以还需要一个新想法。

6 An arm that reaches · แขนที่เอื้อมไปหา · 会伸过去的手臂

🇬🇧 English

The chain in step 2 had one fixed end: the head. An arm has two. The shoulder must stay on the body. The hand must go to the target.

The trick is to pull twice. First put the hand on the target and pull the chain after it. Now the shoulder is off its place. So put the shoulder back and pull the chain after it, the other way. Now the hand is a little off the target.

Do it again and the hand is closer. Each time is better. This is called FABRIK. Here it is one function, reach, and all it does is call pull.

🇹🇭 ไทย

โซ่ในขั้นที่ 2 มีปลายที่ถูกยึดอยู่ปลายเดียว คือหัว แต่แขนมีสองปลาย ไหล่ต้องอยู่ติดกับลำตัว และมือต้องไปที่เป้าหมาย

เคล็ดลับคือดึงสองครั้ง ครั้งแรกวางมือลงบนเป้าหมาย แล้วดึงโซ่ตามมา ตอนนี้ไหล่หลุดจากที่ของมัน จึงวางไหล่กลับที่เดิม แล้วดึงโซ่ตามมาอีกทางหนึ่ง ตอนนี้มือห่างจากเป้าหมายนิดหน่อย

ทำอีกครั้ง มือก็เข้าใกล้ขึ้น ทุกครั้งดีขึ้น วิธีนี้เรียกว่า FABRIK ที่นี่มันคือฟังก์ชันเดียวชื่อ reach และมันทำแค่เรียก pull

🇨🇳 中文

第 2 步的链只有一头是固定的:头。手臂有两头。肩膀必须留在身体上,手必须去到目标那里。

办法是拉两次。先把手放到目标上,拉着整条链跟过去。这时肩膀离开了原位。于是把肩膀放回去,再从另一头拉着链跟过来。这时手又离目标差了一点点。

再做一遍,手就更近。一次比一次好。这个方法叫 FABRIK。在这里它是一个函数 reach,它做的只是调用 pull。

▶ Live. The ring is the shoulder. Move far away, and the arm stretches out straight toward you. ▶ ภาพสด วงแหวนคือไหล่ ลองเลื่อนไปไกล ๆ แขนจะเหยียดตรงชี้มาหาคุณ ▶ 实时演示。圆环是肩膀。把鼠标移远,手臂会伸直了指向你。

📄 NEW FILE · arm.py

"""arm.py - an arm that reaches for the mouse. Its shoulder never moves.

    python arm.py

Move the mouse over the window. The hand goes to the mouse if the arm is long
enough. If the mouse is too far, the arm points at it and stretches out straight.
"""
import math
import tkinter as tk

WIDE = 800           # the window, in pixels
HIGH = 600
BONE = 90            # how long each bone of the arm is
JOINTS = 4           # a shoulder, two elbows and a hand: three bones
WATER = "#12263a"    # the colour behind everything


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


def reach(joints, start, end, bone):
    """Bend the chain of joints so it starts at start and reaches toward end."""
    last = len(joints) - 1
    # Forward: put the last joint on the target, then pull the others after it.
    joints[last] = end
    for i in reversed(range(last)):             # count down: last - 1, ..., 1, 0
        joints[i] = pull(joints[i + 1], joints[i], bone)
    # That dragged the first joint off its place. Put it back.
    # Backward: pull the others after it again, this time from the other end.
    joints[0] = start
    for i in range(1, last + 1):
        joints[i] = pull(joints[i - 1], joints[i], bone)


class Arm:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("arm")
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2 + 200, HIGH / 2]
        self.shoulder = [WIDE / 2, HIGH / 2]
        # Start as a straight line, going up from the shoulder.
        self.joints = []
        for i in range(JOINTS):
            self.joints.append([WIDE / 2, HIGH / 2 - i * BONE])

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        self.canvas.create_line(self.joints, fill="#52796f", width=16,
                                capstyle="round", joinstyle="round")
        for joint in self.joints:
            self.circle(joint, 5, "white", "white")
        self.circle(self.shoulder, 11, "", "white")       # a ring round the shoulder
        self.circle(self.mouse, 5, "#ee6c4d", "#ee6c4d")

    def frame(self):
        # One reach each frame is enough. The next frame starts from this
        # answer and makes it better, so after a few frames it is right.
        reach(self.joints, self.shoulder, self.mouse, BONE)
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    arm = Arm()
    arm.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
joints[last] = endPut the hand on the target. For a moment the last bone is the wrong length.วางมือลงบนเป้าหมาย ชั่วขณะหนึ่งกระดูกท่อนสุดท้ายจะยาวไม่ถูก把手放到目标上。这一刻最后一根骨头的长度是不对的。
for i in reversed(range(last)):Count down, from the joint before the hand to the shoulder.นับถอยหลัง จากข้อต่อก่อนถึงมือ ไปจนถึงไหล่倒着数,从手前面那个关节数到肩膀。
joints[0] = startPut the shoulder back where it belongs.วางไหล่กลับไปยังที่ของมัน把肩膀放回它该在的地方。
reach(self.joints, self.shoulder, self.mouse, BONE)reach changes the list you hand it. It gives nothing back.reach แก้ลิสต์ที่คุณส่งให้มันโดยตรง มันไม่ส่งอะไรกลับมาreach 直接改你传给它的那个列表,不返回任何东西。
YOU GET An arm that finds its own elbows. In one test the hand was 10.9 pixels from the target after one reach. It was 1.4 after three, and 0.06 after six. One call in each frame is enough. แขนที่หาตำแหน่งข้อศอกของมันเอง ในการทดสอบครั้งหนึ่ง หลังเรียก reach หนึ่งครั้ง มือห่างจากเป้าหมาย 10.9 พิกเซล หลังสามครั้งเหลือ 1.4 และหลังหกครั้งเหลือ 0.06 เรียกเฟรมละครั้งก็พอ 一条会自己摆好手肘的手臂。在一次测试里,调用一次 reach 后,手离目标 10.9 像素;三次后是 1.4;六次后是 0.06。每帧调用一次就够了。
YOUR TURN
  1. Change JOINTS to 10 and BONE to 30. A tentacle.
  2. Change JOINTS to 2. Why can it not reach now?
  3. Swap the two halves of reach. What comes off?
  1. เปลี่ยน JOINTS เป็น 10 และ BONE เป็น 30 จะได้หนวดปลาหมึก
  2. เปลี่ยน JOINTS เป็น 2 ทำไมตอนนี้มันเอื้อมไม่ถึง
  3. สลับสองครึ่งของ reach อะไรหลุดออก
  1. 把 JOINTS 改成 10,BONE 改成 30。一条触手。
  2. 把 JOINTS 改成 2。为什么现在够不着了?
  3. 把 reach 的前后两半对调。什么东西脱开了?

7 Four legs that walk · สี่ขาที่เดินได้ · 会走路的四条腿

🇬🇧 English

Now put it all together. A lizard is a spine, a body and four small arms.

Each leg has a hip on the body, at point 3 or point 7 of the spine. Each foot has a place on the ground. The foot stays there while the body walks on.

In every frame we ask one question. Where would the next step land? It is a spot out to the side and in front of the hip. When the foot is more than 60 pixels behind that spot, the foot gets the new spot. That is a step.

The foot does not jump there. It goes four tenths of the way in each frame. Then reach bends the leg from the hip to the foot.

Two legs start half a step behind. So the legs take turns: front left with back right, then the other two.

A lizard's back is stiffer than a snake's. Each of its joints may bend only STIFF, 11.25 degrees, half of BEND. With BEND, a tight turn curled the body round on itself, and the legs on the inside crossed over it.

🇹🇭 ไทย

ทีนี้เอาทุกอย่างมารวมกัน กิ้งก่าคือสันหลัง ลำตัว และแขนเล็ก ๆ สี่ข้าง

ขาแต่ละข้างมีสะโพกอยู่บนลำตัว ที่จุดที่ 3 หรือจุดที่ 7 ของสันหลัง เท้าแต่ละข้างมีที่ของมันบนพื้น เท้าอยู่ตรงนั้นขณะที่ลำตัวเดินต่อไป

ทุกเฟรมเราถามคำถามเดียว ก้าวต่อไปจะลงตรงไหน คำตอบคือจุดที่อยู่ด้านข้างและด้านหน้าของสะโพก เมื่อเท้าอยู่ข้างหลังจุดนั้นเกิน 60 พิกเซล เท้าก็ได้จุดใหม่ นั่นคือหนึ่งก้าว

เท้าไม่ได้กระโดดไปที่นั่น มันไปสี่ในสิบของระยะทางในแต่ละเฟรม จากนั้น reach งอขาจากสะโพกไปถึงเท้า

ขาสองข้างเริ่มต้นช้ากว่าครึ่งก้าว ขาจึงผลัดกันก้าว คือหน้าซ้ายคู่กับหลังขวา แล้วค่อยเป็นอีกสองข้าง

หลังของกิ้งก่าแข็งกว่าหลังของงู ข้อต่อแต่ละข้องอได้แค่ STIFF คือ 11.25 องศา ครึ่งหนึ่งของ BEND ถ้าใช้ BEND เวลาเลี้ยวแคบ ๆ ลำตัวจะม้วนเข้าหาตัวเอง และขาข้างในก็พาดทับลำตัว

🇨🇳 中文

现在把所有东西合在一起。蜥蜴就是一根脊柱、一个身体,再加四条小手臂。

每条腿在身体上有一个髋部,在脊柱的第 3 个点或第 7 个点上。每只脚在地上有一个位置。身体往前走的时候,脚留在那里不动。

每一帧我们只问一个问题:下一步会落在哪?那是髋部侧前方的一个点。当脚落后那个点超过 60 像素,脚就得到这个新位置。这就是迈了一步。

脚不是一下子跳过去的。它每帧走全程的十分之四。然后 reach 把腿从髋部弯到脚上。

有两条腿一开始就落后半步。所以腿是轮流迈的:左前和右后一起,然后是另外两条。

蜥蜴的背比蛇的硬。它的每个关节最多只能弯 STIFF,也就是 11.25 度,是 BEND 的一半。如果用 BEND,急转弯时身体会卷回到自己身上,内侧的腿也会压过身体。

▶ Live. Watch one foot. It stays on the ground, then steps. ▶ ภาพสด ลองมองเท้าข้างเดียว มันอยู่บนพื้น แล้วค่อยก้าว ▶ 实时演示。盯着一只脚看。它先留在地上,然后迈一步。

📄 NEW FILE · lizard.py

"""lizard.py - one class, three animals. The lizard has four legs that walk.

    python lizard.py

Move the mouse over the window. A lizard walks to it.
In main(), change "lizard" to "fish" or "snake" and run it again.
"""
import math
import tkinter as tk

WIDE = 800                # the window, in pixels
HIGH = 600
SPEED = 4                 # pixels the head moves in one frame
TURN = 0.15               # the most the head can turn in one frame
NEAR = 30                 # the head stops when it is this close to the mouse
BEND = math.pi / 8        # the most one joint can bend: 22.5 degrees
STIFF = math.pi / 16      # a lizard's back is stiffer: 11.25 degrees, so it cannot curl up
QUARTER = math.pi / 2     # a quarter turn. Python measures angles in radians.
BONE = 24                 # how long each of the two bones in a leg is
REACH = 36                # how far from the hip a new step lands
STEP = 60                 # a foot takes a step when it is this far behind
WATER = "#12263a"         # the colour behind everything
FIN = "#81c3d7"           # the colour of the fins

# How far the skin is from the spine at each point, from the head to the tail.
# There is one number for each point, so each list also says how long the animal is.
SNAKE = [18, 20, 17, 16, 16, 16, 16, 16, 16, 16, 16, 15, 15, 15,
         14, 14, 13, 13, 12, 12, 11, 10, 9, 8, 7, 6, 5, 4]
FISH = [20, 25, 27, 27, 25, 21, 17, 12, 9, 6]
LIZARD = [17, 19, 13, 20, 22, 23, 21, 16, 9, 6, 5, 4, 3, 3]


def angle_to(start, end):
    """The direction you face when you stand at start and look at end."""
    return math.atan2(end[1] - start[1], end[0] - start[0])


def walk(start, angle, far):
    """Stand at start, face angle, walk far pixels. Give back where you are."""
    return [start[0] + math.cos(angle) * far, start[1] + math.sin(angle) * far]


def limit(angle, middle, most):
    """Give back angle, or the nearest angle that is within most of middle."""
    # How far angle is from middle, going the short way round the circle.
    # The answer is from -pi to pi: minus is one way round, plus is the other.
    away = (angle - middle + math.pi) % (2 * math.pi) - math.pi
    if away > most:
        return middle + most
    if away < -most:
        return middle - most
    return angle


def oval(centre, angle, long, wide):
    """Twelve points round the edge of an oval. Its long side points along angle."""
    edge = []
    for k in range(12):
        around = k * math.pi / 6        # twelve of these make one full turn
        # Go along the oval's long side, then out to the edge.
        along = walk(centre, angle, math.cos(around) * long)
        edge.append(walk(along, angle + QUARTER, math.sin(around) * wide))
    return edge


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


def reach(joints, start, end, bone):
    """Bend the chain of joints so it starts at start and reaches toward end."""
    last = len(joints) - 1
    # Forward: put the last joint on the target, then pull the others after it.
    joints[last] = end
    for i in reversed(range(last)):             # count down: last - 1, ..., 1, 0
        joints[i] = pull(joints[i + 1], joints[i], bone)
    # That dragged the first joint off its place. Put it back.
    # Backward: pull the others after it again, this time from the other end.
    joints[0] = start
    for i in range(1, last + 1):
        joints[i] = pull(joints[i - 1], joints[i], bone)


class Animal:
    def __init__(self, kind):
        self.kind = kind                # draw() reads this later
        if kind == "snake":
            self.widths = SNAKE
            self.gap = 14               # pixels from one point of the spine to the next
            self.colour = "#ee6c4d"
            self.bend = BEND            # the most one joint of the spine can bend
        if kind == "fish":
            self.widths = FISH
            self.gap = 20
            self.colour = "#3a7ca5"
            self.bend = BEND
        if kind == "lizard":
            self.widths = LIZARD
            self.gap = 20
            self.colour = "#52796f"
            self.bend = STIFF
        self.root = tk.Tk()
        self.root.title(kind)
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE / 2, HIGH / 2]
        # Start as a straight line. Point 0 is the head, in the middle of the window.
        self.points = []
        self.angles = []      # which way the body faces at each point
        for i in range(len(self.widths)):
            self.points.append([WIDE / 2 - i * self.gap, HIGH / 2])
            self.angles.append(0)       # 0 is "facing right"
        if kind == "lizard":
            # Only the lizard has legs. Each leg is: which point of the spine it
            # joins, which side (-1 left, 1 right), and how far back its foot starts.
            self.legs = []
            self.add_leg(3, -1, 0)
            self.add_leg(3, 1, STEP / 2)    # half a step behind, so the legs take turns
            self.add_leg(7, -1, STEP / 2)
            self.add_leg(7, 1, 0)

    def add_leg(self, spot, side, behind):
        leg = {"spot": spot, "side": side}
        hip, ahead = self.leg_places(leg)
        # The place on the ground where the foot stands. It stays there until a step.
        leg["ground"] = walk(ahead, self.angles[spot], -behind)
        # Hip, knee, foot. The knee starts on the hip, and reach() moves it.
        leg["joints"] = [hip, hip, leg["ground"]]
        self.legs.append(leg)

    def leg_places(self, leg):
        """Where this leg's hip is now, and where its next step will land."""
        spot = leg["spot"]
        facing = self.angles[spot]
        sideways = facing + leg["side"] * QUARTER
        hip = walk(self.points[spot], sideways, self.widths[spot] - 6)
        # The next step lands out to the side and in front: half a quarter turn.
        ahead = walk(hip, facing + leg["side"] * QUARTER / 2, REACH)
        return hip, ahead

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]

    def move(self):
        head = self.points[0]
        if math.dist(head, self.mouse) < NEAR:
            return            # it has arrived, so it waits for the mouse to move
        # The head wants to face the mouse, but it can turn only TURN in one frame.
        wanted = angle_to(head, self.mouse)
        self.angles[0] = limit(wanted, self.angles[0], TURN)
        self.points[0] = walk(head, self.angles[0], SPEED)
        for i in range(1, len(self.widths)):
            # Each point wants to face the point in front of it, within self.bend.
            wanted = angle_to(self.points[i], self.points[i - 1])
            self.angles[i] = limit(wanted, self.angles[i - 1], self.bend)
            # Then it stands one gap behind that point. Minus is a walk backwards.
            self.points[i] = walk(self.points[i - 1], self.angles[i], -self.gap)

    def move_legs(self):
        for leg in self.legs:
            hip, ahead = self.leg_places(leg)
            # The body walks on and the foot stays. When the foot is too far
            # behind, it gets a new place on the ground: that is one step.
            if math.dist(leg["ground"], ahead) > STEP:
                leg["ground"] = ahead
            # The foot does not jump. Each frame it goes 4 tenths of the way there.
            ground = leg["ground"]
            foot = leg["joints"][2]
            x = foot[0] + (ground[0] - foot[0]) * 0.4
            y = foot[1] + (ground[1] - foot[1]) * 0.4
            reach(leg["joints"], hip, [x, y], BONE)

    def outline(self):
        """The points of the skin, in order: down one side and back up the other."""
        left = []
        right = []
        for i in range(len(self.widths)):
            # A quarter turn from "forward" is "sideways". Walk out to the skin.
            wide = self.widths[i]
            left.append(walk(self.points[i], self.angles[i] - QUARTER, wide))
            right.append(walk(self.points[i], self.angles[i] + QUARTER, wide))
        right.reverse()       # the right side runs from the tail back to the head
        nose = walk(self.points[0], self.angles[0], self.widths[0])
        return [nose] + left + right

    def shape(self, edge, colour):
        # smooth=True rounds every corner, so the points become one curved shape.
        self.canvas.create_polygon(edge, fill=colour, outline="white",
                                   width=3, smooth=True)

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def fin(self, spot, side, long, wide):
        """One fin on the skin beside point spot. side is -1 for left, 1 for right."""
        sideways = self.angles[spot] + side * QUARTER
        centre = walk(self.points[spot], sideways, self.widths[spot])
        # The fin leans out and back. It copies the angle of the point in front
        # of it, so it starts to turn a moment before the body under it does.
        lean = self.angles[spot - 1] + side * math.pi / 4
        self.shape(oval(centre, lean, long, wide), FIN)

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        if self.kind == "fish":
            # The fins go on first, so the body covers the place where they join.
            self.fin(3, -1, 26, 10)
            self.fin(3, 1, 26, 10)
            self.fin(7, -1, 15, 5)
            self.fin(7, 1, 15, 5)
            # The tail is one more oval. It sits behind the last point of the spine.
            tail = len(self.widths) - 1
            centre = walk(self.points[tail], self.angles[tail], -16)
            self.shape(oval(centre, self.angles[tail], 20, 5), FIN)
        if self.kind == "lizard":
            # The legs go on first too. Each is a thick white line with a
            # thinner green line on top, so it looks like it has a white edge.
            for leg in self.legs:
                self.canvas.create_line(leg["joints"], fill="white", width=14,
                                        capstyle="round", joinstyle="round")
                self.canvas.create_line(leg["joints"], fill=self.colour, width=8,
                                        capstyle="round", joinstyle="round")
        self.shape(self.outline(), self.colour)
        if self.kind == "fish":
            # The fin on its back goes on after the body, so it sits on top.
            self.shape(oval(self.points[4], self.angles[4], 22, 4), FIN)
        # The eyes. 1.2 is most of a quarter turn (1.57), so one sits on each side.
        head = self.points[0]
        facing = self.angles[0]
        far = self.widths[0] / 2
        self.circle(walk(head, facing - 1.2, far), 3, "white", "white")
        self.circle(walk(head, facing + 1.2, far), 3, "white", "white")

    def frame(self):
        self.move()
        if self.kind == "lizard":
            self.move_legs()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    animal = Animal("lizard")
    animal.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
if kind == "lizard": self.legs = []Only the lizard builds legs. A fish has no legs list at all.มีแค่กิ้งก่าที่สร้างขา ปลาไม่มีลิสต์ legs เลย只有蜥蜴才造腿。鱼根本没有 legs 这个列表。
leg = {"spot": spot, "side": side}A leg is a small dictionary: a few things, each with a name.ขาคือดิกชันนารีเล็ก ๆ คือของไม่กี่อย่าง แต่ละอย่างมีชื่อ一条腿是一个小字典:几样东西,每样有个名字。
hip, ahead = self.leg_places(leg)The method gives back two things. This line gives each one a name.เมธอดส่งของกลับมาสองอย่าง บรรทัดนี้ตั้งชื่อให้แต่ละอย่าง这个方法返回两样东西。这一行给每样起一个名字。
if math.dist(leg["ground"], ahead) > STEP:Is the foot too far behind? Then take a step.เท้าอยู่ข้างหลังไกลเกินไปไหม ถ้าใช่ก็ก้าว脚落后太多了吗?那就迈一步。
x = foot[0] + (ground[0] - foot[0]) * 0.4Go four tenths of the way from where the foot is to where it should be.ไปสี่ในสิบของระยะทาง จากที่ที่เท้าอยู่ ไปยังที่ที่มันควรอยู่从脚现在的位置,朝它该去的位置走十分之四。
YOU GET A lizard that walks, and nobody drew one step. On a straight walk, two legs step at frames 15, 31 and 47. The other two step at frames 7, 23 and 39. That was counted from this file. กิ้งก่าที่เดินได้ โดยไม่มีใครวาดสักก้าว เมื่อเดินตรง ขาสองข้างก้าวที่เฟรม 15, 31 และ 47 อีกสองข้างก้าวที่เฟรม 7, 23 และ 39 ตัวเลขนี้นับจากไฟล์นี้จริง 一只会走路的蜥蜴,而没有人画过一步。走直线时,两条腿在第 15、31、47 帧迈步,另外两条在第 7、23、39 帧迈步。这是从这个文件里数出来的。
YOUR TURN
  1. In main, change the word to "fish", then "snake".
  2. Change STEP to 20. Then to 75.
  3. Give all four legs 0 for the last number. Now it hops.
  4. Add a third pair of legs at point 5.
  5. Add a new word, "worm", with your own widths.
  1. ใน main เปลี่ยนคำเป็น "fish" แล้วลอง "snake"
  2. เปลี่ยน STEP เป็น 20 แล้วลอง 75
  3. ใส่ 0 เป็นเลขตัวสุดท้ายให้ขาทั้งสี่ ตอนนี้มันจะกระโดด
  4. เพิ่มขาคู่ที่สามที่จุดที่ 5
  5. เพิ่มคำใหม่ "worm" พร้อมความกว้างของคุณเอง
  1. 在 main 里,把那个词改成 "fish",再改成 "snake"。
  2. 把 STEP 改成 20,再改成 75。
  3. 把四条腿的最后一个数都写成 0。现在它是跳着走的。
  4. 在第 5 个点上加第三对腿。
  5. 加一个新词 "worm",用你自己的宽度。

🧠 Tutorial 5 — An animal that decides · บทที่ 5 — สัตว์ที่ตัดสินใจเอง · 教程 5 —— 会自己做决定的动物

🇬🇧 English

The lizard walks. But your mouse still leads it. Now the lizard decides for itself. An AI model picks its moves.

This part is not in the video. It is the next step: from an animation to a small game.

The model cannot see your window. It can only read. So the program does three jobs, again and again. It says what the animal sees, in words. It asks the model. Then it moves the body. A program like this has a name: an agent.

The model is called decider-2b. It is small and free. It runs on one small computer in Chiang Mai.

🇹🇭 ไทย

กิ้งก่าเดินได้แล้ว แต่เมาส์ของคุณยังเป็นคนนำมันอยู่ ทีนี้กิ้งก่าจะตัดสินใจเอง โดยมีโมเดล AI เป็นผู้เลือกท่าให้

ส่วนนี้ไม่มีในวิดีโอ มันคือก้าวต่อไป จากแอนิเมชันไปสู่เกมเล็ก ๆ

โมเดลมองไม่เห็นหน้าต่างของคุณ มันอ่านได้อย่างเดียว โปรแกรมจึงทำงานสามอย่าง วนซ้ำไปเรื่อย ๆ คือบอกเป็นคำว่าสัตว์เห็นอะไร ถามโมเดล แล้วขยับลำตัว โปรแกรมแบบนี้มีชื่อเรียกว่า เอเจนต์

โมเดลนี้ชื่อ decider-2b เป็นโมเดลเล็กและใช้ฟรี มันรันอยู่บนคอมพิวเตอร์เครื่องเล็ก ๆ เครื่องหนึ่งในเชียงใหม่

🇨🇳 中文

蜥蜴会走了,可是带路的还是你的鼠标。现在让蜥蜴自己做决定:由一个 AI 模型来挑它的动作。

这一部分视频里没有。它是下一步:从动画走到一个小游戏。

模型看不见你的窗口,它只会读文字。所以程序一遍又一遍地做三件事:用文字说出动物看到了什么,去问模型,然后移动身体。这样的程序有个名字,叫智能体。

这个模型叫 decider-2b。它很小,而且免费。它运行在清迈的一台小电脑上。

THESE TWO FILES NEED THE INTERNET Each question travels over the internet to that computer. Sometimes the computer is off. Then the file says no answer, and nothing breaks. Try again later. คำถามแต่ละข้อเดินทางผ่านอินเทอร์เน็ตไปยังคอมพิวเตอร์เครื่องนั้น บางครั้งเครื่องนั้นปิดอยู่ ไฟล์จะบอกว่า no answer และไม่มีอะไรพัง ค่อยลองใหม่ทีหลัง 每个问题都要经过互联网送到那台电脑。有时候那台电脑没有开。这时文件会说 no answer,什么都不会坏。过一会儿再试。

8 Ask a model one question · ถามโมเดลหนึ่งคำถาม · 问模型一个问题

🇬🇧 English

This file opens no window. It sends one question and prints the answer.

The question has three parts. WHO says who the animal is. RULES says what it must do. MOVES lists the moves, with one sentence for each.

Then comes what the gecko sees. That is the only part that changes.

The model sends back one move. It also sends a number for each move. The numbers add up to 1. A big number means the model is sure.

🇹🇭 ไทย

ไฟล์นี้ไม่เปิดหน้าต่าง มันส่งคำถามหนึ่งข้อ แล้วพิมพ์คำตอบออกมา

คำถามมีสามส่วน WHO บอกว่าสัตว์ตัวนี้คือใคร RULES บอกว่ามันต้องทำอะไร MOVES คือรายการท่า พร้อมประโยคหนึ่งประโยคสำหรับแต่ละท่า

จากนั้นคือสิ่งที่ตุ๊กแกเห็น เป็นส่วนเดียวที่เปลี่ยนไปทุกครั้ง

โมเดลส่งกลับมาหนึ่งท่า และส่งตัวเลขของแต่ละท่ามาด้วย ตัวเลขทั้งหมดรวมกันได้ 1 ตัวเลขมากแปลว่าโมเดลมั่นใจ

🇨🇳 中文

这个文件不开窗口。它发出一个问题,再把回答打印出来。

问题有三个部分。WHO 说这只动物是谁。RULES 说它必须怎么做。MOVES 列出所有动作,每个动作配一句话。

接着是壁虎看到了什么。只有这一部分每次都在变。

模型送回一个动作,还给每个动作送回一个数字。这些数字加起来是 1。数字大,说明模型很有把握。

▶ Live. Press a sentence. It goes to the model with the rules. Then the answer comes back. This is the model that the file asks. ▶ ภาพสด กดประโยคหนึ่งประโยค มันถูกส่งไปที่โมเดลพร้อมกฎ แล้วคำตอบก็กลับมา นี่คือโมเดลตัวเดียวกับที่ไฟล์ถาม ▶ 实时演示。点一句话。它会连同规则一起发给模型,回答马上回来。这就是文件去问的那个模型。

💻 RUN IT

python ask.py

📄 NEW FILE · ask.py

"""ask.py - ask an AI model one question, and print its answer.

    python ask.py

The model reads a few sentences and picks one move. It runs on a small
computer in Chiang Mai, so this file needs the internet.
"""
import json
import urllib.request

MODEL = "https://spark-wav2lip-1.taila00da8.ts.net/gecko/v1/systemone"

# Who the animal is, and its rules. The model reads these every time.
WHO = ("You are a gecko in a rice field. You hunt a dragonfly. "
       "You can only catch a dragonfly that rests.")
RULES = ("If the dragonfly is flying, stay still. "
         "If it rests and is too far to jump at, creep closer. "
         "If it rests and is close enough, jump.")

# The moves the gecko can choose from, and what each one is for.
MOVES = {
    "wait": "Stay still. Do this when the dragonfly is flying.",
    "creep": "Creep closer. Do this when a resting dragonfly is too far to jump at.",
    "jump": "Jump at it. Do this when a resting dragonfly is close enough.",
}

# Three things the gecko might see.
SEES = [
    "The dragonfly is flying. It is too far to jump at.",
    "The dragonfly rests. It is too far to jump at.",
    "The dragonfly rests. It is close enough to jump at.",
]


def ask(sees):
    """Send the rules, what the gecko sees, and the moves. Give back the answer."""
    question = {"type": "choice",
                "instructions": "What should you do right now?",
                "criteria": MOVES}
    message = {"state": WHO + " Rules: " + RULES + "\n\nSituation: " + sees,
               "questions": {"move": question}}
    # The internet carries bytes. dumps() writes the dictionary as text,
    # and encode() turns that text into bytes.
    request = urllib.request.Request(MODEL, json.dumps(message).encode(),
                                     {"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=10) as reply:
        answer = json.load(reply)       # the other way: bytes back to a dictionary
    return answer["answers"]["move"]


def main():
    for sees in SEES:
        try:
            answer = ask(sees)
        except OSError as problem:
            # There is no internet, or the model's computer is off.
            print("No answer:", problem)
            return
        print(sees)
        print("   ", answer["choice"], answer["probabilities"])


if __name__ == "__main__":
    main()

🖨 WHAT IT PRINTED

The dragonfly is flying. It is too far to jump at.
    wait {'wait': 0.9129, 'creep': 0.086, 'jump': 0.0011}
The dragonfly rests. It is too far to jump at.
    creep {'wait': 0.0005, 'creep': 0.9981, 'jump': 0.0014}
The dragonfly rests. It is close enough to jump at.
    jump {'wait': 0.0005, 'creep': 0.0019, 'jump': 0.9976}
LineWhat it does · ทำอะไร · 做什么
"wait": "Stay still. Do this when ...",A move has a name and a sentence. The sentence says when to use it.ท่าหนึ่งท่ามีชื่อและมีประโยค ประโยคบอกว่าควรใช้ท่านี้เมื่อไร一个动作有名字,还有一句话。这句话说明什么时候用它。
WHO + " Rules: " + RULES + "\n\nSituation: " + seesOne long text: who it is, its rules, then what it sees now.ข้อความยาวหนึ่งก้อน: มันคือใคร กฎของมัน แล้วตามด้วยสิ่งที่มันเห็นตอนนี้一段长文字:它是谁、它的规则,然后是它现在看到的。
json.dumps(message).encode()Write the dictionary as text. Then turn the text into bytes.เขียนดิกชันนารีออกมาเป็นข้อความ แล้วแปลงข้อความเป็นไบต์把字典写成文字,再把文字变成字节。
with urllib.request.urlopen(request, timeout=10) as reply:Send it and wait for the reply. Stop waiting after 10 seconds.ส่งออกไปแล้วรอคำตอบ ถ้าเกิน 10 วินาทีก็เลิกรอ发出去,等回答。超过 10 秒就不等了。
except OSError as problem:The internet is off, or the model is off. Say so, and stop.อินเทอร์เน็ตใช้ไม่ได้ หรือโมเดลปิดอยู่ ก็บอกให้รู้ แล้วหยุด网络断了,或者模型没开。说一声,然后停下。
YOU GET Three questions and three answers. The model was sure each time: about 0.91, 1.00 and 1.00. Your numbers can differ a little. Over the internet, one answer took about 1 second here. คำถามสามข้อ คำตอบสามข้อ โมเดลมั่นใจทุกครั้ง คือประมาณ 0.91, 1.00 และ 1.00 ตัวเลขของคุณอาจต่างไปเล็กน้อย เมื่อส่งผ่านอินเทอร์เน็ต คำตอบหนึ่งข้อใช้เวลาประมาณ 1 วินาทีที่นี่ 三个问题,三个回答。模型每次都很有把握:大约 0.91、1.00 和 1.00。你的数字可能会有一点不同。经过互联网,在这里一个回答大约要 1 秒。
YOUR TURN
  1. Add a fourth sentence to SEES: "The dragonfly is flying. It is close enough to jump at."
  2. Make the rules empty: RULES = "". Now the gecko creeps at a flying dragonfly.
  3. Put the rules back. Take "creep" out of MOVES. Now it jumps from too far.
  4. Make a lazy gecko. In RULES, change creep closer to stay still. It still creeps. Now change the sentence for "creep" to "Creep closer. Never do this." Now it waits.
  5. Write WHO, RULES and MOVES for the dragonfly. Give it two moves, fly and rest.
  1. เพิ่มประโยคที่สี่ใน SEES: "The dragonfly is flying. It is close enough to jump at."
  2. ทำให้กฎว่างเปล่า: RULES = "" ตอนนี้ตุ๊กแกจะย่องเข้าหาแมลงปอที่กำลังบิน
  3. ใส่กฎกลับคืน แล้วเอา "creep" ออกจาก MOVES ตอนนี้มันจะกระโดดจากที่ไกลเกินไป
  4. สร้างตุ๊กแกขี้เกียจ ใน RULES เปลี่ยน creep closer เป็น stay still มันก็ยังย่องอยู่ ทีนี้เปลี่ยนประโยคของ "creep" เป็น "Creep closer. Never do this." ตอนนี้มันจะรอ
  5. เขียน WHO, RULES และ MOVES ให้แมลงปอ ให้มันมีสองท่า คือ fly กับ rest
  1. 在 SEES 里加第四句:"The dragonfly is flying. It is close enough to jump at."
  2. 把规则清空:RULES = ""。现在壁虎会朝正在飞的蜻蜓悄悄爬过去。
  3. 把规则放回去。从 MOVES 里拿掉 "creep"。现在它离得太远也跳。
  4. 做一只懒壁虎。在 RULES 里把 creep closer 改成 stay still。它还是会爬。再把 "creep" 的那句话改成 "Creep closer. Never do this." 现在它只等着。
  5. 给蜻蜓写 WHO、RULES 和 MOVES。给它两个动作:fly 和 rest。

9 A gecko that hunts your mouse · ตุ๊กแกที่ล่าเมาส์ของคุณ · 追捕你鼠标的壁虎

🇬🇧 English

Now give the lizard that question. The lizard is a gecko. Your mouse is a dragonfly.

Hold the mouse still for 20 frames. Then the dragonfly rests. Move the mouse and it flies.

Once in every 50 frames the gecko asks the model. sees writes what the gecko sees, as two short sentences. The model picks wait, creep or jump.

The rest is the maths you know. creep moves the head slowly. jump moves it fast, to the place where the dragonfly was. Is the dragonfly still there? Then the gecko catches it.

An answer takes time. The window must not stop and wait for it. So the question goes out in a second thread. The first thread keeps drawing.

🇹🇭 ไทย

ทีนี้เอาคำถามนั้นไปใส่ให้กิ้งก่า กิ้งก่าตัวนี้คือตุ๊กแก ส่วนเมาส์ของคุณคือแมลงปอ

ถือเมาส์นิ่ง ๆ ไว้ 20 เฟรม แมลงปอก็จะเกาะพัก ขยับเมาส์ แมลงปอก็บิน

ทุก ๆ 50 เฟรม ตุ๊กแกจะถามโมเดลหนึ่งครั้ง sees เขียนสิ่งที่ตุ๊กแกเห็น เป็นประโยคสั้น ๆ สองประโยค โมเดลเลือก wait, creep หรือ jump

ที่เหลือคือคณิตศาสตร์ที่คุณรู้จักแล้ว creep ขยับหัวไปช้า ๆ jump ขยับหัวไปเร็ว ๆ ไปยังจุดที่แมลงปอเคยอยู่ แมลงปอยังอยู่ตรงนั้นไหม ถ้าอยู่ ตุ๊กแกก็จับได้

คำตอบต้องใช้เวลา หน้าต่างจะหยุดรอไม่ได้ คำถามจึงถูกส่งออกไปในเธรดที่สอง ส่วนเธรดแรกวาดภาพต่อไป

🇨🇳 中文

现在把这个问题交给蜥蜴。这只蜥蜴是一只壁虎。你的鼠标是一只蜻蜓。

让鼠标停 20 帧不动,蜻蜓就停下休息。一动鼠标,它就飞。

每 50 帧,壁虎问模型一次。sees 把壁虎看到的写成两句短话。模型在 wait、creep、jump 里挑一个。

剩下的是你已经会的数学。creep 让头慢慢移动。jump 让头很快移到蜻蜓刚才所在的地方。蜻蜓还在那里吗?在的话,壁虎就抓到它了。

等回答需要时间。窗口不能停下来等。所以问题是在第二个线程里发出去的,第一个线程继续画画。

▶ Live. Your pointer is the dragonfly. Hold it still, then move away in time. The gecko asks the real model. If the top line says no answer, the model's computer is off. ▶ ภาพสด ตัวชี้ของคุณคือแมลงปอ ถือให้นิ่ง แล้วหนีให้ทัน ตุ๊กแกถามโมเดลตัวจริง ถ้าบรรทัดบนสุดเขียนว่า no answer แปลว่าคอมพิวเตอร์ของโมเดลปิดอยู่ ▶ 实时演示。你的指针就是蜻蜓。先停住不动,再及时逃开。壁虎问的是真正的模型。如果最上面一行写着 no answer,说明模型那台电脑没开。

📄 NEW FILE · hunt.py

"""hunt.py - a gecko that hunts your mouse. An AI model picks its moves.

    python hunt.py

Your mouse is a dragonfly. Hold the mouse still and the dragonfly rests.
Then the gecko creeps up and jumps. Move away in time.
The model runs on a small computer in Chiang Mai, so this file needs the internet.
In main(), change "model" to "mouse". The gecko walks to the mouse, as in step 7.
"""
import json
import math
import threading
import tkinter as tk
import urllib.request

WIDE = 800                # the window, in pixels
HIGH = 600
SPEED = 4                 # pixels the head moves in one frame when it walks
CREEP = 1.5               # pixels in one frame when it creeps
DASH = 12                 # pixels in one frame when it jumps
TURN = 0.15               # the most the head can turn in one frame
NEAR = 30                 # the head stops when it is this close to the mouse
STIFF = math.pi / 16      # the most one joint of a gecko's back can bend: 11.25 degrees
QUARTER = math.pi / 2     # a quarter turn. Python measures angles in radians.
BONE = 24                 # how long each of the two bones in a leg is
REACH = 36                # how far from the hip a new step lands
STEP = 60                 # a foot takes a step when it is this far behind
JUMP = 130                # how far a jump reaches, in pixels
CATCH = 25                # a jump catches the dragonfly if the head lands this close
REST = 20                 # the dragonfly rests after the mouse is still for 20 frames
THINK = 50                # the gecko asks the model once in every 50 frames
WATER = "#12263a"         # the colour behind everything
WING = "#81c3d7"          # the colour of the dragonfly's wings

# How far the skin is from the spine at each point, from the head to the tail.
LIZARD = [17, 19, 13, 20, 22, 23, 21, 16, 9, 6, 5, 4, 3, 3]

MODEL = "https://spark-wav2lip-1.taila00da8.ts.net/gecko/v1/systemone"

# Who the animal is, and its rules. The model reads these every time.
WHO = ("You are a gecko in a rice field. You hunt a dragonfly. "
       "You can only catch a dragonfly that rests.")
RULES = ("If the dragonfly is flying, stay still. "
         "If it rests and is too far to jump at, creep closer. "
         "If it rests and is close enough, jump.")

# The moves the gecko can choose from, and what each one is for.
MOVES = {
    "wait": "Stay still. Do this when the dragonfly is flying.",
    "creep": "Creep closer. Do this when a resting dragonfly is too far to jump at.",
    "jump": "Jump at it. Do this when a resting dragonfly is close enough.",
}


def angle_to(start, end):
    """The direction you face when you stand at start and look at end."""
    return math.atan2(end[1] - start[1], end[0] - start[0])


def walk(start, angle, far):
    """Stand at start, face angle, walk far pixels. Give back where you are."""
    return [start[0] + math.cos(angle) * far, start[1] + math.sin(angle) * far]


def limit(angle, middle, most):
    """Give back angle, or the nearest angle that is within most of middle."""
    # How far angle is from middle, going the short way round the circle.
    # The answer is from -pi to pi: minus is one way round, plus is the other.
    away = (angle - middle + math.pi) % (2 * math.pi) - math.pi
    if away > most:
        return middle + most
    if away < -most:
        return middle - most
    return angle


def oval(centre, angle, long, wide):
    """Twelve points round the edge of an oval. Its long side points along angle."""
    edge = []
    for k in range(12):
        around = k * math.pi / 6        # twelve of these make one full turn
        # Go along the oval's long side, then out to the edge.
        along = walk(centre, angle, math.cos(around) * long)
        edge.append(walk(along, angle + QUARTER, math.sin(around) * wide))
    return edge


def pull(anchor, point, gap):
    """Give back the place that is gap pixels from anchor, on the way to point."""
    far = math.dist(anchor, point)      # how far apart the two are now
    if far == 0:
        # The same place: there is no direction, and gap / 0 would crash.
        return point
    scale = gap / far                   # 0.5 means "come half as far"
    x = anchor[0] + (point[0] - anchor[0]) * scale
    y = anchor[1] + (point[1] - anchor[1]) * scale
    return [x, y]


def reach(joints, start, end, bone):
    """Bend the chain of joints so it starts at start and reaches toward end."""
    last = len(joints) - 1
    # Forward: put the last joint on the target, then pull the others after it.
    joints[last] = end
    for i in reversed(range(last)):             # count down: last - 1, ..., 1, 0
        joints[i] = pull(joints[i + 1], joints[i], bone)
    # That dragged the first joint off its place. Put it back.
    # Backward: pull the others after it again, this time from the other end.
    joints[0] = start
    for i in range(1, last + 1):
        joints[i] = pull(joints[i - 1], joints[i], bone)


def ask(sees):
    """Send the rules, what the gecko sees, and the moves. Give back the answer."""
    question = {"type": "choice",
                "instructions": "What should you do right now?",
                "criteria": MOVES}
    message = {"state": WHO + " Rules: " + RULES + "\n\nSituation: " + sees,
               "questions": {"move": question}}
    # The internet carries bytes. dumps() writes the dictionary as text,
    # and encode() turns that text into bytes.
    request = urllib.request.Request(MODEL, json.dumps(message).encode(),
                                     {"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=10) as reply:
        answer = json.load(reply)       # the other way: bytes back to a dictionary
    return answer["answers"]["move"]


class Animal:
    def __init__(self, kind):
        self.kind = kind                # "model" hunts. "mouse" walks to the mouse.
        self.widths = LIZARD
        self.gap = 20                   # pixels from one point of the spine to the next
        self.colour = "#52796f"
        self.root = tk.Tk()
        self.root.title(kind)
        self.canvas = tk.Canvas(self.root, width=WIDE, height=HIGH, bg=WATER)
        self.canvas.pack()
        self.canvas.bind("<Motion>", self.mouse_moved)
        self.mouse = [WIDE - 150, HIGH / 2]
        # Start as a straight line on the left. Point 0 is the head.
        self.points = []
        self.angles = []      # which way the body faces at each point
        for i in range(len(self.widths)):
            self.points.append([300 - i * self.gap, HIGH / 2])
            self.angles.append(0)       # 0 is "facing right"
        # Each leg is: which point of the spine it joins, which side
        # (-1 left, 1 right), and how far back its foot starts.
        self.legs = []
        self.add_leg(3, -1, 0)
        self.add_leg(3, 1, STEP / 2)    # half a step behind, so the legs take turns
        self.add_leg(7, -1, STEP / 2)
        self.add_leg(7, 1, 0)
        if kind == "model":
            # Only the hunter needs these. A "mouse" gecko has none of them.
            self.doing = "wait"         # the move the model picked last
            self.asking = False         # True while a question is on its way
            self.frames = 0             # how many frames there have been
            self.still = 0              # frames since the mouse last moved
            self.target = None          # where a jump is going. None: no jump now.
            self.gone = False           # True after a catch, until the mouse moves
            self.caught = 0

    def add_leg(self, spot, side, behind):
        leg = {"spot": spot, "side": side}
        hip, ahead = self.leg_places(leg)
        # The place on the ground where the foot stands. It stays there until a step.
        leg["ground"] = walk(ahead, self.angles[spot], -behind)
        # Hip, knee, foot. The knee starts on the hip, and reach() moves it.
        leg["joints"] = [hip, hip, leg["ground"]]
        self.legs.append(leg)

    def leg_places(self, leg):
        """Where this leg's hip is now, and where its next step will land."""
        spot = leg["spot"]
        facing = self.angles[spot]
        sideways = facing + leg["side"] * QUARTER
        hip = walk(self.points[spot], sideways, self.widths[spot] - 6)
        # The next step lands out to the side and in front: half a quarter turn.
        ahead = walk(hip, facing + leg["side"] * QUARTER / 2, REACH)
        return hip, ahead

    def mouse_moved(self, event):
        self.mouse = [event.x, event.y]
        if self.kind == "model":
            self.still = 0              # the mouse moved, so the dragonfly is flying
            self.gone = False           # after a catch, a new dragonfly comes

    def go(self, place, speed, turn):
        """Move the head toward place. Then the body follows, as in step 4."""
        head = self.points[0]
        wanted = angle_to(head, place)
        self.angles[0] = limit(wanted, self.angles[0], turn)
        self.points[0] = walk(head, self.angles[0], speed)
        for i in range(1, len(self.widths)):
            # Each point wants to face the point in front of it, within STIFF.
            wanted = angle_to(self.points[i], self.points[i - 1])
            self.angles[i] = limit(wanted, self.angles[i - 1], STIFF)
            # Then it stands one gap behind that point. Minus is a walk backwards.
            self.points[i] = walk(self.points[i - 1], self.angles[i], -self.gap)

    def move(self):
        """The head from step 7: walk to the mouse, and stop when it is near."""
        if math.dist(self.points[0], self.mouse) >= NEAR:
            self.go(self.mouse, SPEED, TURN)

    def sees(self):
        """What the gecko sees, in words. The model reads words, not numbers."""
        words = "The dragonfly is flying."
        if self.still >= REST:
            words = "The dragonfly rests."
        if math.dist(self.points[0], self.mouse) <= JUMP:
            return words + " It is close enough to jump at."
        return words + " It is too far to jump at."

    def think(self, sees):
        """Wait for the model's answer, then keep the move it picked."""
        # This runs in a second thread. The window goes on drawing in the first.
        try:
            self.doing = ask(sees)["choice"]
        except OSError:
            self.doing = "no answer"    # no internet, or the model's computer is off
        self.asking = False

    def ask_model(self):
        """Start a second thread that asks the model. Do not wait for it here."""
        self.asking = True
        thread = threading.Thread(target=self.think, args=[self.sees()], daemon=True)
        thread.start()

    def start_jump(self):
        # Jump to where the dragonfly is now. If it flies away, the jump misses.
        head = self.points[0]
        self.target = list(self.mouse)
        if math.dist(head, self.mouse) > JUMP:
            # A jump is JUMP pixels long at most. pull() finds that place.
            self.target = pull(head, self.mouse, JUMP)

    def jump(self):
        """One frame of a jump. When it lands, is the dragonfly still there?"""
        self.go(self.target, DASH, math.pi)     # math.pi: it may turn any way at once
        if math.dist(self.points[0], self.target) > DASH:
            return            # it is still in the air
        if math.dist(self.points[0], self.mouse) < CATCH:
            self.caught = self.caught + 1
            self.gone = True
        self.target = None
        self.doing = "wait"

    def hunt(self):
        """The new head: finish a jump, or ask the model and do the move it picked."""
        self.frames = self.frames + 1
        self.still = self.still + 1
        if self.target:
            self.jump()
            return
        if self.gone:
            return            # it caught the dragonfly, and waits for a new one
        if self.frames % THINK == 0 and not self.asking:
            self.ask_model()
        far = math.dist(self.points[0], self.mouse)
        if self.doing == "creep" and far > JUMP - 10:
            self.go(self.mouse, CREEP, TURN)
        if self.doing == "jump":
            self.start_jump()

    def move_legs(self):
        for leg in self.legs:
            hip, ahead = self.leg_places(leg)
            # The body walks on and the foot stays. When the foot is too far
            # behind, it gets a new place on the ground: that is one step.
            if math.dist(leg["ground"], ahead) > STEP:
                leg["ground"] = ahead
            # The foot does not jump. Each frame it goes 4 tenths of the way there.
            ground = leg["ground"]
            foot = leg["joints"][2]
            x = foot[0] + (ground[0] - foot[0]) * 0.4
            y = foot[1] + (ground[1] - foot[1]) * 0.4
            reach(leg["joints"], hip, [x, y], BONE)

    def outline(self):
        """The points of the skin, in order: down one side and back up the other."""
        left = []
        right = []
        for i in range(len(self.widths)):
            # A quarter turn from "forward" is "sideways". Walk out to the skin.
            wide = self.widths[i]
            left.append(walk(self.points[i], self.angles[i] - QUARTER, wide))
            right.append(walk(self.points[i], self.angles[i] + QUARTER, wide))
        right.reverse()       # the right side runs from the tail back to the head
        nose = walk(self.points[0], self.angles[0], self.widths[0])
        return [nose] + left + right

    def shape(self, edge, colour):
        # smooth=True rounds every corner, so the points become one curved shape.
        self.canvas.create_polygon(edge, fill=colour, outline="white",
                                   width=3, smooth=True)

    def circle(self, centre, radius, fill, rim):
        x, y = centre
        # An oval is given by the box around it: left, top, right, bottom.
        box = [x - radius, y - radius, x + radius, y + radius]
        self.canvas.create_oval(box, fill=fill, outline=rim, width=2)

    def draw_hunt(self):
        if not self.gone:
            # The dragonfly. Two long ovals are its four wings. One is its body.
            self.shape(oval(self.mouse, 0.5, 28, 6), WING)
            self.shape(oval(self.mouse, -0.5, 28, 6), WING)
            self.shape(oval(self.mouse, QUARTER, 20, 3), "#ee6c4d")
        words = "the model says: " + self.doing + "      caught: " + str(self.caught)
        self.canvas.create_text(WIDE / 2, 24, text=words, fill="white",
                                font=("Arial", 14))

    def draw(self):
        self.canvas.delete("all")                         # wipe the last picture
        # The legs go on first. Each is a thick white line with a thinner
        # green line on top, so it looks like it has a white edge.
        for leg in self.legs:
            self.canvas.create_line(leg["joints"], fill="white", width=14,
                                    capstyle="round", joinstyle="round")
            self.canvas.create_line(leg["joints"], fill=self.colour, width=8,
                                    capstyle="round", joinstyle="round")
        self.shape(self.outline(), self.colour)
        # The eyes. 1.2 is most of a quarter turn (1.57), so one sits on each side.
        head = self.points[0]
        facing = self.angles[0]
        far = self.widths[0] / 2
        self.circle(walk(head, facing - 1.2, far), 3, "white", "white")
        self.circle(walk(head, facing + 1.2, far), 3, "white", "white")
        if self.kind == "model":
            self.draw_hunt()

    def frame(self):
        if self.kind == "model":
            self.hunt()
        else:
            self.move()
        self.move_legs()
        self.draw()
        # Ask tkinter to call frame again in 16 ms, so the picture keeps changing.
        self.root.after(16, self.frame)

    def run(self):
        self.frame()
        self.root.mainloop()


def main():
    animal = Animal("model")
    animal.run()


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
if self.still >= REST: words = "The dragonfly rests."A number becomes words here. The model never reads 20.ตัวเลขกลายเป็นคำตรงนี้ โมเดลไม่เคยได้อ่านเลข 20数字在这里变成了文字。模型从来读不到 20。
if self.frames % THINK == 0 and not self.asking:Ask once in every 50 frames. Never ask two questions at once.ถามหนึ่งครั้งทุก 50 เฟรม และไม่ถามสองคำถามพร้อมกัน每 50 帧问一次。绝不同时问两个问题。
thread = threading.Thread(target=self.think, ...)Make a second thread. Its one job is to call think.สร้างเธรดที่สอง งานอย่างเดียวของมันคือเรียก think造第二个线程。它唯一的工作是调用 think。
self.doing = ask(sees)["choice"]Wait for the answer. Keep only the move it picked.รอคำตอบ แล้วเก็บไว้แค่ท่าที่โมเดลเลือก等回答。只留下它挑的那个动作。
self.target = pull(head, self.mouse, JUMP)The old pull again. A jump is 130 pixels long at most.pull ตัวเดิมอีกครั้ง กระโดดหนึ่งครั้งไปได้ไกลที่สุด 130 พิกเซล又是那个 pull。一次跳最远 130 像素。
if math.dist(self.points[0], self.mouse) < CATCH:The jump has landed. Is the dragonfly within 25 pixels?กระโดดลงถึงพื้นแล้ว แมลงปออยู่ในระยะ 25 พิกเซลไหม这一跳落地了。蜻蜓在 25 像素以内吗?
YOU GET A gecko that hunts by itself. At the start the dragonfly is 350 pixels away. The gecko creeps for 154 frames and stops 119 pixels from it. The jump is over in 10 frames. Those numbers were counted from this file. We also ran it for real. It asked the model once every 1.0 to 1.3 seconds. ตุ๊กแกที่ออกล่าเอง ตอนเริ่ม แมลงปออยู่ห่าง 350 พิกเซล ตุ๊กแกย่องไป 154 เฟรม แล้วหยุดห่างจากมัน 119 พิกเซล การกระโดดจบใน 10 เฟรม ตัวเลขพวกนี้นับจากไฟล์นี้จริง ในการรันจริง มันถามโมเดลหนึ่งครั้งทุก 1.0 ถึง 1.3 วินาที 一只自己会捕猎的壁虎。开始时蜻蜓在 350 像素外。壁虎悄悄爬了 154 帧,停在离它 119 像素的地方。这一跳 10 帧就结束。这些数字是从这个文件里数出来的。在一次真实的运行里,它每 1.0 到 1.3 秒问模型一次。
WHY WORDS, AND WHY A MODEL This model does not compare numbers. In the big game we first sent it numbers. For example: “60 steps away, a jump reaches 130”. It picked the right move in 6 of 21 test situations. With words like “close enough to jump at”, it follows the rules. So the program does the counting, and the model does the choosing. Four lines of if could pick these three moves too. But now the rules are English sentences. Change a sentence and the gecko changes. You wrote no new code for that in step 8. โมเดลตัวนี้เปรียบเทียบตัวเลขไม่เป็น ในเกมใหญ่ ตอนแรกเราส่งให้มันว่า “60 steps away, a jump reaches 130” มันเลือกท่าถูกแค่ 6 จาก 21 สถานการณ์ทดสอบ แต่พอใช้คำอย่าง “close enough to jump at” มันก็ทำตามกฎ โปรแกรมจึงเป็นฝ่ายนับ ส่วนโมเดลเป็นฝ่ายเลือก if สี่บรรทัดก็เลือกสามท่านี้ได้เหมือนกัน แต่ตอนนี้กฎเป็นประโยคภาษาอังกฤษ เปลี่ยนประโยค ตุ๊กแกก็เปลี่ยน ในขั้นที่ 8 คุณไม่ได้เขียนโค้ดใหม่เพื่อสิ่งนี้เลย 这个模型不会比较数字。在大游戏里,我们一开始发给它的是“60 steps away, a jump reaches 130”。21 个测试情形里,它只挑对了 6 个。换成“close enough to jump at”这样的文字,它就照规则办。所以由程序来数数,由模型来挑选。四行 if 也能挑出这三个动作。可是现在规则是英文句子,改一句话,壁虎就变了。第 8 步里,你没有为此写一行新代码。
YOUR TURN
  1. In main, change "model" to "mouse". The gecko walks to your mouse again.
  2. Change THINK to 200. Now the gecko is slow to see you.
  3. Change CREEP to 4. Then change DASH to 3.
  4. Use your lazy gecko from step 8. It never creeps. It waits for you to rest near it.
  5. Give the dragonfly a score. Count the frames it rests, and draw the number.
  1. ใน main เปลี่ยน "model" เป็น "mouse" ตุ๊กแกจะเดินมาหาเมาส์ของคุณเหมือนเดิม
  2. เปลี่ยน THINK เป็น 200 ตอนนี้ตุ๊กแกจะเห็นคุณช้า
  3. เปลี่ยน CREEP เป็น 4 แล้วเปลี่ยน DASH เป็น 3
  4. ใช้ตุ๊กแกขี้เกียจจากขั้นที่ 8 มันไม่ย่องเลย มันรอให้คุณมาพักใกล้ ๆ มัน
  5. ให้คะแนนแมลงปอ นับจำนวนเฟรมที่มันพัก แล้ววาดตัวเลขนั้น
  1. 在 main 里把 "model" 改成 "mouse"。壁虎又会朝你的鼠标走过来。
  2. 把 THINK 改成 200。现在壁虎要过很久才发现你。
  3. 把 CREEP 改成 4。再把 DASH 改成 3。
  4. 用上第 8 步里的那只懒壁虎。它从不悄悄爬,只等你停到它旁边。
  5. 给蜻蜓记分。数一数它休息了多少帧,把这个数字画出来。

🎮 Tutorial 6 — Nine agents: the big game · บทที่ 6 — เอเจนต์เก้าตัว: เกมใหญ่ · 教程 6 —— 九个智能体:大游戏

🇬🇧 English

The same idea makes a bigger game. Three geckos hunt six dragonflies. Each of the nine animals is an agent. Each one asks the same model.

There the animals get tired, and a dragonfly cannot see behind itself. You can rewrite a team's rules in English. You can also drive one animal yourself.

Play the big game. It runs on that small computer, so it is sometimes off.

This part shows how the game is built. First you ask the model once, on your own computer. Then you write the smallest agent. Then you see each part of the game. All its code is at the end.

🇹🇭 ไทย

แนวคิดเดียวกันนี้กลายเป็นเกมที่ใหญ่กว่าได้ ตุ๊กแกสามตัวล่าแมลงปอหกตัว สัตว์ทั้งเก้าตัวเป็นเอเจนต์ตัวละหนึ่ง และทุกตัวถามโมเดลตัวเดียวกัน

ในเกมนั้นสัตว์เหนื่อยเป็น และแมลงปอมองไม่เห็นข้างหลังตัวเอง คุณเขียนกฎของทีมใหม่เป็นภาษาอังกฤษได้ และยังบังคับสัตว์หนึ่งตัวเองได้ด้วย

เล่นเกมใหญ่ มันรันอยู่บนคอมพิวเตอร์เครื่องเล็กเครื่องนั้น บางครั้งจึงปิดอยู่

ส่วนนี้แสดงให้เห็นว่าเกมถูกสร้างขึ้นอย่างไร ก่อนอื่นคุณจะถามโมเดลหนึ่งครั้งบนคอมพิวเตอร์ของคุณเอง แล้วจึงเขียนเอเจนต์ที่เล็กที่สุด จากนั้นคุณจะได้เห็นแต่ละส่วนของเกม โค้ดทั้งหมดอยู่ตอนท้าย

🇨🇳 中文

同样的想法可以做成更大的游戏。三只壁虎追六只蜻蜓。九只动物每一只都是一个智能体,每一只都去问同一个模型。

在那里,动物会累,蜻蜓也看不见自己的身后。你可以用英文重写一队的规则,也可以自己操纵一只动物。

玩这个大游戏。它运行在那台小电脑上,所以有时候没开。

这一部分讲这个游戏是怎么做出来的。你先在自己的电脑上问模型一次,再写一个最小的智能体,然后看游戏的每一个部分。全部代码在最后。

hunt.pyThe big gameเกมใหญ่大游戏
Animalsสัตว์动物1 gecko, and your mouseตุ๊กแก 1 ตัว กับเมาส์ของคุณ1 只壁虎,和你的鼠标3 geckos, 6 dragonfliesตุ๊กแก 3 ตัว แมลงปอ 6 ตัว3 只壁虎,6 只蜻蜓
Who asks the modelใครถามโมเดล谁去问模型The geckoตุ๊กแก壁虎All nine animalsสัตว์ทั้งเก้าตัว九只动物全部
Movesท่า动作wait, creep, jumpNine movesเก้าท่า九个动作
Questions each secondคำถามต่อวินาที每秒的问题数About 1ประมาณ 1大约 1 个About 6ประมาณ 6大约 6 个
Written inเขียนด้วย用什么写的Python, with tkinterPython กับ tkinterPython 和 tkinterJavaScript, in a web pageJavaScript ในหน้าเว็บ网页里的 JavaScript

10 One question, on your own computer · คำถามหนึ่งข้อ บนคอมพิวเตอร์ของคุณเอง · 一个问题,在你自己的电脑上

🇬🇧 English

Steps 8 and 9 asked a model on our computer. Now the model comes to yours.

This file is the smallest use of the model. It has three parts. Decider loads the model. question lists the moves, with one sentence for each. system_one gives the model the text and the question.

The text is the state: who the gecko is, its rules, and what it sees. The answer is a dictionary. It has the move, and a number for each move.

There is no loop yet. The model answers once, and the file ends. Step 11 puts this question in a loop.

🇹🇭 ไทย

ขั้นที่ 8 และ 9 ถามโมเดลที่อยู่บนคอมพิวเตอร์ของเรา ทีนี้โมเดลจะมาอยู่บนเครื่องของคุณ

ไฟล์นี้คือการใช้โมเดลแบบที่เล็กที่สุด มันมีสามส่วน Decider โหลดโมเดล question คือรายการท่า พร้อมประโยคหนึ่งประโยคสำหรับแต่ละท่า system_one ส่งข้อความและคำถามให้โมเดล

ข้อความนั้นคือสถานะ (state): ตุ๊กแกคือใคร กฎของมัน และสิ่งที่มันเห็น คำตอบเป็นดิกชันนารี ข้างในมีท่าที่เลือก และตัวเลขของแต่ละท่า

ยังไม่มีวงวน โมเดลตอบหนึ่งครั้ง แล้วไฟล์ก็จบ ขั้นที่ 11 จะนำคำถามนี้ไปใส่ในวงวน

🇨🇳 中文

第 8、9 步问的是我们电脑上的模型。现在模型来到你的电脑上。

这个文件是使用模型最小的方式。它有三个部分。Decider 加载模型。question 列出动作,每个动作配一句话。system_one 把文字和问题交给模型。

这段文字就是状态(state):壁虎是谁、它的规则、它看到了什么。回答是一个字典,里面有挑中的动作,还有每个动作的数字。

这里还没有循环。模型回答一次,文件就结束了。第 11 步会把这个问题放进一个循环。

THIS FILE RUNS THE MODEL ON YOUR COMPUTER It needs Python 3.11 or newer. pip install decider-ai brings the library and PyTorch. On Windows that is about 1 GB. The first run downloads the model: 3.8 GB. The model needs about 8 GB of free memory. It needs no GPU. You will see some warnings. They are fine. ต้องใช้ Python 3.11 ขึ้นไป คำสั่ง pip install decider-ai จะติดตั้งไลบรารีและ PyTorch บน Windows มีขนาดประมาณ 1 GB การรันครั้งแรกจะดาวน์โหลดโมเดลขนาด 3.8 GB โมเดลต้องใช้หน่วยความจำว่างประมาณ 8 GB ไม่ต้องมี GPU คุณจะเห็นคำเตือนบางบรรทัด ไม่ต้องกังวล 需要 Python 3.11 或更新的版本。pip install decider-ai 会装上这个库和 PyTorch,在 Windows 上大约 1 GB。第一次运行会下载模型:3.8 GB。模型需要大约 8 GB 的空闲内存。不需要 GPU。你会看到几行警告,不用管它们。
▶ Live. Press the button. The same question goes to the model. Here the model answers from our computer. Your numbers can differ a little. ▶ ภาพสด กดปุ่ม คำถามเดียวกันจะถูกส่งไปที่โมเดล ที่นี่โมเดลตอบมาจากคอมพิวเตอร์ของเรา ตัวเลขของคุณอาจต่างไปเล็กน้อย ▶ 实时演示。点这个按钮。同一个问题会发给模型。在这里,模型从我们的电脑上回答。你的数字可能会有一点不同。

💻 RUN IT

pip install decider-ai
python decide.py

📄 NEW FILE · decide.py

"""decide.py - load the AI model, ask it one question, and print the answer.

    pip install decider-ai
    python decide.py

The model runs on this computer. The first run downloads it: 3.8 GB.
"""
from decider.infer import Decider

model = Decider("Mapika/decider-2b")

question = {
    "type": "choice",
    "instructions": "What should you do right now?",
    "criteria": {
        "creep": "Creep closer. Do this when the dragonfly is too far to jump at.",
        "jump": "Jump at it. Do this when the dragonfly is close enough.",
    },
}

answer = model.system_one(
    "You are a gecko. You hunt a dragonfly that rests. "
    "Rules: If it is too far to jump at, creep closer. If it is close enough, jump."
    "\n\nSituation: It is close enough to jump at.",
    {"move": question},
)
print(answer)

🖨 WHAT IT PRINTED

{'model': 'decider-2b-v11', 'answers': {'move': {'type': 'choice', 'choice': 'jump', 'confidence': 0.9964, 'x_p_max': 0.9982, 'certainty': 0.9811, 'probabilities': {'creep': 0.0018, 'jump': 0.9982}}}, 'usage': {'input_tokens': 109, 'output_tokens': 0}}
LineWhat it does · ทำอะไร · 做什么
from decider.infer import DeciderThe library that runs the model. pip install decider-ai puts it on your computer.ไลบรารีที่ใช้รันโมเดล คำสั่ง pip install decider-ai จะติดตั้งมันลงในคอมพิวเตอร์ของคุณ运行模型的库。pip install decider-ai 会把它装到你的电脑上。
model = Decider("Mapika/decider-2b")Load the model. The first time, it is downloaded. This is the slow line.โหลดโมเดล ครั้งแรกมันจะถูกดาวน์โหลดมาก่อน บรรทัดนี้คือบรรทัดที่ช้า加载模型。第一次会先下载它。这一行最慢。
"criteria": {"creep": "Creep closer. ...", ...}The moves. Each has a name and one sentence.ท่าต่าง ๆ แต่ละท่ามีชื่อและประโยคหนึ่งประโยค各个动作。每个动作有名字,还有一句话。
answer = model.system_one("You are a gecko. ...", {"move": question})Ask. The model reads the text once and picks a move. No internet is used.ถาม โมเดลอ่านข้อความหนึ่งรอบแล้วเลือกท่า ไม่ใช้อินเทอร์เน็ต问。模型把文字读一遍,然后挑一个动作。不用联网。
print(answer)Print all of it: the move, the numbers, and the size of the text.พิมพ์ออกมาทั้งหมด: ท่าที่เลือก ตัวเลขของแต่ละท่า และขนาดของข้อความ全部打印出来:挑中的动作、每个动作的数字,还有文字的长度。
YOU GET One answer: jump, with about 0.998 for it. On our laptop the whole file took 16 to 20 seconds. On our GPU it took almost 4 minutes: the library prepares a fast path first. With Decider("Mapika/decider-2b", use_graphs=False) it took one minute. คำตอบหนึ่งข้อ: jump ด้วยตัวเลขประมาณ 0.998 บนแล็ปท็อปของเรา ไฟล์ทั้งไฟล์ใช้เวลา 16 ถึง 20 วินาที บน GPU ของเรา มันใช้เวลาเกือบ 4 นาที เพราะไลบรารีเตรียมทางลัดที่เร็วกว่าไว้ก่อน ถ้าใช้ Decider("Mapika/decider-2b", use_graphs=False) จะใช้เวลาหนึ่งนาที 一个回答:jump,它的数字大约是 0.998。在我们的笔记本电脑上,整个文件用了 16 到 20 秒。在我们的 GPU 上用了将近 4 分钟:这个库会先准备一条更快的路。用 Decider("Mapika/decider-2b", use_graphs=False) 的话,用了一分钟。
YOUR TURN
  1. Change the situation to It is too far to jump at. Now the answer is creep.
  2. Add a third move: "wait": "Stay still. Do this when the dragonfly is flying." Change the situation to The dragonfly is flying. The model still says creep.
  3. Now add a rule: If it is flying, stay still. Now it says wait. A move needs a rule.
  4. Print only the move: print(answer["answers"]["move"]["choice"])
  5. Ask your own question. Change the state, the instructions and the moves.
  1. เปลี่ยนสถานการณ์เป็น It is too far to jump at. ตอนนี้คำตอบจะเป็น creep
  2. เพิ่มท่าที่สาม: "wait": "Stay still. Do this when the dragonfly is flying." แล้วเปลี่ยนสถานการณ์เป็น The dragonfly is flying. โมเดลก็ยังตอบ creep
  3. ทีนี้เพิ่มกฎ: If it is flying, stay still. ตอนนี้มันจะตอบ wait ท่าหนึ่งท่าต้องมีกฎของมัน
  4. พิมพ์เฉพาะท่าที่เลือก: print(answer["answers"]["move"]["choice"])
  5. ตั้งคำถามของคุณเอง เปลี่ยนสถานะ คำสั่ง และรายการท่า
  1. 把情况改成 It is too far to jump at. 现在回答是 creep。
  2. 加第三个动作:"wait": "Stay still. Do this when the dragonfly is flying." 再把情况改成 The dragonfly is flying. 模型还是说 creep。
  3. 现在加一条规则:If it is flying, stay still. 现在它说 wait。一个动作需要一条规则。
  4. 只打印动作:print(answer["answers"]["move"]["choice"])
  5. 问你自己的问题。改状态、指令和动作。

11 The smallest agent · เอเจนต์ที่เล็กที่สุด · 最小的智能体

🇬🇧 English

In step 9 the agent hides inside a long file. Here it stands alone. There is no window and no picture.

It loads the model like step 10. It asks the same question. But it asks again and again. The world changes between the questions.

The world is one number. far counts the steps from the gecko to the dragonfly.

The agent does three things, again and again. It sees: the number becomes a sentence. It asks: the model picks a move. It acts: the program changes the number.

The model only picks. The program decides what the pick does. A jump from too far is still a miss.

That loop is all an agent is. The big game runs it nine times at once.

🇹🇭 ไทย

ในขั้นที่ 9 เอเจนต์ซ่อนอยู่ในไฟล์ยาว ๆ ที่นี่มันอยู่ตัวเดียว ไม่มีหน้าต่าง ไม่มีภาพ

มันโหลดโมเดลแบบเดียวกับขั้นที่ 10 และถามคำถามเดียวกัน แต่มันถามซ้ำไปเรื่อย ๆ และโลกก็เปลี่ยนไประหว่างคำถามแต่ละข้อ

โลกทั้งใบคือตัวเลขตัวเดียว far นับจำนวนก้าวจากตุ๊กแกถึงแมลงปอ

เอเจนต์ทำสามอย่าง วนซ้ำไปเรื่อย ๆ มันมอง: ตัวเลขกลายเป็นประโยค มันถาม: โมเดลเลือกท่าหนึ่งท่า มันลงมือ: โปรแกรมเปลี่ยนตัวเลข

โมเดลทำแค่เลือก โปรแกรมเป็นฝ่ายตัดสินว่าท่าที่เลือกทำให้เกิดอะไร กระโดดจากที่ไกลเกินไปก็ยังพลาดอยู่ดี

วงวนนี้คือทั้งหมดของเอเจนต์ เกมใหญ่รันวงวนนี้เก้าวงพร้อมกัน

🇨🇳 中文

在第 9 步,智能体藏在一个很长的文件里。在这里,它单独站出来。没有窗口,也没有画面。

它像第 10 步那样加载模型,问同一个问题。可是它一遍又一遍地问,每两个问题之间,世界都在变。

整个世界只是一个数字。far 数的是壁虎到蜻蜓有几步。

智能体一遍又一遍地做三件事。它看:数字变成一句话。它问:模型挑一个动作。它行动:程序去改那个数字。

模型只管挑。挑了以后会怎样,由程序决定。离得太远就跳,照样扑空。

智能体就是这个循环,没有别的。大游戏把它同时跑九遍。

THIS FILE NEEDS WHAT STEP 10 NEEDS The library, the model and about 8 GB of free memory. On our laptop, one answer takes about 2 seconds. ไลบรารี ตัวโมเดล และหน่วยความจำว่างประมาณ 8 GB บนแล็ปท็อปของเรา คำตอบหนึ่งข้อใช้เวลาประมาณ 2 วินาที 这个库、模型,还有大约 8 GB 的空闲内存。在我们的笔记本电脑上,一个回答大约要 2 秒。
▶ Live. Press the button. The same loop runs in this page. Here the model answers from our computer. In the file, it runs on yours. ▶ ภาพสด กดปุ่ม วงวนเดียวกันจะรันในหน้านี้ ที่นี่โมเดลตอบมาจากคอมพิวเตอร์ของเรา ส่วนในไฟล์ โมเดลรันบนเครื่องของคุณ ▶ 实时演示。点这个按钮。同一个循环会在这一页里运行。在这里,模型从我们的电脑上回答。在文件里,它在你的电脑上运行。

💻 RUN IT

pip install decider-ai
python agent.py

📄 NEW FILE · agent.py

"""agent.py - the smallest agent, with the AI model inside it.

    pip install decider-ai
    python agent.py

A gecko creeps up on a resting dragonfly. There is no window: the world is
one number. This file asks no server. It loads the model decider-2b and runs
it on this computer. The first run downloads the model: 3.8 GB.
"""
from decider.infer import Decider

JUMP = 2                  # how far a jump reaches, in steps

# Who the animal is, and its rules. The model reads these every time.
WHO = "You are a gecko. You hunt a dragonfly that rests."
RULES = "If it is too far to jump at, creep closer. If it is close enough, jump."

# The moves the gecko can choose from, and what each one is for.
MOVES = {
    "creep": "Creep closer. Do this when the dragonfly is too far to jump at.",
    "jump": "Jump at it. Do this when the dragonfly is close enough.",
}


def ask(model, sees):
    """Give the model the rules, what the gecko sees, and the moves. Give back the move."""
    question = {"type": "choice",
                "instructions": "What should you do right now?",
                "criteria": MOVES}
    state = WHO + " Rules: " + RULES + "\n\nSituation: " + sees
    answer = model.system_one(state, {"move": question})
    return answer["answers"]["move"]["choice"]


def main():
    model = Decider("Mapika/decider-2b")      # load the model: slow, so do it once
    far = 5                   # the whole world: steps from the gecko to the dragonfly
    for turn in range(10):
        # 1. See. The model reads words, so the number becomes a sentence.
        sees = "It is too far to jump at."
        if far <= JUMP:
            sees = "It is close enough to jump at."
        # 2. Ask. The model picks one move.
        move = ask(model, sees)
        print(far, "steps away.", sees, "->", move)
        # 3. Act. The program changes the world. The model never does.
        if move == "jump":
            if far <= JUMP:
                print("The gecko catches the dragonfly.")
            else:
                print("The jump is too short. The dragonfly flies away.")
            return
        far = far - 1         # the move was creep: one step closer
    print("The gecko never jumped.")


if __name__ == "__main__":
    main()

🖨 WHAT IT PRINTED

5 steps away. It is too far to jump at. -> creep
4 steps away. It is too far to jump at. -> creep
3 steps away. It is too far to jump at. -> creep
2 steps away. It is close enough to jump at. -> jump
The gecko catches the dragonfly.
LineWhat it does · ทำอะไร · 做什么
model = Decider("Mapika/decider-2b")Load the model once, before the loop. Loading is slow.โหลดโมเดลครั้งเดียว ก่อนเข้าวงวน การโหลดช้า在循环之前加载一次模型。加载很慢。
far = 5The whole world is this one number.โลกทั้งใบคือตัวเลขตัวนี้ตัวเดียว整个世界就是这一个数字。
for turn in range(10):One turn of the loop is one decision. There are ten turns at most.วงวนหนึ่งรอบคือการตัดสินใจหนึ่งครั้ง มีอย่างมากสิบรอบ循环一圈就是一次决定。最多十圈。
if far <= JUMP:See. The program compares the numbers. The model gets only the sentence.มอง โปรแกรมเป็นฝ่ายเปรียบเทียบตัวเลข โมเดลได้รับแค่ประโยค看。比较数字的是程序。模型拿到的只有那句话。
answer = model.system_one(state, {"move": question})Ask. The model reads the text once and picks a move. No internet is used.ถาม โมเดลอ่านข้อความหนึ่งรอบแล้วเลือกท่า ไม่ใช้อินเทอร์เน็ต问。模型把文字读一遍,然后挑一个动作。不用联网。
if move == "jump":Act. The program decides if the jump catches. The model does not.ลงมือ โปรแกรมตัดสินว่ากระโดดแล้วจับได้หรือไม่ ไม่ใช่โมเดล行动。这一跳抓不抓得到,由程序决定,不是模型。
far = far - 1Act. A creep changes the world. The next turn sees the new world.ลงมือ การย่องเปลี่ยนโลก รอบถัดไปจะเห็นโลกที่เปลี่ยนไปแล้ว行动。爬一步,世界就变了。下一圈看到的是新的世界。
YOU GET Four questions, four answers, and a catch. The gecko creeps three times. Then it jumps. The whole agent is the loop in main. On our laptop it starts in 15 seconds. Then each answer takes about 2 seconds. On our GPU, the first answer took 90 seconds. The next ones took 0.02. คำถามสี่ข้อ คำตอบสี่ข้อ และจับได้หนึ่งครั้ง ตุ๊กแกย่องสามครั้ง แล้วก็กระโดด เอเจนต์ทั้งตัวคือวงวนใน main บนแล็ปท็อปของเรา มันเริ่มทำงานใน 15 วินาที จากนั้นคำตอบแต่ละข้อใช้เวลาประมาณ 2 วินาที บน GPU ของเรา คำตอบแรกใช้เวลา 90 วินาที ข้อต่อ ๆ ไปใช้ 0.02 วินาที 四个问题,四个回答,抓到一次。壁虎爬了三次,然后跳。整个智能体就是 main 里的那个循环。在我们的笔记本电脑上,它 15 秒启动,之后每个回答大约 2 秒。在我们的 GPU 上,第一个回答用了 90 秒,后面的每个 0.02 秒。
YOUR TURN
  1. Change far = 5 to far = 9. Count the questions.
  2. Now try far = 12. The gecko never jumps. Find the number that stops it.
  3. Put far = 5 back. Change JUMP to 4. The gecko jumps sooner.
  4. Tell the model a wrong thing. Change the first sees sentence to "It is close enough to jump at."
  5. Run it. The gecko jumps from 5 steps, and it misses. The model only knows your words.
  6. Write this loop for the dragonfly. It rests when the gecko is far. It flies when the gecko is close.
  1. เปลี่ยน far = 5 เป็น far = 9 แล้วนับจำนวนคำถาม
  2. ทีนี้ลอง far = 12 ตุ๊กแกไม่กระโดดเลย หาตัวเลขที่ทำให้มันหยุด
  3. ใส่ far = 5 กลับคืน แล้วเปลี่ยน JUMP เป็น 4 ตุ๊กแกจะกระโดดเร็วขึ้น
  4. บอกสิ่งที่ผิดกับโมเดล เปลี่ยนประโยค sees ประโยคแรกเป็น "It is close enough to jump at."
  5. รันดู ตุ๊กแกจะกระโดดจาก 5 ก้าว แล้วพลาด โมเดลรู้แค่คำที่คุณบอก
  6. เขียนวงวนนี้ให้แมลงปอ มันพักเมื่อตุ๊กแกอยู่ไกล และบินเมื่อตุ๊กแกอยู่ใกล้
  1. 把 far = 5 改成 far = 9。数一数有几个问题。
  2. 再试试 far = 12。壁虎一次也不跳。找出让它停下来的那个数字。
  3. 把 far = 5 改回来。把 JUMP 改成 4。壁虎会更早跳。
  4. 告诉模型一件错的事。把第一句 sees 改成 "It is close enough to jump at."
  5. 运行它。壁虎从 5 步外就跳,结果扑空。模型只知道你告诉它的话。
  6. 给蜻蜓写这个循环。壁虎离得远,它就休息;壁虎离得近,它就飞走。

🧩 How the big game is built · เกมใหญ่ถูกสร้างขึ้นอย่างไร · 大游戏是怎么搭起来的

🇬🇧 English

The game has three parts, in three places. Your browser draws the field and runs the nine agents. A small computer in Chiang Mai runs the model. The internet carries the questions between them.

The model never sees the field. The browser never runs the model. They only send short messages to each other.

🇹🇭 ไทย

เกมนี้มีสามส่วน อยู่ในสามที่ เบราว์เซอร์ของคุณวาดทุ่งนาและรันเอเจนต์ทั้งเก้าตัว คอมพิวเตอร์เครื่องเล็กในเชียงใหม่รันโมเดล อินเทอร์เน็ตพาคำถามไปมาระหว่างสองฝั่ง

โมเดลไม่เคยเห็นทุ่งนา เบราว์เซอร์ไม่เคยรันโมเดล ทั้งสองฝั่งแค่ส่งข้อความสั้น ๆ ถึงกัน

🇨🇳 中文

这个游戏有三个部分,在三个地方。你的浏览器画出田野,并运行九个智能体。清迈的一台小电脑运行模型。互联网在两边之间传递问题。

模型从来看不到田野。浏览器从来不运行模型。它们只是互相发短消息。

① Your browser① เบราว์เซอร์ของคุณ① 你的浏览器
  • gecko_dragonfly.htmlOne file. It is the whole game.ไฟล์เดียว คือเกมทั้งเกม一个文件,就是整个游戏。
  • <canvas> + JavaScriptDraws the field and moves the bodies.วาดทุ่งนาและขยับลำตัว画出田野,移动身体。
  • 9 × agentEach animal sees, asks and acts.สัตว์แต่ละตัวมอง ถาม แล้วลงมือ每只动物都看、问、行动。
② The internet② อินเทอร์เน็ต② 互联网
  • Tailscale FunnelA public address for a computer at home.ที่อยู่สาธารณะของคอมพิวเตอร์ที่อยู่ในบ้าน给家里那台电脑的一个公开地址。
  • /gecko/v1/systemoneEvery question goes to this address.คำถามทุกข้อส่งมาที่ที่อยู่นี้每个问题都发到这个地址。
③ A small computer in Chiang Mai③ คอมพิวเตอร์เครื่องเล็กในเชียงใหม่③ 清迈的一台小电脑
  • Docker · gecko-gameOne closed box holds everything below.กล่องปิดหนึ่งกล่อง ใส่ทุกอย่างข้างล่างนี้ไว้一个封闭的盒子,装着下面的全部东西。
  • game_server.py · FastAPIThe door. It takes short questions only.ประตู รับเฉพาะคำถามสั้น ๆ门。只收短的问题。
  • decider-ai · PyTorchTurns each question into numbers.แปลงคำถามแต่ละข้อเป็นตัวเลข把每个问题变成数字。
  • decider-2b · GPUThe model. It picks one move.โมเดล เลือกหนึ่งท่า模型。它挑一个动作。

🇬🇧 One decision, step by step

  1. An animal's turn comes, about every 1.5 seconds.
  2. question() writes what the animal senses, in words.
  3. think() sends the rules, those words and the moves. That is one small message.
  4. Tailscale Funnel takes the message to the computer in Chiang Mai.
  5. game_server.py checks it at the door. A message that is too big is sent back.
  6. The decider-ai library writes it as a small test. The moves become (A), (B), (C), (D).
  7. The model reads the test once, on the GPU. It gives each letter a number.
  8. The answer goes back. It holds one move, and a number for each move.
  9. take() starts the move. The page moves the body, frame by frame.

🇹🇭 การตัดสินใจหนึ่งครั้ง ทีละขั้น

  1. ถึงตาของสัตว์หนึ่งตัว ประมาณทุก 1.5 วินาที
  2. question() เขียนสิ่งที่สัตว์รับรู้ออกมาเป็นคำ
  3. think() ส่งกฎ คำพวกนั้น และรายการท่าออกไป ทั้งหมดเป็นข้อความเล็ก ๆ หนึ่งก้อน
  4. Tailscale Funnel พาข้อความไปถึงคอมพิวเตอร์ในเชียงใหม่
  5. game_server.py ตรวจข้อความที่ประตู ข้อความที่ใหญ่เกินไปจะถูกส่งกลับ
  6. ไลบรารี decider-ai เขียนข้อความนั้นเป็นข้อสอบเล็ก ๆ ท่าต่าง ๆ กลายเป็น (A), (B), (C), (D)
  7. โมเดลอ่านข้อสอบหนึ่งรอบบน GPU แล้วให้ตัวเลขกับตัวอักษรแต่ละตัว
  8. คำตอบเดินทางกลับ ข้างในมีหนึ่งท่า กับตัวเลขของทุกท่า
  9. take() เริ่มท่านั้น แล้วหน้าเว็บขยับลำตัวไปทีละเฟรม

🇨🇳 一次决定,一步一步来

  1. 轮到一只动物了,大约每 1.5 秒一次。
  2. question() 把动物感觉到的东西写成文字。
  3. think() 把规则、这些文字和动作一起发出去。这是一条小消息。
  4. Tailscale Funnel 把消息送到清迈的那台电脑。
  5. game_server.py 在门口检查它。太大的消息会被退回。
  6. decider-ai 库把它写成一道小小的选择题。动作变成 (A)、(B)、(C)、(D)。
  7. 模型在 GPU 上把这道题读一遍,给每个字母一个数字。
  8. 回答送回来。里面有一个动作,还有每个动作的数字。
  9. take() 开始这个动作。页面一帧一帧地移动身体。

📨 ONE REAL QUESTION, AS THE PAGE SENDS IT

{
  "state": "You are a gecko in a rice field. You hunt dragonflies. You can only catch a low dragonfly. A dragonfly is low when it rests or eats. Dragonflies see a moving gecko. They do not see a still gecko. Rules: If a low dragonfly is close enough and you are not too tired, jump. If it is too far to jump at, creep closer. If you are too tired to jump, stay still. If no dragonfly is low, stay still and wait: a still gecko is not seen. Only when this spot is bad, walk to a new spot.\n\nSituation: You are not tired. A dragonfly is resting on a stick. It is close enough to jump at.",
  "questions": {
    "move": {
      "type": "choice",
      "instructions": "What should you do right now?",
      "criteria": {
        "jump": "Jump at the dragonfly. Do this when it is close enough and you can jump.",
        "creep": "Creep closer, slowly. Do this when a low dragonfly is too far to jump at.",
        "wait": "Stay still. Do this when you are too tired to jump. Also do this when no dragonfly is low.",
        "move": "Walk to a new spot. Do this only when this spot is bad."
      }
    }
  }
}

📩 THE ANSWER THAT CAME BACK

{
  "model": "decider-2b-v11",
  "answers": {
    "move": {
      "type": "choice",
      "choice": "jump",
      "confidence": 0.9799,
      "x_p_max": 0.9849,
      "certainty": 0.9323,
      "probabilities": {
        "jump": 0.9849,
        "creep": 0.0041,
        "wait": 0.0074,
        "move": 0.0035
      }
    }
  },
  "usage": {
    "input_tokens": 269,
    "output_tokens": 0
  }
}
LineWhat it does · ทำอะไร · 做什么
"state": "You are a gecko ... Situation: ..."One text. Who the animal is, its rules, and what it senses now.ข้อความหนึ่งก้อน บอกว่าสัตว์คือใคร กฎของมัน และสิ่งที่มันรับรู้ตอนนี้一段文字:动物是谁、它的规则、它现在感觉到什么。
"type": "choice"The model must pick one of the moves. It cannot say anything else.โมเดลต้องเลือกหนึ่งท่าจากรายการ จะพูดอย่างอื่นไม่ได้模型必须从这些动作里挑一个,别的什么都不能说。
"criteria": {"jump": "Jump at ...", ...}The moves on offer now. Each has a name and one sentence.ท่าที่เลือกได้ตอนนี้ แต่ละท่ามีชื่อและประโยคหนึ่งประโยค现在可以选的动作。每个动作有名字,还有一句话。
"choice": "jump"The move the model picked.ท่าที่โมเดลเลือก模型挑中的动作。
"probabilities": {"jump": 0.9849, ...}A number for each move. The four numbers add up to 1.ตัวเลขของแต่ละท่า ตัวเลขทั้งสี่รวมกันได้ 1每个动作一个数字。四个数字加起来是 1。
"input_tokens": 269, "output_tokens": 0The model read 269 small pieces of text. It wrote none.โมเดลอ่านข้อความชิ้นเล็ก ๆ 269 ชิ้น และไม่ได้เขียนอะไรออกมาเลย模型读了 269 个小文字片段,一个也没有写。
THE MODEL DOES NOT KNOW WHERE ANYTHING IS Look at the question again. There are no positions in it, and no distances. Only the page knows where each animal is. The page measures. Then it writes a sentence. The sentence is “It is close enough to jump at”. The model picks a move from those words. Then the page aims the jump. It knows where the dragonfly is. ดูคำถามอีกครั้ง ในนั้นไม่มีตำแหน่ง และไม่มีระยะทาง มีแต่หน้าเว็บที่รู้ว่าสัตว์แต่ละตัวอยู่ตรงไหน หน้าเว็บเป็นฝ่ายวัด แล้วเขียนออกมาเป็นประโยค: “It is close enough to jump at.” โมเดลเลือกท่าจากคำพวกนั้น จากนั้นหน้าเว็บเป็นฝ่ายเล็งการกระโดด เพราะมันรู้ว่าแมลงปออยู่ที่ไหน 再看一遍这个问题。里面没有位置,也没有距离。只有页面知道每只动物在哪里。页面先量一量,再写成一句话:“It is close enough to jump at.” 模型根据这些文字挑一个动作。然后由页面来瞄准这一跳,因为它知道蜻蜓在哪里。
The jobงาน工作Who does itใครทำ谁来做Whereที่ไหน在哪里
Move the bodies, 60 steps a secondขยับลำตัว 60 ก้าวต่อวินาที移动身体,每秒 60 步tick(), step()Your browserเบราว์เซอร์ของคุณ你的浏览器
Turn what an animal senses into wordsแปลงสิ่งที่สัตว์รับรู้ให้เป็นคำ把动物感觉到的东西变成文字question()Your browserเบราว์เซอร์ของคุณ你的浏览器
Pick one moveเลือกหนึ่งท่า挑一个动作decider-2bThe small computer's GPUGPU ของคอมพิวเตอร์เครื่องเล็ก那台小电脑的 GPU
Decide what the move does: a catch, a miss, tired legsตัดสินว่าท่านั้นทำให้เกิดอะไร: จับได้ พลาด หรือขาล้า决定这个动作的结果:抓到、扑空、腿累了take(), step()Your browserเบราว์เซอร์ของคุณ你的浏览器
Keep the score, the clock and the rulesเก็บคะแนน นาฬิกา และกฎ记比分、时间和规则gecko_dragonfly.htmlYour browserเบราว์เซอร์ของคุณ你的浏览器
Turn away a message that is too bigไม่รับข้อความที่ใหญ่เกินไป把太大的消息挡在门外door()The small computerคอมพิวเตอร์เครื่องเล็ก那台小电脑
Carry the messagesพาข้อความไปมา传递消息Tailscale FunnelThe internetอินเทอร์เน็ต互联网
WHY THE PICTURE NEVER WAITS A picture needs a new frame 60 times a second. An answer takes much longer than one frame. So the page never waits for the model. fetch sends the question, and the loop goes on. The animal keeps its old move until the answer comes. In hunt.py, a second thread did this job. ภาพต้องมีเฟรมใหม่ 60 ครั้งต่อวินาที แต่คำตอบหนึ่งข้อใช้เวลานานกว่าหนึ่งเฟรมมาก หน้าเว็บจึงไม่เคยหยุดรอโมเดล fetch ส่งคำถามออกไป แล้ววงวนก็เดินต่อ สัตว์ทำท่าเดิมต่อไปจนกว่าคำตอบจะมา ใน hunt.py เธรดที่สองทำหน้าที่นี้ 画面每秒需要 60 帧新画。一个回答花的时间比一帧长得多。所以页面从不停下来等模型。fetch 把问题发出去,循环照常往下走。回答没到之前,动物继续做原来的动作。在 hunt.py 里,做这件事的是第二个线程。
What was measuredสิ่งที่วัด量了什么The numberตัวเลข数字
Questions from one open pageคำถามจากหน้าเว็บที่เปิดอยู่หนึ่งหน้า一个打开的页面发出的问题About 6 a secondประมาณ 6 ข้อต่อวินาที每秒大约 6 个
The size of one questionขนาดของคำถามหนึ่งข้อ一个问题的大小About 800 to 1,000 bytesประมาณ 800 ถึง 1,000 ไบต์大约 800 到 1,000 字节
The size of one answerขนาดของคำตอบหนึ่งข้อ一个回答的大小About 250 bytesประมาณ 250 ไบต์大约 250 字节
The GPU's work for one questionเวลาที่ GPU ใช้กับคำถามหนึ่งข้อGPU 处理一个问题的时间About 57 msประมาณ 57 มิลลิวินาที大约 57 毫秒
The most it can answerจำนวนมากที่สุดที่ตอบได้最多能回答多少About 17 questions a secondประมาณ 17 ข้อต่อวินาที每秒大约 17 个问题
The model's place in the GPU's memoryหน่วยความจำ GPU ที่โมเดลใช้模型占用的 GPU 内存About 4 GBประมาณ 4 GB大约 4 GB

🧰 What the big game is made of · เกมใหญ่ทำมาจากอะไร · 大游戏是用什么做的

🇬🇧 English

No part was made for this game. Each part is a tool that many people use. The game only joins them.

The model and its library are free. You can read about them at github.com/Mapika/decider.

🇹🇭 ไทย

ไม่มีชิ้นส่วนไหนถูกสร้างขึ้นมาเพื่อเกมนี้ แต่ละชิ้นเป็นเครื่องมือที่คนจำนวนมากใช้กัน เกมนี้แค่นำมาต่อเข้าด้วยกัน

โมเดลและไลบรารีของมันใช้ได้ฟรี อ่านรายละเอียดได้ที่ github.com/Mapika/decider

🇨🇳 中文

没有哪个部件是专门为这个游戏做的。每一个都是很多人在用的工具。这个游戏只是把它们接到一起。

模型和它的库是免费的。可以到 github.com/Mapika/decider 了解它们。

Technologyเทคโนโลยี技术What it does in this gameทำอะไรในเกมนี้在这个游戏里做什么Where it isอยู่ที่ไหน在哪里
HTML, CSSThe page: the field, the score and the rule boxes.หน้าเว็บ: ทุ่งนา คะแนน และกล่องกฎ页面:田野、比分和规则框。gecko_dragonfly.html
<canvas>A rectangle in the page. The code paints it again for every frame.สี่เหลี่ยมในหน้าเว็บ โค้ดวาดมันใหม่ทุกเฟรม页面里的一块矩形。代码每一帧都把它重画一遍。gecko_dragonfly.html
JavaScriptThe whole game: bodies, agents and score. It uses no library.ทั้งเกม: ลำตัว เอเจนต์ และคะแนน ไม่ใช้ไลบรารีใดเลย整个游戏:身体、智能体和比分。没有用任何库。gecko_dragonfly.html
fetch, JSONSend a question and get the answer. The picture does not stop.ส่งคำถามและรับคำตอบ โดยที่ภาพไม่หยุด发出问题,收回回答,画面不会停。think()
HTTPSLocks each message on its way.ล็อกข้อความแต่ละก้อนระหว่างทาง给路上的每条消息上锁。The addressที่อยู่地址
Tailscale FunnelGives a computer at home a public address.ให้ที่อยู่สาธารณะกับคอมพิวเตอร์ที่อยู่ในบ้าน给家里的一台电脑一个公开的地址。The small computerคอมพิวเตอร์เครื่องเล็ก那台小电脑
DockerOne closed box. The server and its Python live in it.กล่องปิดหนึ่งกล่อง เซิร์ฟเวอร์กับ Python ของมันอยู่ข้างใน一个封闭的盒子。服务器和它的 Python 住在里面。run.sh
Python, FastAPI, uvicornThe web server. It takes questions and sends answers.เว็บเซิร์ฟเวอร์ รับคำถามและส่งคำตอบ网页服务器。它接收问题,送出回答。game_server.py
decider-aiA library. It writes each question the way the model learned it.ไลบรารี มันเขียนคำถามแต่ละข้อในแบบที่โมเดลเรียนมา一个库。它把每个问题写成模型学过的样子。In the boxในกล่อง盒子里
PyTorchDoes the model's maths on the GPU.คำนวณคณิตศาสตร์ของโมเดลบน GPU在 GPU 上做模型的数学运算。In the boxในกล่อง盒子里
decider-2bThe model. It is built on Qwen3.5-2B. It holds 1.9 billion numbers.ตัวโมเดล สร้างต่อยอดจาก Qwen3.5-2B ข้างในมีตัวเลข 1.9 พันล้านตัว模型本身。它建立在 Qwen3.5-2B 之上,里面有 19 亿个数字。On the GPUบน GPUGPU 上
NVIDIA DGX SparkThe small computer. Its GPU does the work.คอมพิวเตอร์เครื่องเล็ก GPU ของมันเป็นตัวทำงาน那台小电脑。干活的是它的 GPU。Chiang Maiเชียงใหม่清迈
A MODEL THAT WRITES NOTHING A chat model writes its answer word by word. That is slow, and the words can be anything. This model does not write. It reads once and gives each move a number. So an answer is fast. It is always one of your moves. โมเดลแชตเขียนคำตอบออกมาทีละคำ ซึ่งช้า และคำที่ออกมาเป็นอะไรก็ได้ โมเดลตัวนี้ไม่เขียน มันอ่านหนึ่งรอบแล้วให้ตัวเลขกับแต่ละท่า คำตอบจึงเร็ว และเป็นหนึ่งในท่าของคุณเสมอ 聊天模型一个词一个词地写出回答。这样很慢,而且写出来的可以是任何话。这个模型不写字。它读一遍,然后给每个动作一个数字。所以回答很快,而且一定是你给的动作之一。

📄 All the code of the big game · โค้ดทั้งหมดของเกมใหญ่ · 大游戏的全部代码

🇬🇧 English

The game is three files. One file is the page. Two files start the server.

You can run the page yourself. Download gecko_dragonfly.html and open it. Your copy asks the model in Chiang Mai. Change a number or a rule in your copy. Then load it again.

The two server files are for our computer. It has an NVIDIA GPU and Docker. Most readers will not run them. Read them to see how small a server is.

🇹🇭 ไทย

เกมนี้มีสามไฟล์ ไฟล์หนึ่งคือหน้าเว็บ อีกสองไฟล์ใช้เริ่มเซิร์ฟเวอร์

คุณรันหน้าเว็บเองได้ ดาวน์โหลด gecko_dragonfly.html แล้วเปิดมัน สำเนาของคุณจะถามโมเดลที่เชียงใหม่ ลองเปลี่ยนตัวเลขหรือกฎในสำเนาของคุณ แล้วโหลดใหม่อีกครั้ง

ไฟล์เซิร์ฟเวอร์สองไฟล์เขียนไว้สำหรับคอมพิวเตอร์ของเรา ซึ่งมี GPU ของ NVIDIA และ Docker ผู้อ่านส่วนใหญ่จะไม่ได้รันมัน แต่ลองอ่านดู จะเห็นว่าเซิร์ฟเวอร์เล็กแค่ไหน

🇨🇳 中文

这个游戏是三个文件。一个文件是页面。另外两个文件用来启动服务器。

页面你可以自己运行。下载 gecko_dragonfly.html,然后打开它。你的这份副本会去问清迈的那个模型。在你的副本里改一个数字或者一条规则,再重新加载。

两个服务器文件是为我们那台电脑写的,它有 NVIDIA 的 GPU 和 Docker。大多数读者不会去运行它们。读一读,看看一个服务器可以有多小。

In the page's code, look forในโค้ดของหน้าเว็บ ให้มองหา在页面的代码里,找What that part doesส่วนนั้นทำอะไร这一部分做什么
WHO, FIRST_RULES, MOVESThe words: who each animal is, its rules, and its moves.ถ้อยคำ: สัตว์แต่ละชนิดคือใคร กฎของมัน และท่าของมัน文字:每种动物是谁、它的规则、它的动作。
pull, walk, limit, oval, reachThe maths of this page, written again in JavaScript.คณิตศาสตร์ของหน้านี้ เขียนใหม่ด้วย JavaScript这一页的数学,用 JavaScript 重新写了一遍。
function Animal(kind, ...)One class for both animals. kind says which one.คลาสเดียวสำหรับสัตว์ทั้งสองชนิด kind บอกว่าเป็นชนิดไหน两种动物共用一个类。kind 说明是哪一种。
question()See. It turns what the animal senses into words.มอง แปลงสิ่งที่สัตว์รับรู้ให้เป็นคำ看。把动物感觉到的东西变成文字。
think()Ask. It sends the question and waits for the pick.ถาม ส่งคำถามออกไป แล้วรอท่าที่ถูกเลือก问。把问题发出去,等模型挑好。
take(), step()Act. They start the move and move the body.ลงมือ เริ่มท่าที่เลือก แล้วขยับลำตัว行动。开始这个动作,并移动身体。
draw()Paints the field and the animals on the canvas.วาดทุ่งนาและสัตว์ลงบน canvas把田野和动物画到 canvas 上。
tick()The one loop. It steps the world, lets the agents ask, and draws.วงวนหลักวงเดียว เดินโลกไปหนึ่งก้าว ให้เอเจนต์ถาม แล้ววาด唯一的主循环。让世界走一步,让智能体提问,然后画出来。

📄 gecko_dragonfly.html · the pageหน้าเว็บ页面

Show the whole file · แสดงทั้งไฟล์ · 显示整个文件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Geckos and dragonflies — animals that decide for themselves | Krueng AI</title>
<style>
  :root {
    --primary: #2a6f97; --primary-dark: #12263a; --primary-light: #61a5c2;
    --primary-pale: #e8f1f6; --accent: #a9d6e5; --ink: #1b2a36; --soft: #51616d;
    --gecko: #6b8fb3; --dragonfly: #ee6c4d; --good: #2e7d32; --warn: #b25e00;
  }
  * { box-sizing: border-box; }
  body { margin: 0; color: var(--ink); background: linear-gradient(135deg, #e8f1f6 0%, #f5f9fb 100%);
    font: 16px/1.55 -apple-system, 'Segoe UI', Roboto, 'Noto Sans', sans-serif; }
  header.top { background: linear-gradient(135deg, #12263a 0%, #2a6f97 100%); color: #fff; padding: 12px 18px 11px; }
  header.top h1 { margin: 0 0 2px; font-size: 1.4em; }
  header.top p { margin: 0; color: #d6e8f2; }
  main { max-width: 1400px; margin: 0 auto; padding: 12px 14px 40px; }
  .card { background: #fff; border-radius: 14px; padding: 16px 18px; box-shadow: 0 2px 10px rgba(18, 38, 58, 0.08); }
  .card h2 { margin: 0 0 10px; font-size: 1.12em; color: var(--primary-dark);
    border-bottom: 3px solid var(--primary); padding-bottom: 6px; }
  .card p { margin: 6px 0; }
  .intro { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
  .intro .card { border-left: 5px solid var(--primary); padding: 9px 14px; font-size: 0.94em; line-height: 1.4; }
  .intro b { display: block; color: var(--primary); font-size: 0.82em; letter-spacing: 0.05em; text-transform: uppercase; }

  .play { display: grid; grid-template-columns: minmax(0, 2.3fr) minmax(0, 1fr); gap: 14px; align-items: start; }
  .play > * { min-width: 0; }
  .stage { padding: 12px; }
  .score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin-bottom: 8px; }
  .team { display: flex; align-items: center; gap: 8px; font-weight: 700; }
  .team.right { justify-content: flex-end; }
  .team .dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 0 1px #9fb3c0; flex: none; }
  .team .points { font-size: 1.6em; line-height: 1; font-variant-numeric: tabular-nums; }
  .team small { display: block; font-weight: 500; color: var(--soft); font-size: 0.78em; }
  .clock { font: 700 1.25em/1 'Fira Code', Consolas, Menlo, monospace; color: var(--primary-dark);
    background: var(--primary-pale); border-radius: 8px; padding: 7px 12px; }
  .field-box { position: relative; }
  canvas#field { display: block; width: 100%; height: auto; border-radius: 10px; background: #1b2a22; touch-action: none; cursor: pointer; }
  .banner { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; text-align: center;
    background: rgba(18, 38, 58, 0.72); color: #fff; border-radius: 10px; padding: 16px; }
  .banner.on { display: flex; }
  .banner strong { display: block; font-size: 1.7em; margin-bottom: 6px; }
  .tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px; }
  button { font: 600 0.92em/1 inherit; font-family: inherit; color: var(--primary-dark); background: #fff;
    border: 1.5px solid var(--primary); border-radius: 8px; padding: 9px 13px; cursor: pointer; }
  button:hover { background: var(--primary); color: #fff; }
  button.on { background: var(--primary); color: #fff; }
  .model { flex: 1; min-width: 200px; font-size: 0.86em; color: var(--soft); text-align: right; }
  .model.down { color: #b3261e; font-weight: 700; }
  .legend { font-size: 0.86em; color: var(--soft); margin: 8px 2px 0; }

  .side { display: grid; gap: 14px; }
  .side > * { min-width: 0; }
  ul.animals { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px 8px; }
  ul.animals li { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; align-items: center; gap: 7px;
    padding: 4px 6px; border-radius: 8px; border: 1.5px solid transparent; cursor: pointer; min-width: 0; }
  ul.animals li:hover { background: var(--primary-pale); }
  ul.animals li.picked { border-color: var(--primary); background: var(--primary-pale); }
  ul.animals .dot { width: 12px; height: 12px; border-radius: 50%; }
  ul.animals .who { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  ul.animals .gauge { height: 4px; border-radius: 2px; background: #dbe6ec; margin-top: 3px; overflow: hidden; }
  ul.animals .gauge i { display: block; height: 100%; background: var(--primary-light); }
  ul.animals .does { font: 600 0.82em/1 'Fira Code', Consolas, Menlo, monospace; color: var(--primary-dark);
    background: #eef4f7; border-radius: 6px; padding: 5px 7px; white-space: nowrap; }
  ul.animals .does.you { background: #fff3cd; color: #7a5200; }

  .thought .tally { margin: -4px 0 4px; font-size: 0.86em; color: var(--soft); }
  .thought .saw { background: var(--primary-dark); color: #e3eef5; border-radius: 10px; padding: 10px 12px; margin: 8px 0; font-size: 0.95em; }
  .thought .label { font-size: 0.76em; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: var(--primary); margin-top: 10px; }
  .choice { display: grid; grid-template-columns: 5.2em minmax(0, 1fr) 3.2em; align-items: center; gap: 8px; margin: 5px 0; font-size: 0.92em; }
  .choice .name { font-weight: 700; }
  .choice .track { height: 12px; background: #e3ecf1; border-radius: 6px; overflow: hidden; }
  .choice .track i { display: block; height: 100%; background: var(--primary-light); border-radius: 6px; }
  .choice.won .track i { background: var(--good); }
  .choice.won .name { color: var(--good); }
  .choice .share { text-align: right; font-variant-numeric: tabular-nums; color: var(--soft); }
  .thought .took { font-size: 0.84em; color: var(--soft); }
  .thought details { margin-top: 10px; font-size: 0.86em; }
  .thought summary { cursor: pointer; color: var(--primary); font-weight: 600; }
  .thought pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f5f9fb; border: 1px solid #cfe0ea;
    border-radius: 8px; padding: 10px; margin: 8px 0 0; font: 0.95em/1.5 'Fira Code', Consolas, Menlo, monospace; }

  .below { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 14px; }
  .below > * { min-width: 0; }
  .coach label { display: block; font-weight: 700; margin: 10px 0 4px; }
  .coach textarea { width: 100%; min-height: 7.2em; resize: vertical; border: 1.5px solid #b9cdd9; border-radius: 8px;
    padding: 9px 10px; font: 0.95em/1.5 inherit; font-family: inherit; color: var(--ink); }
  .coach textarea:focus { outline: 2px solid var(--primary-light); border-color: var(--primary); }
  .coach .row { display: flex; justify-content: flex-end; margin-top: 6px; }
  .how ol { margin: 6px 0 0; padding-left: 22px; }
  .how li { margin: 5px 0; }
  .how a { color: var(--primary); }
  .note { background: #fff8e1; border: 2px solid #ff9500; border-radius: 10px; padding: 9px 12px; margin-top: 10px; font-size: 0.94em; }
  footer { text-align: center; color: var(--soft); font-size: 0.86em; padding: 0 14px 28px; }

  @media (max-width: 980px) {
    .play, .below, .intro { grid-template-columns: minmax(0, 1fr); }
    .model { text-align: left; }
  }
</style>
</head>
<body>

<header class="top">
  <h1>Geckos and dragonflies</h1>
  <p>Nine animals. Each one asks an AI model what to do.</p>
</header>

<main>
  <div class="intro">
    <div class="card"><b>What you see</b>Many AI agents work at the same time. Each animal has its own agent.</div>
    <div class="card"><b>What you do</b>Watch them. Coach a team with new rules. Or drive one animal yourself.</div>
    <div class="card"><b>What it needs</b>The model runs on one small computer in Chiang Mai. Many players at once make it slow.</div>
  </div>

  <div class="play">
    <section class="card stage">
      <div class="score">
        <div class="team"><span class="dot" style="background:var(--gecko)"></span>
          <div><span class="points" id="geckoPoints">0</span> Geckos<small id="geckoCount">0 catches</small></div></div>
        <div class="clock" id="clock">3:00</div>
        <div class="team right"><div style="text-align:right"><span class="points" id="dragonflyPoints">0</span> Dragonflies
          <small id="dragonflyCount">0 midges</small></div><span class="dot" style="background:var(--dragonfly)"></span></div>
      </div>
      <div class="field-box">
        <canvas id="field" width="1200" height="720"
          aria-label="A rice field seen from above. Geckos hunt dragonflies. Dragonflies eat midges and rest on sticks."></canvas>
        <div class="banner" id="banner"><div><strong id="bannerTop"></strong><span id="bannerText"></span></div></div>
      </div>
      <div class="tools">
        <button id="pause" type="button">Pause</button>
        <button id="again" type="button">New round</button>
        <button id="drive" type="button" hidden>Drive it yourself</button>
        <span class="model" id="model">Asking the model…</span>
      </div>
      <p class="legend">A catch is 5 points. A midge is 1 point. A dragonfly sees a moving gecko, but never behind itself.
        Click an animal to see its last choice. The ring is how far a gecko jumps. The dark fan is where a dragonfly cannot see.</p>
    </section>

    <div class="side">
      <section class="card">
        <h2>The animals</h2>
        <ul class="animals" id="animals"></ul>
      </section>
      <section class="card thought" id="thought">
        <h2 id="thoughtName">Pick an animal</h2>
        <p class="tally" id="thoughtTally"></p>
        <div id="thoughtBody"><p>Click an animal in the field or in the list.</p></div>
      </section>
    </div>
  </div>

  <div class="below">
    <section class="card coach">
      <h2>Be the coach</h2>
      <p>Each team has rules in plain English. The model reads them every time. Change a rule and watch the team change.</p>
      <label for="rulesGecko">Rules for the geckos</label>
      <textarea id="rulesGecko" spellcheck="false" maxlength="3000"></textarea>
      <div class="row"><button type="button" data-reset="gecko">Put the gecko rules back</button></div>
      <label for="rulesDragonfly">Rules for the dragonflies</label>
      <textarea id="rulesDragonfly" spellcheck="false" maxlength="3000"></textarea>
      <div class="row"><button type="button" data-reset="dragonfly">Put the dragonfly rules back</button></div>
    </section>

    <section class="card how">
      <h2>How it works</h2>
      <ol>
        <li>The page moves the bodies. It uses the maths from
          <a href="https://krueng.ai/procedural_animation.html">Animals that move by themselves</a>.</li>
        <li>Every 1.5 seconds each animal says what it sees. It says it in words.</li>
        <li>It sends three things to the model. They are its rules, those words and its moves.</li>
        <li>The model picks one move. It also says how sure it is.</li>
        <li>The model is <b>decider-2b</b>. It is free. It runs on the Spark's GPU.</li>
        <li>Read how this game is built. All its code is there too:
          <a href="https://krueng.ai/procedural_animation.html#game">Tutorial 6</a>.</li>
      </ol>
      <div class="note">The model does not plan and it does not count. It picks the move that fits the words.
        So the page turns numbers into words first. It says “close enough to jump at”, not “96 steps”.</div>
    </section>
  </div>
</main>

<footer>Krueng AI · a test page on the DGX Spark</footer>

<script>
/* Geckos and dragonflies.

   The bodies move with the same maths as procedural_animation.html: pull, walk,
   limit, oval, reach. What is new here is the head. Each animal has an agent, and
   the agent asks a small decision model (decider-2b, on the Spark) which move to
   take. The page turns what the animal senses into words, the model picks a move,
   and the page moves the body. */
(function(){
  'use strict';

  var WIDE = 1200, HIGH = 720, QUARTER = Math.PI / 2, BEND = Math.PI / 8;
  var STIFF = Math.PI / 16;     // a gecko's back bends half as far, so it cannot curl up over its own legs
  var STEPS = 60;               // steps of the world in one second

  /* ---- the model ---- */
  // The decider server. The game's own server sends this page as "/" or as "/gecko"
  // (typed with or without the last slash), and answers questions next to it.
  // A copy of this page has a file name that ends in .html and no server next to it,
  // so a copy asks the game's public address.
  var PUBLIC = 'https://spark-wav2lip-1.taila00da8.ts.net/gecko/';
  var COPY = /\.html?$/.test(location.pathname);
  var MODEL = (COPY ? PUBLIC : location.pathname.replace(/\/?$/, '/')) + 'v1/systemone';
  var ASK = 'What should you do right now?';
  var ASK_EVERY = 1500;         // ms between two questions from one animal
  var ASK_GAP = 120;            // an animal never asks again sooner than this

  /* ---- the game ---- */
  var ROUND = 180;              // seconds in a round
  var CATCH = 5;                // points for a catch; a midge is 1
  var JUMP = 130;               // how far a gecko's jump reaches
  var CREEP_TO = 320;           // a gecko can creep to a dragonfly this near
  var CLOSE = 170;              // a gecko the dragonfly sees this near is "close"
  var SEEN = {still: 70, creep: 120, walk: 300, jump: 300};   // how far a gecko is seen
  var BLIND = Math.PI * 60 / 180;   // a dragonfly sees nothing this far to each side of straight behind
  var BAD_SPOT = 25;            // seconds with nothing low nearby before a spot is "bad"
  var WIND_UP = 14, DASH = 13, DASH_SPEED = 11, LANDING = 70;  // a jump, in steps
  var JUMP_COST = 50;
  var MIDGES = 5, EAT_EVERY = 120, SWARMS = 2, SWARM_BACK = 10 * STEPS;
  var BACK_IN = 4 * STEPS;      // a caught dragonfly is replaced after this

  var WHO = {
    gecko: 'You are a gecko in a rice field. You hunt dragonflies. You can only catch a low dragonfly. ' +
           'A dragonfly is low when it rests or eats. Dragonflies see a moving gecko. They do not see a still gecko.',
    dragonfly: 'You are a dragonfly over a rice field. You eat midges. Flying uses energy. Resting on a stick ' +
               'gives it back. Geckos hunt you. A gecko can catch you only when you are low. ' +
               'You are low when you rest or eat.'
  };
  var FIRST_RULES = {
    gecko: 'If a low dragonfly is close enough and you are not too tired, jump. If it is too far to jump at, ' +
           'creep closer. If you are too tired to jump, stay still. If no dragonfly is low, stay still and wait: ' +
           'a still gecko is not seen. Only when this spot is bad, walk to a new spot.',
    dragonfly: 'If a gecko is close, fly away. If you are very tired, land on a stick and do not feed. ' +
               'If you are still tired, stay and rest. If you are not tired, feed at a swarm. ' +
               'If there is no swarm, look around.'
  };
  var rules = {gecko: FIRST_RULES.gecko, dragonfly: FIRST_RULES.dragonfly};
  // says: the word shown over the animal.  means: what the model is told the move is for.
  var MOVES = {
    jump:  {says: 'Jump!',    means: 'Jump at the dragonfly. Do this when it is close enough and you can jump.'},
    creep: {says: 'creep',    means: 'Creep closer, slowly. Do this when a low dragonfly is too far to jump at.'},
    wait:  {says: 'wait',     means: 'Stay still. Do this when you are too tired to jump. Also do this when no dragonfly is low.'},
    move:  {says: 'new spot', means: 'Walk to a new spot. Do this only when this spot is bad.'},
    eat:   {says: 'eat',      means: 'Feed at the swarm. Do this only when you are not tired, or rested.'},
    rest:  {says: 'rest',     means: 'Land on a stick. Do this when you are very tired.'},
    stay:  {says: 'stay',     means: 'Stay and rest. Do this when you are still tired and no gecko is close.'},
    flee:  {says: 'Away!',    means: 'Fly up and away. Do this when a gecko is close to you.'},
    look:  {says: 'look',     means: 'Fly high and look around. Do this when there is no swarm.'}
  };

  var SHAPES = {
    gecko: {widths: [8.5, 9.5, 6.5, 10, 11, 11.5, 10.5, 8, 4.5, 3, 2.5, 2, 1.5, 1.5], gap: 10},
    dragonfly: {widths: [4.2, 5.2, 4.4, 2.3, 2.1, 2.1, 2, 2, 1.8, 1.5], gap: 6}
  };
  var BONE = 12, FOOT = 18, STRIDE = 30;       // a gecko's legs
  var TEAM = [
    ['gecko', 'Kham', '#6b8fb3'], ['gecko', 'Tong', '#86a8c9'], ['gecko', 'Jai', '#5c7fa3'],
    ['dragonfly', 'Fah', '#3a9bd5'], ['dragonfly', 'Dao', '#f2c14e'], ['dragonfly', 'Mali', '#ee6c4d'],
    ['dragonfly', 'Bua', '#ff8fab'], ['dragonfly', 'Ploy', '#b98cf0'], ['dragonfly', 'Nam', '#8bd17c']
  ];
  var STICKS = [[150, 140], [430, 100], [770, 150], [1050, 120], [250, 420], [600, 350], [930, 440], [1070, 610], [400, 620], [720, 590]];

  /* ---------- the maths, the same as on the moving-animals page ---------- */
  function dist(a, b){ return Math.hypot(a[0] - b[0], a[1] - b[1]); }
  function pull(anchor, point, gap){
    var far = dist(anchor, point);
    if (far === 0) return point;
    var scale = gap / far;
    return [anchor[0] + (point[0] - anchor[0]) * scale, anchor[1] + (point[1] - anchor[1]) * scale];
  }
  function angleTo(start, end){ return Math.atan2(end[1] - start[1], end[0] - start[0]); }
  function walk(start, angle, far){
    return [start[0] + Math.cos(angle) * far, start[1] + Math.sin(angle) * far];
  }
  function limit(angle, middle, most){
    var full = 2 * Math.PI;
    var away = ((angle - middle + Math.PI) % full + full) % full - Math.PI;
    if (away > most) return middle + most;
    if (away < -most) return middle - most;
    return angle;
  }
  function oval(centre, angle, long, wide){
    var edge = [];
    for (var k = 0; k < 12; k++){
      var around = k * Math.PI / 6;
      var along = walk(centre, angle, Math.cos(around) * long);
      edge.push(walk(along, angle + QUARTER, Math.sin(around) * wide));
    }
    return edge;
  }
  function reach(joints, start, end, bone){
    var last = joints.length - 1, i;
    joints[last] = end;
    for (i = last - 1; i >= 0; i--) joints[i] = pull(joints[i + 1], joints[i], bone);
    joints[0] = start;
    for (i = 1; i <= last; i++) joints[i] = pull(joints[i - 1], joints[i], bone);
  }
  function apart(a, b){                 // the angle between two directions, 0 to pi
    var full = 2 * Math.PI;
    return Math.abs(((a - b + Math.PI) % full + full) % full - Math.PI);
  }
  function inside(point, edge){
    return [Math.min(WIDE - edge, Math.max(edge, point[0])), Math.min(HIGH - edge, Math.max(edge, point[1]))];
  }
  function anyOf(list){ return list[Math.floor(Math.random() * list.length)]; }
  function nearest(from, list, where){
    var best = null, far = Infinity;
    list.forEach(function(item){
      var d = dist(from, where(item));
      if (d < far){ far = d; best = item; }
    });
    return best;
  }

  /* ---------- the field: sticks, swarms, the score ---------- */
  var world = {
    animals: [], sticks: [], swarms: [], floats: [],
    points: {gecko: 0, dragonfly: 0}, catches: 0, jumps: 0, midges: 0,
    left: ROUND * STEPS, step: 0, paused: false, over: 0,
    picked: null, driven: null, pointer: [WIDE / 2, HIGH / 2]
  };

  function newSwarm(){
    var at, tries = 0;
    do {
      at = [130 + Math.random() * (WIDE - 260), 110 + Math.random() * (HIGH - 220)];
      tries++;
    } while (tries < 40 && world.swarms.some(function(s){ return dist(s.at, at) < 220; }));
    var midges = [];
    for (var i = 0; i < MIDGES; i++) midges.push(Math.random() * 6.28);
    return {at: at, left: MIDGES, back: 0, midges: midges};
  }
  function liveSwarms(){ return world.swarms.filter(function(s){ return s.left > 0; }); }
  function float(text, at, colour){ world.floats.push({text: text, at: [at[0], at[1]], age: 0, colour: colour}); }

  /* ---------- one class for both animals; kind says which ---------- */
  function Animal(kind, name, colour){
    this.kind = kind; this.name = name; this.colour = colour;
    this.widths = SHAPES[kind].widths; this.gap = SHAPES[kind].gap;
    this.driver = 'model';        // or 'you'
    this.asking = false; this.nextAsk = 0; this.lastAsk = 0; this.life = 0;
    this.last = null;             // the last question and answer, for the panel
    this.says = ''; this.saysFor = 0;
    this.place(kind === 'gecko' ? this.edgeSpot() : [200 + Math.random() * (WIDE - 400), 150 + Math.random() * (HIGH - 300)]);
  }
  Animal.prototype.edgeSpot = function(){
    return anyOf([[60 + Math.random() * 80, 80 + Math.random() * (HIGH - 160)],
                  [WIDE - 140 + Math.random() * 80, 80 + Math.random() * (HIGH - 160)],
                  [150 + Math.random() * (WIDE - 300), 50 + Math.random() * 60],
                  [150 + Math.random() * (WIDE - 300), HIGH - 110 + Math.random() * 60]]);
  };
  Animal.prototype.place = function(at){
    var facing = angleTo(at, [WIDE / 2, HIGH / 2]), i;
    this.points = []; this.angles = [];
    for (i = 0; i < this.widths.length; i++){
      this.points.push(walk(at, facing, -i * this.gap));
      this.angles.push(facing);
    }
    this.life++;                  // an answer for an earlier life is thrown away
    this.gone = 0;
    if (this.kind === 'gecko'){
      this.stamina = 100; this.move = 'wait'; this.mode = 'still'; this.timer = 0;
      this.spot = null; this.waited = 0; this.catches = 0; this.jumps = 0;
      this.legs = [];
      this.addLeg(3, -1, 0); this.addLeg(3, 1, STRIDE / 2);
      this.addLeg(7, -1, STRIDE / 2); this.addLeg(7, 1, 0);
    } else {
      this.energy = 60 + Math.random() * 40; this.move = 'look'; this.high = 1;
      this.stick = null; this.swarm = null; this.perched = false; this.feeding = false;
      this.grounded = false; this.aim = null; this.fast = false; this.eatIn = EAT_EVERY;
      this.meals = this.meals || 0; this.beat = Math.random() * 6;
    }
  };
  Animal.prototype.head = function(){ return this.points[0]; };
  Animal.prototype.body = function(){ return this.points[1]; };

  /* steer the head toward a point, then let the body follow */
  Animal.prototype.go = function(aim, speed, turn){
    var i, wanted;
    if (speed <= 0) return;
    if (dist(this.points[0], aim) > 1){
      wanted = angleTo(this.points[0], aim);
      this.angles[0] = limit(wanted, this.angles[0], turn);
    }
    this.points[0] = inside(walk(this.points[0], this.angles[0], speed), 14);
    for (i = 1; i < this.points.length; i++){
      wanted = angleTo(this.points[i], this.points[i - 1]);
      this.angles[i] = limit(wanted, this.angles[i - 1], this.kind === 'gecko' ? STIFF : BEND);
      this.points[i] = walk(this.points[i - 1], this.angles[i], -this.gap);
    }
  };

  /* ---- legs (geckos) ---- */
  Animal.prototype.addLeg = function(spot, side, behind){
    var leg = {spot: spot, side: side};
    var places = this.legPlaces(leg);
    leg.ground = walk(places[1], this.angles[spot], -behind);
    leg.joints = [places[0], places[0], leg.ground];
    this.legs.push(leg);
  };
  Animal.prototype.legPlaces = function(leg){
    var facing = this.angles[leg.spot];
    var hip = walk(this.points[leg.spot], facing + leg.side * QUARTER, this.widths[leg.spot] - 3);
    return [hip, walk(hip, facing + leg.side * QUARTER / 2, FOOT)];
  };
  Animal.prototype.moveLegs = function(){
    for (var n = 0; n < this.legs.length; n++){
      var leg = this.legs[n], places = this.legPlaces(leg);
      if (dist(leg.ground, places[1]) > STRIDE) leg.ground = places[1];
      var foot = leg.joints[2];
      var x = foot[0] + (leg.ground[0] - foot[0]) * 0.4;
      var y = foot[1] + (leg.ground[1] - foot[1]) * 0.4;
      reach(leg.joints, places[0], [x, y], BONE);
    }
  };

  /* ---------- what an animal senses ---------- */
  Animal.prototype.low = function(){ return this.kind === 'dragonfly' && !this.gone && this.high < 0.5; };
  // how easy this gecko is to see: still, creep, walk or jump
  Animal.prototype.loud = function(){
    if (this.mode === 'wind' || this.mode === 'dash' || this.mode === 'land') return 'jump';
    return this.mode;
  };
  Animal.prototype.lowDragonfly = function(){
    var from = this.head();
    return nearest(from, world.animals.filter(function(a){ return a.low(); }), function(a){ return a.body(); });
  };
  // does this dragonfly see that gecko?  Near enough for how much the gecko moves,
  // and not behind: a dragonfly has a blind spot behind its tail.
  Animal.prototype.sees = function(gecko){
    if (dist(this.head(), gecko.head()) > SEEN[gecko.loud()]) return false;
    var behind = this.angles[0] + Math.PI;
    return apart(angleTo(this.body(), gecko.head()), behind) > BLIND;
  };
  Animal.prototype.geckoSeen = function(){
    var me = this, seen = world.animals.filter(function(a){ return a.kind === 'gecko' && me.sees(a); });
    return nearest(this.head(), seen, function(a){ return a.head(); });
  };

  /* ---------- the question: who I am, my rules, what I see, my moves ---------- */
  Animal.prototype.question = function(){
    var words, offers, target, d, gecko;
    if (this.kind === 'gecko'){
      words = this.stamina >= 70 ? 'You are not tired.' :
              this.stamina >= JUMP_COST ? 'You are a little tired, but you can still jump.' : 'You are too tired to jump.';
      target = this.lowDragonfly();
      if (!target){
        words += ' No dragonfly is low now. ' + (this.waited >= BAD_SPOT * STEPS ?
          'This spot is bad: nothing came for a long time.' : 'This spot is still good.');
        offers = ['wait', 'move'];
      } else {
        d = dist(this.head(), target.body());
        words += target.feeding ? ' A dragonfly is eating at a swarm.' :
                 target.grounded ? ' A dragonfly is resting on the ground.' : ' A dragonfly is resting on a stick.';
        words += d <= JUMP ? ' It is close enough to jump at.' :
                 d <= CREEP_TO ? ' It is too far to jump at. You can creep to it.' : ' It is far away.';
        offers = (this.stamina >= JUMP_COST ? ['jump'] : []).concat(['creep', 'wait', 'move']);
      }
    } else {
      var down = this.perched || this.grounded;
      var atSwarm = this.feeding && this.swarm && this.swarm.left > 0;
      words = this.perched ? 'You are on a stick.' : this.grounded ? 'You are on the ground.' :
              atSwarm ? 'You are at a swarm.' : 'You are flying.';
      if (down) words += this.energy < 90 ? ' You are still tired.' : ' You are rested.';
      else words += this.energy < 25 ? ' You are very tired and must land soon.' : ' You are not tired.';
      var food = liveSwarms().length > 0;
      if (!atSwarm) words += food ? ' A swarm of midges is near.' : ' There is no swarm now.';
      gecko = this.geckoSeen();
      words += !gecko ? ' You see no gecko.' :
               dist(this.head(), gecko.head()) <= CLOSE ? ' A gecko is close to you.' : ' A gecko is far away.';
      offers = (food ? ['eat'] : []).concat([down ? 'stay' : 'rest'], gecko ? ['flee'] : [], ['look']);
    }
    var criteria = {};
    offers.forEach(function(name){ criteria[name] = MOVES[name].means; });
    return {
      words: words, offers: offers, criteria: criteria,
      state: WHO[this.kind] + ' Rules: ' + rules[this.kind].trim() + '\n\nSituation: ' + words
    };
  };

  /* may this animal ask now?  A gecko in a jump, or on its way to a new spot, does not. */
  Animal.prototype.mayAsk = function(){
    if (this.gone || this.driver === 'you') return false;
    if (this.kind === 'gecko'){
      if (this.mode === 'wind' || this.mode === 'dash' || this.mode === 'land') return false;
      if (this.move === 'move' && this.spot){
        var target = this.lowDragonfly();
        return !!target && dist(this.head(), target.body()) <= CREEP_TO;
      }
    }
    return true;
  };
  Animal.prototype.askSoon = function(){ this.nextAsk = 0; };

  /* ---------- taking a move ---------- */
  Animal.prototype.take = function(move){
    var changed = move !== this.move, target;
    this.move = move;
    if (changed || move === 'jump' || move === 'flee'){ this.says = MOVES[move].says; this.saysFor = 70; }
    if (this.kind === 'gecko'){
      if (move === 'jump'){
        target = this.lowDragonfly();
        if (target && this.stamina >= JUMP_COST && this.mode !== 'wind' && this.mode !== 'dash' && this.mode !== 'land'){
          this.stamina -= JUMP_COST; this.jumps++; world.jumps++;
          this.jumpAt = angleTo(this.head(), target.body());
          this.mode = 'wind'; this.timer = WIND_UP;
          var me = this;                    // a dragonfly that sees the jump may think again at once
          world.animals.forEach(function(a){ if (a.low() && a.sees(me)) a.askSoon(); });
        } else {
          this.move = 'wait';
        }
      }
      if (move === 'move' && (changed || !this.spot)){
        var places = world.sticks.map(function(s){ return s.at; })
          .concat(liveSwarms().map(function(s){ return s.at; }));
        var here = this.head();
        var far = places.filter(function(p){ return dist(p, here) > 260; });
        this.spot = inside(walk(anyOf(far.length ? far : places), Math.random() * 6.28, 75 + Math.random() * 35), 30);
      }
      if (move !== 'move') this.spot = null;
    } else {
      if (move !== 'rest' && move !== 'stay') this.leaveStick();
      if (move !== 'eat'){ this.swarm = null; this.feeding = false; }
      if (move === 'rest' && !this.stick){
        var mine = this, free = world.sticks.filter(function(s){ return !s.owner; });
        this.stick = nearest(this.head(), free, function(s){ return s.at; });
        if (this.stick) this.stick.owner = mine; else this.move = 'look';
      }
      if (move === 'stay' && !this.perched && !this.grounded) this.move = 'look';
      if (move === 'flee'){
        var gecko = this.geckoSeen() || nearest(this.head(), world.animals.filter(function(a){ return a.kind === 'gecko'; }),
                                               function(a){ return a.head(); });
        this.aim = this.awayFrom(gecko.head());
        this.grounded = false;
      }
      if (move === 'look' && (changed || !this.aim)) this.aim = this.somewhere();
      if (move === 'look' || move === 'eat') this.grounded = false;
    }
  };
  Animal.prototype.leaveStick = function(){
    if (this.stick){ this.stick.owner = null; this.stick = null; }
    this.perched = false;
  };
  Animal.prototype.somewhere = function(){
    return [120 + Math.random() * (WIDE - 240), 100 + Math.random() * (HIGH - 200)];
  };
  Animal.prototype.awayFrom = function(danger){
    var me = this.head(), best = null, far = -1;
    for (var k = 0; k < 8; k++){              // eight ways out; take the one that ends furthest from the gecko
      var to = inside(walk(me, angleTo(danger, me) + (k - 4) * Math.PI / 6, 320), 50);
      var d = dist(to, danger) - Math.abs(k - 4) * 12;
      if (d > far){ far = d; best = to; }
    }
    return best;
  };

  /* ---------- one step of the body ---------- */
  Animal.prototype.step = function(){
    if (this.saysFor > 0) this.saysFor--;
    if (this.gone){
      if (--this.gone === 0){                  // a new dragonfly flies in from the edge
        this.place(this.edgeSpot());
        this.energy = 100;
        this.askSoon();
      }
      return;
    }
    if (this.kind === 'gecko') this.stepGecko(); else this.stepDragonfly();
  };

  Animal.prototype.stepGecko = function(){
    var target = this.lowDragonfly(), far = target ? dist(this.head(), target.body()) : Infinity;
    var you = this.driver === 'you', toPointer = dist(this.head(), world.pointer);

    if (this.mode === 'wind'){
      if (--this.timer <= 0){ this.mode = 'dash'; this.timer = DASH; this.angles[0] = this.jumpAt; }
    } else if (this.mode === 'dash'){
      this.go(walk(this.head(), this.jumpAt, 60), DASH_SPEED, 0.05);
      var me = this, hit = null;
      world.animals.forEach(function(a){ if (a.low() && dist(me.head(), a.body()) < 20) hit = a; });
      if (hit) this.catchIt(hit);
      if (--this.timer <= 0 || hit){ this.mode = 'land'; this.timer = LANDING; }
    } else if (this.mode === 'land'){
      if (--this.timer <= 0){ this.mode = 'still'; this.move = 'wait'; this.waited = 0; this.askSoon(); }
    } else if (you){
      if (toPointer > 170){ this.mode = 'walk'; this.go(world.pointer, 2.4, 0.15); }
      else if (toPointer > 26){ this.mode = 'creep'; this.go(world.pointer, 1.0, 0.12); }
      else this.mode = 'still';
    } else if (this.move === 'creep' && target && far > JUMP - 6){
      this.mode = 'creep'; this.go(target.body(), 1.0, 0.12);
    } else if (this.move === 'move' && this.spot && dist(this.head(), this.spot) > 24){
      this.mode = 'walk'; this.go(this.spot, 2.4, 0.15);
    } else {
      if (this.move === 'move'){ this.move = 'wait'; this.spot = null; this.waited = 0; this.askSoon(); }
      this.mode = 'still';
    }

    if (this.mode === 'still' || this.mode === 'land') this.stamina += 4 / STEPS;
    else if (this.mode === 'creep') this.stamina += 1 / STEPS;
    this.stamina = Math.min(100, this.stamina);
    // a spot goes bad when nothing low comes within creeping range for a long time
    if (this.mode === 'still' && far > CREEP_TO) this.waited++;
    else if (far <= CREEP_TO) this.waited = 0;
    this.moveLegs();
  };

  Animal.prototype.jumpNow = function(angle){        // a jump you start yourself
    if (this.stamina < JUMP_COST || this.mode === 'wind' || this.mode === 'dash' || this.mode === 'land') return;
    this.stamina -= JUMP_COST; this.jumps++; world.jumps++;
    this.jumpAt = angle; this.mode = 'wind'; this.timer = WIND_UP;
    this.says = MOVES.jump.says; this.saysFor = 70;
    var me = this;
    world.animals.forEach(function(a){ if (a.low() && a.sees(me)) a.askSoon(); });
  };
  Animal.prototype.catchIt = function(prey){
    this.catches++; world.catches++; world.points.gecko += CATCH;
    float('+' + CATCH + '  ' + this.name + ' caught ' + prey.name, prey.body(), '#ffd166');
    prey.leaveStick(); prey.swarm = null; prey.feeding = false; prey.gone = BACK_IN;
    if (world.driven === prey) world.driven = null;
    prey.driver = 'model';
    this.says = 'Got it!'; this.saysFor = 90;
  };

  Animal.prototype.stepDragonfly = function(){
    var you = this.driver === 'you', me = this, speed = 0, aim = this.aim || this.head(), turn = 0.22, wantHigh = 1, i;
    this.beat += 1;

    if (this.energy <= 0 && !this.perched && !this.grounded){      // out of energy: it falls
      this.leaveStick(); this.swarm = null; this.feeding = false; this.grounded = true;
      this.says = 'too tired!'; this.saysFor = 90;
      if (!you) this.move = 'stay';
      this.askSoon();
    }
    if (you && !this.grounded){
      // you fly it: it eats inside a swarm and lands on a free stick under the pointer
      var here = this.head(), s = nearest(here, liveSwarms(), function(x){ return x.at; });
      var stick = nearest(world.pointer, world.sticks, function(x){ return x.at; });
      this.feeding = !!s && dist(here, s.at) < 26;
      this.swarm = this.feeding ? s : null;
      var onStick = stick && (!stick.owner || stick.owner === this) && dist(world.pointer, stick.at) < 16;
      if (onStick && dist(here, stick.at) < 5){
        if (!this.perched){ this.leaveStick(); this.stick = stick; stick.owner = this; this.perched = true; }
      } else if (this.perched) this.leaveStick();
      aim = onStick ? stick.at : world.pointer;
      speed = this.perched ? 0 : Math.min(4, dist(here, aim) / 10);
      wantHigh = (this.feeding || this.perched) ? 0 : 1;
    } else if (this.grounded){
      wantHigh = 0;
      if (you && this.energy >= 30) this.grounded = false;
    } else if (this.move === 'rest' || this.move === 'stay'){
      if (this.stick){
        var d = dist(this.head(), this.stick.at);
        if (d < 6) this.perched = true;
        aim = this.stick.at; speed = this.perched ? 0 : Math.min(3.6, Math.max(0.6, d / 6));
        wantHigh = d < 40 ? 0 : 1;
      }
    } else if (this.move === 'eat'){
      if (!this.swarm || this.swarm.left <= 0){
        this.swarm = nearest(this.head(), liveSwarms(), function(x){ return x.at; });
        this.feeding = false;
        if (!this.swarm){ this.take('look'); this.askSoon(); }
      }
      if (this.swarm){
        var away = dist(this.head(), this.swarm.at);
        this.feeding = away < 26;
        if (this.feeding){
          aim = walk(this.swarm.at, this.beat / 14, 12); speed = 0.9; wantHigh = 0;
        } else { aim = this.swarm.at; speed = 3.6; }
      }
    } else if (this.move === 'flee'){
      aim = this.aim; speed = 5.2;
      if (dist(this.head(), aim) < 30){ this.take('look'); this.askSoon(); }
    } else {                                           // look around
      if (!this.aim || dist(this.head(), this.aim) < 30) this.aim = this.somewhere();
      aim = this.aim; speed = 3.0;
    }

    this.fast = speed > 4.5;
    this.go(aim, speed, turn);
    this.high += (wantHigh - this.high) * 0.12;

    if (this.feeding && this.swarm && this.high < 0.5){            // eating
      if (--this.eatIn <= 0){
        this.eatIn = EAT_EVERY;
        this.swarm.left--; this.meals++; world.midges++; world.points.dragonfly++;
        float('+1', this.swarm.at, '#cdeac0');
        if (this.swarm.left <= 0){
          this.swarm.back = SWARM_BACK;
          world.animals.forEach(function(a){ if (a.swarm === me.swarm && a !== me) a.askSoon(); });
          this.askSoon();
        }
      }
    } else this.eatIn = EAT_EVERY;

    if (this.perched) this.energy += 16 / STEPS;
    else if (this.grounded) this.energy += 7 / STEPS;
    else this.energy -= (this.fast ? 5.5 : 3) / STEPS;
    this.energy = Math.max(0, Math.min(100, this.energy));

    var nowLow = this.high < 0.5;                      // a gecko near a dragonfly that comes low thinks again
    if (nowLow && !this.wasLow){
      world.animals.forEach(function(a){
        if (a.kind === 'gecko' && dist(a.head(), me.head()) <= CREEP_TO) a.askSoon();
      });
    }
    this.wasLow = nowLow;
  };

  /* ---------- the agent: ask the model, take its pick ---------- */
  var brain = {answers: 0, fails: 0, times: [], stamps: [], lastGood: 0, tooLong: -1e9, log: []};

  function think(animal, now){
    if (animal.asking || now < animal.nextAsk || now - animal.lastAsk < ASK_GAP || !animal.mayAsk()) return;
    var asked = animal.question(), life = animal.life;
    animal.asking = true; animal.lastAsk = now;
    animal.nextAsk = Infinity;      // askSoon() during the wait sets this to 0: then it asks again at once
    var stop = new AbortController(), timeout = setTimeout(function(){ stop.abort(); }, 8000);
    fetch(MODEL, {
      method: 'POST', headers: {'Content-Type': 'application/json'}, signal: stop.signal,
      body: JSON.stringify({state: asked.state,
                            questions: {move: {type: 'choice', instructions: ASK, criteria: asked.criteria}}})
    }).then(function(reply){
      if (reply.status === 413) brain.tooLong = performance.now();   // the server takes short messages only
      if (!reply.ok) throw new Error('HTTP ' + reply.status);
      return reply.json();
    }).then(function(data){
      var answer = data.answers.move, took = performance.now() - now;
      brain.answers++; brain.lastGood = performance.now();
      brain.times.push(took); brain.stamps.push(performance.now());
      if (brain.times.length > 60){ brain.times.shift(); brain.stamps.shift(); }
      var record = {name: animal.name, kind: animal.kind, words: asked.words, offers: asked.offers, state: asked.state,
                    criteria: asked.criteria, choice: answer.choice, shares: answer.probabilities, ms: Math.round(took)};
      brain.log.push(record);
      if (brain.log.length > 400) brain.log.shift();
      if (animal.life !== life || animal.gone || animal.driver === 'you') return;   // it was caught, or you took over
      animal.last = record;
      if (MOVES[answer.choice]) animal.take(answer.choice);
    }).catch(function(){
      brain.fails++;
    }).then(function(){
      clearTimeout(timeout);
      animal.asking = false;
      if (animal.nextAsk !== 0) animal.nextAsk = performance.now() + ASK_EVERY * (0.8 + 0.4 * Math.random());
    });
  }

  /* ---------- drawing ---------- */
  var canvas = document.getElementById('field'), ctx = canvas.getContext('2d'), ground = null, scale = 1;

  function drawGround(){
    // the field is drawn once: earth, and rows of cut rice
    ground = document.createElement('canvas');
    ground.width = WIDE; ground.height = HIGH;
    var g = ground.getContext('2d'), x, y, seed = 7;
    function random(){ seed = (seed * 16807) % 2147483647; return seed / 2147483647; }
    g.fillStyle = '#1b2a22'; g.fillRect(0, 0, WIDE, HIGH);
    for (y = 30; y < HIGH; y += 34){
      for (x = 24 + (Math.floor(y / 34) % 2) * 17; x < WIDE; x += 34){
        var px = x + (random() - 0.5) * 9, py = y + (random() - 0.5) * 9, lean = (random() - 0.5) * 0.9;
        g.strokeStyle = random() < 0.5 ? '#2f4a36' : '#3a5a3f'; g.lineWidth = 1.6; g.lineCap = 'round';
        for (var blade = -1; blade <= 1; blade++){
          g.beginPath(); g.moveTo(px, py);
          g.lineTo(px + Math.sin(lean + blade * 0.6) * 7, py - Math.cos(lean + blade * 0.6) * 7);
          g.stroke();
        }
      }
    }
  }
  function line(points, colour, width){
    ctx.beginPath();
    ctx.moveTo(points[0][0], points[0][1]);
    for (var i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
    ctx.strokeStyle = colour; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    ctx.stroke();
  }
  function circle(centre, radius, fill, rim, width){
    ctx.beginPath();
    ctx.arc(centre[0], centre[1], radius, 0, 2 * Math.PI);
    if (fill){ ctx.fillStyle = fill; ctx.fill(); }
    if (rim){ ctx.strokeStyle = rim; ctx.lineWidth = width || 1.5; ctx.stroke(); }
  }
  // what tkinter draws for smooth=True: each corner becomes a curve between two side middles
  function shape(edge, fill, rim, width){
    var n = edge.length, a = edge[n - 1], b = edge[0];
    ctx.beginPath();
    ctx.moveTo((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
    for (var i = 0; i < n; i++){
      var p = edge[i], q = edge[(i + 1) % n];
      ctx.quadraticCurveTo(p[0], p[1], (p[0] + q[0]) / 2, (p[1] + q[1]) / 2);
    }
    ctx.closePath();
    if (fill){ ctx.fillStyle = fill; ctx.fill(); }
    if (rim){ ctx.strokeStyle = rim; ctx.lineWidth = width || 2; ctx.lineJoin = 'round'; ctx.stroke(); }
  }
  Animal.prototype.outline = function(grow, shift){
    var left = [], right = [], i, p;
    for (i = 0; i < this.widths.length; i++){
      p = [this.points[i][0] + shift[0], this.points[i][1] + shift[1]];
      left.push(walk(p, this.angles[i] - QUARTER, this.widths[i] * grow));
      right.push(walk(p, this.angles[i] + QUARTER, this.widths[i] * grow));
    }
    right.reverse();
    p = [this.points[0][0] + shift[0], this.points[0][1] + shift[1]];
    return [walk(p, this.angles[0], this.widths[0] * grow)].concat(left, right);
  };

  Animal.prototype.drawGecko = function(){
    var i, crouch = this.mode === 'wind';
    for (i = 0; i < this.legs.length; i++){
      line(this.legs[i].joints, '#fff', 7);
      line(this.legs[i].joints, this.colour, 4);
      circle(this.legs[i].joints[2], 3.2, this.colour, '#fff', 1.2);       // the wide toes of a gecko
    }
    shape(this.outline(crouch ? 1.12 : 1, [0, 0]), this.colour, '#fff', 2);
    for (i = 2; i < 9; i++){                                               // the orange spots of a tokay gecko
      var side = i % 2 ? 1 : -1;
      circle(walk(this.points[i], this.angles[i] + side * QUARTER, this.widths[i] * 0.42), 1.7, '#f4a259');
    }
    circle(walk(this.points[0], this.angles[0] - 1.2, this.widths[0] / 2), 1.8, '#12263a', '#fff', 1);
    circle(walk(this.points[0], this.angles[0] + 1.2, this.widths[0] / 2), 1.8, '#12263a', '#fff', 1);
  };
  Animal.prototype.wings = function(grow, shift, fill, rim){
    var flying = this.high > 0.2 || this.feeding;
    var flap = flying ? Math.sin(this.beat * 1.9) * 0.3 : 0;
    var at = [this.points[1][0] + shift[0], this.points[1][1] + shift[1]], facing = this.angles[1];
    [[1, 0.12 + flap, 27, 5.2], [-1, 0.12 + flap, 27, 5.2], [1, -0.42 - flap, 23, 5.6], [-1, -0.42 - flap, 23, 5.6]]
      .forEach(function(w){
        var out = facing + w[0] * (QUARTER - w[1]);
        shape(oval(walk(at, out, w[2] * grow * 0.55), out, w[2] * grow * 0.55, w[3] * grow), fill, rim, 1);
      });
  };
  Animal.prototype.drawDragonfly = function(){
    var grow = 1 + this.high * 0.28, drop = [4 + this.high * 16, 6 + this.high * 22];
    ctx.globalAlpha = 0.28;                                               // the shadow says how high it is
    this.wings(grow, drop, '#000', null);
    shape(this.outline(grow, drop), '#000', null);
    ctx.globalAlpha = 1;
    this.wings(grow, [0, 0], 'rgba(214,236,255,0.38)', 'rgba(255,255,255,0.85)');
    shape(this.outline(grow, [0, 0]), this.colour, '#fff', 1.6);
    circle(walk(this.points[0], this.angles[0] - 0.9, this.widths[0] * grow * 0.7), 2 * grow, '#12263a');
    circle(walk(this.points[0], this.angles[0] + 0.9, this.widths[0] * grow * 0.7), 2 * grow, '#12263a');
  };

  function label(text, at, colour, size, bold){
    // words keep their size on any screen, so they are drawn after the picture is scaled
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    var px = Math.max(10, size * Math.max(scale, 0.62)) * (canvas.width / canvas.clientWidth || 1);
    ctx.font = (bold ? '700 ' : '500 ') + px + 'px -apple-system, "Segoe UI", sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    var k = canvas.width / WIDE, x = at[0] * k, y = at[1] * k;
    ctx.lineWidth = px / 4; ctx.strokeStyle = 'rgba(10,20,16,0.85)'; ctx.lineJoin = 'round';
    ctx.strokeText(text, x, y);
    ctx.fillStyle = colour; ctx.fillText(text, x, y);
    ctx.restore();
  }

  function draw(){
    var k = canvas.width / WIDE, i;
    ctx.setTransform(k, 0, 0, k, 0, 0);
    ctx.drawImage(ground, 0, 0);

    world.swarms.forEach(function(s){                                     // midges
      if (s.left <= 0) return;
      ctx.setLineDash([3, 6]); circle(s.at, 26, null, 'rgba(205,234,192,0.55)', 1.4); ctx.setLineDash([]);
      for (i = 0; i < s.left; i++){
        var turn = s.midges[i] + world.step * (0.05 + i * 0.006);
        circle(walk(s.at, turn, 7 + 11 * Math.abs(Math.sin(turn * 0.7 + i))), 2.3, '#f3fbe8');
      }
    });
    world.sticks.forEach(function(s){                                     // bamboo sticks, seen from above
      line([s.at, [s.at[0] + 16, s.at[1] + 22]], 'rgba(0,0,0,0.30)', 4);
      circle(s.at, 7.5, '#c8a96a', '#7a6233', 2);
      circle(s.at, 2.5, '#7a6233');
    });

    var picked = world.picked;
    if (picked && !picked.gone){
      ctx.setLineDash([6, 6]);
      circle(picked.body(), picked.kind === 'gecko' ? 30 : 26, null, '#ffd166', 2);
      if (picked.kind === 'gecko') circle(picked.head(), JUMP, null, 'rgba(255,209,102,0.35)', 1.5);
      ctx.setLineDash([]);
      if (picked.kind === 'dragonfly'){                                   // its blind spot, behind the tail
        var back = picked.angles[0] + Math.PI;
        ctx.beginPath(); ctx.moveTo(picked.body()[0], picked.body()[1]);
        ctx.arc(picked.body()[0], picked.body()[1], 90, back - BLIND, back + BLIND);
        ctx.closePath(); ctx.fillStyle = 'rgba(0,0,0,0.30)'; ctx.fill();
      }
    }
    var low = [], high = [];
    world.animals.forEach(function(a){
      if (a.gone) return;
      if (a.kind === 'gecko') a.drawGecko(); else (a.high < 0.5 ? low : high).push(a);
    });
    low.concat(high).forEach(function(a){ a.drawDragonfly(); });

    world.animals.forEach(function(a){
      if (a.gone) return;
      var under = a.kind === 'gecko' ? 26 : 30;
      label(a.name + (a.driver === 'you' ? ' (you)' : ''), [a.body()[0], a.body()[1] + under], 'rgba(255,255,255,0.82)', 12, false);
      if (a.saysFor > 0) label(a.says, [a.head()[0], a.head()[1] - 26], '#ffd166', 15, true);
      if (a.asking) circle([a.head()[0] + 14, a.head()[1] - 12], 2.6, '#ffd166');   // a dot while it waits for the model
    });
    world.floats.forEach(function(f){
      label(f.text, [f.at[0], f.at[1] - 20 - f.age * 0.5], f.colour, 15, true);
    });
  }

  /* ---------- the panel ---------- */
  var list = document.getElementById('animals'), rows = [];
  function buildList(){
    world.animals.forEach(function(a){
      var li = document.createElement('li');
      li.innerHTML = '<span class="dot"></span><div><div class="who"></div><div class="gauge"><i></i></div></div><span class="does"></span>';
      li.querySelector('.dot').style.background = a.colour;
      li.addEventListener('click', function(){ pick(a); });
      list.appendChild(li);
      rows.push({animal: a, li: li, who: li.querySelector('.who'), bar: li.querySelector('.gauge i'), does: li.querySelector('.does')});
    });
  }
  function doing(a){
    if (a.gone) return 'caught';
    if (a.driver === 'you') return 'you drive';
    if (a.kind === 'gecko' && (a.mode === 'wind' || a.mode === 'dash')) return 'Jump!';
    if (a.kind === 'gecko' && a.mode === 'land') return 'landed';
    if (a.kind === 'dragonfly' && a.grounded) return 'fell';
    return MOVES[a.move].says;
  }
  function showList(){
    rows.forEach(function(r){
      var a = r.animal;
      r.who.textContent = a.name;
      r.bar.style.width = Math.round(a.kind === 'gecko' ? a.stamina : a.energy) + '%';
      r.does.textContent = doing(a);
      r.does.className = 'does' + (a.driver === 'you' ? ' you' : '');
      r.li.className = world.picked === a ? 'picked' : '';
    });
  }
  var thoughtName = document.getElementById('thoughtName'), thoughtBody = document.getElementById('thoughtBody');
  var shown = null;
  function showThought(){
    var a = world.picked;
    if (!a) return;
    var key = a.name + '|' + a.driver + '|' + (a.last ? brain.log.indexOf(a.last) + ':' + a.last.ms : 'none');
    if (key === shown) return;                      // only rebuild when there is a new answer to show
    shown = key;
    thoughtName.textContent = a.name + ', a ' + a.kind;
    var html = '';
    if (a.driver === 'you'){
      html += '<p><b>You are driving ' + a.name + '.</b> ' + (a.kind === 'gecko' ?
        'Move the pointer over the field and it follows. Stay near it and it creeps. Click to jump.' :
        'Move the pointer over the field and it follows. It eats inside a swarm. It rests on a free stick.') +
        ' Press Esc to stop.</p>';
    } else if (!a.last){
      html += '<p>It is waiting for its first answer.</p>';
    } else {
      html += '<div class="label">What it told the model</div><div class="saw">' + a.last.words + '</div>';
      html += '<div class="label">What the model chose</div>';
      a.last.offers.forEach(function(name){
        var share = a.last.shares[name] || 0;
        html += '<div class="choice' + (name === a.last.choice ? ' won' : '') + '"><span class="name">' + name +
                '</span><span class="track"><i style="width:' + Math.round(share * 100) + '%"></i></span>' +
                '<span class="share">' + Math.round(share * 100) + '%</span></div>';
      });
      html += '<p class="took">The answer took ' + a.last.ms + ' ms.</p>';
      var whole = a.last.state + '\n\nQuestion: ' + ASK + '\n' +
                  a.last.offers.map(function(name){ return '- ' + name + ': ' + a.last.criteria[name]; }).join('\n');
      html += '<details><summary>See the whole message</summary><pre>' +
              whole.replace(/&/g, '&amp;').replace(/</g, '&lt;') + '</pre></details>';
    }
    var open = thoughtBody.querySelector('details');
    var wasOpen = open && open.open;
    thoughtBody.innerHTML = html;
    if (wasOpen && thoughtBody.querySelector('details')) thoughtBody.querySelector('details').open = true;
  }
  var driveButton = document.getElementById('drive'), tally = document.getElementById('thoughtTally');
  function showDrive(){
    var a = world.picked;
    driveButton.hidden = !a || !!a.gone;
    if (!a) return;
    driveButton.textContent = a.driver === 'you' ? 'Let the model drive' : 'Drive ' + a.name + ' yourself';
    driveButton.className = a.driver === 'you' ? 'on' : '';
    tally.textContent = a.kind === 'gecko' ? a.catches + ' caught in ' + a.jumps + (a.jumps === 1 ? ' jump' : ' jumps')
                                            : a.meals + (a.meals === 1 ? ' midge eaten' : ' midges eaten');
  }
  driveButton.addEventListener('click', function(){
    var a = world.picked;
    if (a && !a.gone) drive(a.driver === 'you' ? null : a);
  });
  function pick(animal){ world.picked = animal; shown = null; showThought(); showList(); showDrive(); }
  function drive(animal){
    if (world.driven){ world.driven.driver = 'model'; world.driven.askSoon(); }
    world.driven = animal;
    if (animal){
      animal.driver = 'you';
      if (animal.kind === 'gecko'){ animal.move = 'wait'; animal.spot = null; }
      else { animal.leaveStick(); animal.swarm = null; animal.feeding = false; }
      world.pointer = [animal.head()[0], animal.head()[1]];
    }
    shown = null; showThought(); showList(); showDrive();
  }

  var modelLine = document.getElementById('model');
  function showModel(){
    var now = performance.now();
    if (!brain.answers && !brain.fails){ modelLine.textContent = 'Asking the model…'; return; }
    if (now - brain.tooLong < 4000){
      modelLine.className = 'model down';
      modelLine.textContent = 'These rules are too long for the model. Make them shorter.';
      return;
    }
    if (now - brain.lastGood > 5000 && brain.fails){
      modelLine.className = 'model down';
      modelLine.textContent = 'The model does not answer. The animals keep their last move.';
      return;
    }
    var times = brain.times.slice().sort(function(x, y){ return x - y; });
    var middle = times.length ? times[Math.floor(times.length / 2)] : 0;
    var recent = brain.stamps.filter(function(t){ return now - t < 5000; }).length / 5;
    modelLine.className = 'model';
    modelLine.textContent = 'decider-2b on the Spark · ' + brain.answers + ' answers · ' + recent.toFixed(1) +
                            ' a second · ' + Math.round(middle) + ' ms each';
  }
  function showScore(){
    document.getElementById('geckoPoints').textContent = world.points.gecko;
    document.getElementById('dragonflyPoints').textContent = world.points.dragonfly;
    document.getElementById('geckoCount').textContent = world.catches + (world.catches === 1 ? ' catch' : ' catches') +
                                                        ' in ' + world.jumps + (world.jumps === 1 ? ' jump' : ' jumps');
    document.getElementById('dragonflyCount').textContent = world.midges + (world.midges === 1 ? ' midge' : ' midges');
    var seconds = Math.ceil(world.left / STEPS);
    document.getElementById('clock').textContent = Math.floor(seconds / 60) + ':' + ('0' + seconds % 60).slice(-2);
  }

  /* ---------- rounds ---------- */
  var banner = document.getElementById('banner');
  function newRound(){
    world.points = {gecko: 0, dragonfly: 0}; world.catches = 0; world.jumps = 0; world.midges = 0;
    world.left = ROUND * STEPS; world.over = 0;
    world.animals.forEach(function(a){ a.catches = 0; a.jumps = 0; a.meals = 0; });
    banner.className = 'banner';
    showScore();
  }
  function endRound(){
    var g = world.points.gecko, d = world.points.dragonfly;
    document.getElementById('bannerTop').textContent = g > d ? 'The geckos win!' : d > g ? 'The dragonflies win!' : 'A tie!';
    document.getElementById('bannerText').textContent = 'Geckos ' + g + ' · Dragonflies ' + d + '. A new round starts soon.';
    banner.className = 'banner on';
    world.over = 8 * STEPS;
  }

  /* ---------- one step of the whole field ---------- */
  function stepWorld(){
    world.step++;
    if (world.over){
      if (--world.over === 0) newRound();
    } else if (--world.left <= 0) endRound();
    world.swarms.forEach(function(s, i){
      if (s.left <= 0 && --s.back <= 0) world.swarms[i] = newSwarm();
    });
    world.animals.forEach(function(a){ a.step(); });
    world.floats.forEach(function(f){ f.age++; });
    world.floats = world.floats.filter(function(f){ return f.age < 80; });
  }

  /* ---------- pointer: pick an animal, or steer the one you drive ---------- */
  function toField(event){
    var box = canvas.getBoundingClientRect();
    return [(event.clientX - box.left) * WIDE / box.width, (event.clientY - box.top) * HIGH / box.height];
  }
  canvas.addEventListener('pointermove', function(event){ world.pointer = toField(event); });
  canvas.addEventListener('pointerdown', function(event){
    var at = toField(event);
    world.pointer = at;
    var hit = nearest(at, world.animals.filter(function(a){ return !a.gone; }), function(a){ return a.body(); });
    if (hit && dist(at, hit.body()) < 34 && hit !== world.driven){ pick(hit); return; }
    var mine = world.driven;
    if (mine && mine.kind === 'gecko') mine.jumpNow(angleTo(mine.head(), at));
  });
  document.addEventListener('keydown', function(event){ if (event.key === 'Escape' && world.driven) drive(null); });

  /* ---------- buttons and the coach's rules ---------- */
  var pauseButton = document.getElementById('pause');
  pauseButton.addEventListener('click', function(){
    world.paused = !world.paused;
    pauseButton.textContent = world.paused ? 'Play' : 'Pause';
    pauseButton.className = world.paused ? 'on' : '';
  });
  document.getElementById('again').addEventListener('click', newRound);
  var boxes = {gecko: document.getElementById('rulesGecko'), dragonfly: document.getElementById('rulesDragonfly')};
  Object.keys(boxes).forEach(function(kind){
    boxes[kind].value = rules[kind];
    boxes[kind].addEventListener('input', function(){ rules[kind] = boxes[kind].value; });
  });
  document.querySelectorAll('[data-reset]').forEach(function(button){
    button.addEventListener('click', function(){
      var kind = button.dataset.reset;
      rules[kind] = boxes[kind].value = FIRST_RULES[kind];
    });
  });

  /* ---------- size, and the one loop ---------- */
  function resize(){
    var ratio = Math.min(window.devicePixelRatio || 1, 2);
    var width = Math.max(1, Math.round(canvas.clientWidth * ratio));
    if (canvas.width !== width){
      canvas.width = width;
      canvas.height = Math.round(width * HIGH / WIDE);
    }
    scale = canvas.clientWidth / WIDE;
    draw();
  }

  var last = 0, owed = 0, panelAt = 0;
  function tick(now){
    owed = Math.min(owed + (now - last), 50);
    last = now;
    while (owed >= 1000 / STEPS){
      owed -= 1000 / STEPS;
      if (!world.paused) stepWorld();
    }
    if (!world.paused) world.animals.forEach(function(a){ think(a, now); });
    draw();
    if (now - panelAt > 200){ panelAt = now; showList(); showThought(); showDrive(); showModel(); showScore(); }
    requestAnimationFrame(tick);
  }

  function start(){
    STICKS.forEach(function(at){ world.sticks.push({at: at, owner: null}); });
    for (var i = 0; i < SWARMS; i++) world.swarms.push(newSwarm());
    TEAM.forEach(function(t, n){
      var animal = new Animal(t[0], t[1], t[2]);
      animal.nextAsk = performance.now() + 200 + n * 150;      // the first questions do not all leave at once
      world.animals.push(animal);
    });
    if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) pauseButton.click();
    drawGround();
    buildList();
    pick(world.animals[0]);
    resize();
    window.addEventListener('resize', resize);
    requestAnimationFrame(tick);
  }

  window.field = world;           // so a test can look inside
  window.fieldBrain = brain;
  start();
})();
</script>
</body>
</html>

📄 game_server.py · the serverเซิร์ฟเวอร์服务器

"""The gecko and dragonfly game: one port for the page and for the model.

    python3 game_server.py

The decider library has its own web server, decider.serve. It answers
POST /v1/systemone, the same request Jev takes: a situation and a list of
moves go in, one move comes back with a probability for each.

This file adds three things to that server. The game page, at "/", so the page
and the model share one address. A door, because the address can be opened to
the internet: only the three paths the game uses are answered, and a request
body may not be large. And a CORS rule, so a page on another site may ask the
model too: HTML/procedural_animation.html on krueng.ai does.

The model is named by DECIDER_MODEL. run.sh sets it, and the size limits.
"""
from pathlib import Path

import uvicorn
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse

PAGE = Path(__file__).resolve().parent / "gecko_dragonfly.html"
PORT = 7863
OPEN = {"/", "/v1/systemone", "/health"}    # every other path answers 404
BIGGEST = 16_000                            # bytes in one request; the game sends about 2,000


def page():
    """The game. no-store, so an edited page shows on the next reload."""
    return FileResponse(PAGE, media_type="text/html", headers={"Cache-Control": "no-store"})


async def door(request, call_next):
    """Runs before every request. decider.serve also has /decide, /stats,
    /v1/models and FastAPI's /docs; the game uses none of them, so strangers
    do not get them. A POST must say how long it is, and it must be short."""
    if request.url.path not in OPEN:
        return PlainTextResponse("not found", status_code=404)
    if request.method == "POST":
        length = request.headers.get("content-length", "")
        if not length.isdigit() or int(length) > BIGGEST:
            return PlainTextResponse("too many tokens: the request is too big", status_code=413)
    return await call_next(request)


def main():
    # Imported here, not at the top: importing decider.serve builds its app
    # and reads the DECIDER_ settings. The model loads when uvicorn starts.
    from decider.serve import app

    app.add_api_route("/", page, methods=["GET"], include_in_schema=False)
    app.middleware("http")(door)
    # Added after the door, so it wraps the door: a browser's question
    # "may I ask?" (OPTIONS) is answered here, and the door's own "no" gets
    # the header too. Any site may ask. The door and the limits still apply.
    app.add_middleware(CORSMiddleware, allow_origins=["*"],
                       allow_methods=["GET", "POST"], allow_headers=["Content-Type"])
    # No access log: nine animals ask about six times a second.
    uvicorn.run(app, host="0.0.0.0", port=PORT, access_log=False)


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
from decider.serve import appThe library has its own web server. This file adds three things to it.ไลบรารีมีเว็บเซิร์ฟเวอร์ของมันเองอยู่แล้ว ไฟล์นี้เพิ่มสามอย่างเข้าไป这个库自带一个网页服务器。这个文件给它加了三样东西。
app.add_api_route("/", page, ...)One. The game's page, at the same address as the model.หนึ่ง หน้าเว็บของเกม อยู่ที่ที่อยู่เดียวกับโมเดล一。游戏的页面,和模型用同一个地址。
app.middleware("http")(door)Two. The door. It answers three paths, and short messages only.สอง ประตู ตอบแค่สามเส้นทาง และรับเฉพาะข้อความสั้น ๆ二。门。只回应三个路径,只收短消息。
allow_origins=["*"]Three. A page on any site may ask. This page does.สาม หน้าเว็บจากไซต์ไหนก็ถามได้ หน้านี้ก็ถามอยู่三。任何网站上的页面都可以来问。这一页就在问。
uvicorn.run(app, host="0.0.0.0", port=PORT, ...)Start. The model loads, and the server listens on port 7863.เริ่มทำงาน โมเดลถูกโหลด แล้วเซิร์ฟเวอร์รอฟังที่พอร์ต 7863启动。模型加载进来,服务器在 7863 端口等着。

📄 run.sh · it starts the serverใช้เริ่มเซิร์ฟเวอร์用来启动服务器

#!/usr/bin/env bash
# Start the gecko and dragonfly game on the DGX Spark.
#
#   bash run.sh        then open  http://<the Spark>:7863/
#
# One new container, gecko-game. It reads this folder and ~/s1_bench and
# writes to neither. It touches no other container.
#
#   docker logs -f gecko-game      watch it load (ready when it prints "[serve] ready")
#   docker rm -f gecko-game        stop it
set -euo pipefail

HERE="$(cd "$(dirname "$0")" && pwd)"
BENCH="$HOME/s1_bench"      # libs/ = the decider-ai library, hf_cache/ = the weights

# DECIDER_WARMUP=0: do not record CUDA graphs at start-up. In this image the
# Qwen3.5 layers run on plain PyTorch (flash-linear-attention is not installed),
# and that code cannot be recorded: the server stops with "Cannot copy between
# CPU and CUDA tensors during CUDA graph capture". With 0 every request runs
# directly on the GPU instead.
#
# The four MAX settings are limits for a server strangers can reach. The game
# sends one question of 200 to 280 tokens at a time. The library's own limits
# are 1,024 questions and a million tokens in one request.
#   MAX_ROWS            questions in one request
#   MAX_ROW_TOKENS      tokens in one question, rules and situation included
#   MAX_REQUEST_TOKENS  tokens in one request
#   MAX_QUEUE_ROWS      questions waiting; past this the server answers "busy"
docker run -d --name gecko-game --gpus all \
  --memory 24g --cpus 8 \
  --log-opt max-size=10m --log-opt max-file=2 \
  -p 7863:7863 \
  -e PYTHONPATH=/bench/libs \
  -e HF_HOME=/bench/hf_cache -e HF_HUB_OFFLINE=1 \
  -e DECIDER_MODEL=Mapika/decider-2b \
  -e DECIDER_WARMUP=0 \
  -e DECIDER_MAX_ROWS=2 -e DECIDER_MAX_ROW_TOKENS=700 \
  -e DECIDER_MAX_REQUEST_TOKENS=1400 -e DECIDER_MAX_QUEUE_ROWS=48 \
  -v "$BENCH:/bench:ro" -v "$HERE:/game:ro" \
  --entrypoint python3 \
  vllm/vllm-openai:cu130-nightly /game/game_server.py
LineWhat it does · ทำอะไร · 做什么
docker run -d --name gecko-game --gpus allStart one box in the background. Let it use the GPU.เริ่มกล่องหนึ่งกล่องให้ทำงานอยู่เบื้องหลัง และให้มันใช้ GPU ได้在后台启动一个盒子,并让它使用 GPU。
-p 7863:7863Open one port, so the page and the questions can come in.เปิดพอร์ตหนึ่งพอร์ต ให้หน้าเว็บและคำถามเข้ามาได้打开一个端口,让页面和问题能进来。
-e DECIDER_MODEL=Mapika/decider-2bTell the library which model to load.บอกไลบรารีว่าให้โหลดโมเดลตัวไหน告诉这个库该加载哪个模型。
-e DECIDER_MAX_ROWS=2A limit for strangers. One message may hold two questions.ขีดจำกัดสำหรับคนแปลกหน้า ข้อความหนึ่งก้อนมีคำถามได้สองข้อ给陌生人设的限制。一条消息里最多两个问题。
-v "$BENCH:/bench:ro" -v "$HERE:/game:ro"Lend the box two folders: the library with the model, and the game. It may only read them.ให้กล่องยืมสองโฟลเดอร์: ไลบรารีกับโมเดล และตัวเกม กล่องอ่านได้อย่างเดียว借给盒子两个文件夹:库和模型,还有游戏。盒子只能读,不能改。
vllm/vllm-openai:cu130-nightly /game/game_server.pyA ready-made box, with PyTorch for this GPU. In it, run our server.กล่องสำเร็จรูปที่มี PyTorch สำหรับ GPU ตัวนี้อยู่แล้ว ในกล่องนั้นให้รันเซิร์ฟเวอร์ของเรา一个现成的盒子,里面有适合这块 GPU 的 PyTorch。在里面运行我们的服务器。
ANYONE CAN ASK THIS MODEL The address is public and has no password. So the door keeps each message small. One computer answers everyone, and many players at once make it slow. ที่อยู่นี้เป็นสาธารณะและไม่มีรหัสผ่าน ประตูจึงคุมให้ข้อความแต่ละก้อนเล็กอยู่เสมอ คอมพิวเตอร์เครื่องเดียวตอบทุกคน ถ้ามีผู้เล่นหลายคนพร้อมกันมันจะช้าลง 这个地址是公开的,没有密码。所以门要把每条消息都限制得很小。一台电脑回答所有人,同时玩的人多了就会变慢。

🇬🇧 English

You started with a dot on a leash. You finished with a gecko that hunts your mouse.

There are only four small functions under all the movement. None of them knows what a snake is.

That is procedural animation. You do not draw the animal moving. You write the rules, and it moves. In the last part the rules are English sentences, and a model reads them.

🇹🇭 ไทย

คุณเริ่มจากจุดที่ผูกสายจูง และจบด้วยตุ๊กแกที่ล่าเมาส์ของคุณ

ข้างใต้การเคลื่อนไหวทั้งหมดนี้มีฟังก์ชันเล็ก ๆ แค่สี่ตัว ไม่มีตัวไหนรู้ว่างูคืออะไร

นี่แหละโพรซีเจอรัลแอนิเมชัน คุณไม่ได้วาดสัตว์ให้ขยับ คุณเขียนกฎ แล้วมันก็ขยับเอง ในส่วนสุดท้าย กฎเป็นประโยคภาษาอังกฤษ และมีโมเดลเป็นผู้อ่าน

🇨🇳 中文

你从一个拴着绳子的点开始,最后做出了一只追捕你鼠标的壁虎。

所有这些动作的底下只有四个小函数。它们谁也不知道蛇是什么。

这就是程序化动画。你不去画动物怎么动。你写规则,它自己就动了。到了最后一部分,规则是英文句子,由一个模型来读。

Functionฟังก์ชัน函数Its one jobงานอย่างเดียวของมัน它唯一的工作Where it is usedใช้ที่ไหน用在哪
pullKeep a distance.รักษาระยะห่าง保持距离。The leash, the chain, every leg.สายจูง โซ่ และขาทุกข้าง绳子、链、每一条腿。
walkGo this far, this way.ไปไกลเท่านี้ ในทิศนี้朝这个方向走这么远。The skin, the eyes, the fins, the spine.ผิว ตา ครีบ และสันหลัง皮肤、眼睛、鳍、脊柱。
limitDo not turn too far.อย่าหันมากเกินไป不许转得太多。Every joint of the spine, and the head.ทุกข้อต่อของสันหลัง และหัว脊柱的每个关节,还有头。
reachHold both ends.ยึดทั้งสองปลาย两头都固定住。The arm, and every leg.แขน และขาทุกข้าง手臂,和每一条腿。

🇬🇧 English

Next: classes, taught with a window, physics with Python, or a chat window in tkinter.

The video's own code is at github.com/argonautcode/animal-proc-anim. The idea of chained constraints is drawn well at zalo.github.io/blog/constraints.

🇹🇭 ไทย

ต่อไป: คลาส สอนด้วยหน้าต่าง, ฟิสิกส์ด้วย Python หรือ หน้าต่างแชตด้วย tkinter

โค้ดของวิดีโออยู่ที่ github.com/argonautcode/animal-proc-anim ส่วนแนวคิดเรื่องข้อบังคับที่ต่อกันเป็นโซ่ มีภาพอธิบายดี ๆ ที่ zalo.github.io/blog/constraints

🇨🇳 中文

接下来:用一个窗口来讲类、用 Python 学物理,或者用 tkinter 做聊天窗口。

视频自己的代码在 github.com/argonautcode/animal-proc-anim。把约束连成链的想法,在 zalo.github.io/blog/constraints 有很好的图解。

One rule. Eleven files. A snake, a fish, a lizard and a gecko that hunts. · กฎหนึ่งข้อ ไฟล์สิบเอ็ดไฟล์ งู ปลา กิ้งก่า และตุ๊กแกที่ออกล่า · 一条规则,十一个文件,一条蛇、一条鱼、一只蜥蜴,还有一只会捕猎的壁虎。

The technique is argonaut's, from the video at the top of this page. His own code is Java for Processing, MIT licensed, at github.com/argonautcode/animal-proc-anim. The Python here was written from the technique and is not a translation of his files. Three things differ from the video. The head steers, turning a little in each frame, where his goes straight at the mouse. The head stops when it is near the mouse. And the fish's tail and back fins are plain ovals that do not change with the bend of the body. The seven files of tutorials 1 to 4 were run on 1 October 2026, on Windows 11 with Python 3.12.10 and Tk 8.6. Each window was opened for real, sent real mouse-move events, and photographed to check that it drew correctly; the photographs are not published. Each file was also started through its own main(), and closed with nothing on the error stream. The frame rate was counted: 34 to 43 frames a second across the runs, 36 to 39 with an idle mouse. Every other number in those tutorials is printed by measure.py, which builds the real classes and steps them by hand: the hand's distance after each reach, the 117 and 7 pixel jumps, the joints that never reach BEND when TURN is 0.05, the frames in which each leg steps, the hop when all four feet start together, the fish with no legs list, and the error for a wrong word. The experiments in step 4's Your Turn were run and looked at before they were written down. The live pictures are JavaScript, written again from the Python. They were stepped by hand beside the Python classes for 400 frames with the same mouse path, and every point of every animal agreed to nine decimal places. The code blocks are written into this page from the files by python/video_gen/fill_code_blocks.py, and each Download was checked to give back its file exactly.

Tutorial 5 was added on 2 October 2026. It is ours and is not from the video. The model is Mapika/decider-2b, Apache-2.0 licensed. It runs on our own computer in Chiang Mai, and the address in ask.py and hunt.py reaches it through Tailscale Funnel. That computer is sometimes off. Then ask.py prints No answer: and the reason, and the gecko in hunt.py stands still under the words no answer. The output in step 8 is what ask.py printed. hunt.py was run in a real window on Windows 11 with Python 3.12.10 and Tk 8.6.15, and was sent mouse-move events through Tk. In 20.5 seconds it drew 914 frames, 44.5 a second, and asked the model 18 times, 1.08 to 1.21 seconds apart. In three earlier runs the gaps were 1.01 to 1.25 seconds. The gecko crept from 350 pixels to 119, jumped, and caught the dragonfly within 10 frames. It left a moving dragonfly alone for 6 seconds. It missed one that flew off as the jump began. With the model's address made unreachable, the window showed no answer and nothing was written to the error stream. With "mouse" in main, the gecko walked to the mouse and stopped near it, as in step 7. The 350, 154, 119 and 10 in step 9 are printed by measure.py, which sets each move by hand and asks nothing. The wording of WHO, RULES and MOVES was chosen by testing. The four things sees can say were each asked with the three moves in all six orders, and all 24 answers followed the rules. That is a fit to four sentences, not a score. Each Your Turn change that promises a result was run first: the rule changes against the model, and the changes to THINK, CREEP and DASH by stepping the class by hand. One idea was left out because it failed: with a new rule and a new move for sleeping at night, the gecko still jumped. The two live pictures send the same words to the same model. The page's hunter was stepped by hand beside hunt.py for 1,000 frames, with the same mouse moves and the same picks, and the three older animals again for 400 frames. None of the hunter's 66,076 numbers differed by as much as 0.000000001, and its move, its counts and the words it sees were the same in every frame. One thing differs: in the live picture the dragonfly flies to a new place every eight seconds when no pointer is on it. Four runs of twelve questions were timed through the public address, each question on a new connection, as ask.py sends them: 0.86 to 1.60 seconds a question, with the middle one at 0.93 to 1.03. All started in the house the computer is in, so a reader far away may wait longer. The model's shares differ a little from run to run: the first one in step 8 was 0.9247 in one run of ask.py and 0.9129 in the next. The 6 of 21 in step 9 was counted while the big game was built.

Tutorial 6 was added on 3 October 2026. decide.py was run as it is shown on the laptop described below: 16 to 20 seconds in all, and the output in step 10 is what it printed. It asks the question that agent.py asks when the dragonfly is close enough, word for word. On the GPU described below it took 227 seconds in all, nearly all of it the fast path, and 62 seconds with use_graphs=False. Each Your Turn change in step 10 that promises a result was run first, with the library on the laptop. The button in step 10 asks the model's server the same question. agent.py runs the model itself, with the decider-ai library. It was run as it is shown on a laptop with no GPU: Windows 11, an Intel Core Ultra 7 155H, 24 GB of memory, Python 3.12.10, decider-ai 1.8.1, PyTorch 2.14.1 for the CPU and transformers 5.18.0. pip install decider-ai took 72 seconds and made a folder of 902 MB. The first run took 9 minutes, nearly all of it the download of the model at 7 MB a second. Later runs took 6 seconds to start Python and the library, 9 to load the model, and 1.7 to 1.8 seconds for each decision. The process held 8.1 GB of memory at most. The output in step 11 is what it printed. The warnings it showed were about the download and about slower code paths on a CPU; the library calls those paths correct. On our GPU, an NVIDIA DGX Spark with Linux and PyTorch 2.11.0, the model loaded in 28 seconds, the first decision took 92 seconds while the library built its fast path, and the next three took 0.02 seconds each. On that computer the library needed transformers 5.18.0 and failed with 5.6.0, and it did not run with the GPU hidden. Its two sentences were each asked with the two moves in both orders, and all four answers followed the rules. Each Your Turn change in step 11 that promises a result was run first, on the laptop and on the GPU. One idea was left out because it changed nothing: a rule and two sentences that say always jump. The button in step 11 runs the same loop in the page. A page cannot run the model, so the button asks the model's server. It printed the same five lines. The question and the answer under "How the big game is built" are one real exchange with the server, on 3 October 2026. The numbers in the model's answers differ a little from run to run. The sizes were measured on one open page for 12 seconds: 61 questions of 794 to 1,045 bytes, and answers of 224 to 257 bytes. The 6 questions a second, the 57 ms and the 17 a second were measured on 2 October 2026 on the home network. The model's 4 GB is what nvidia-smi showed for the server on 3 October 2026: 4,158 MiB. The three files of the big game are written into this page from the files that run on the server, by the same script as the other code blocks. A copy of gecko_dragonfly.html was opened from disk in a browser, and it got its answers from the public address. game_server.py and run.sh started the server that answers this page. They were not run on any other computer. How the model reads a question, as a test with letters, in one pass and with no text written, is from the notes and the code of the decider-ai library, version 1.6.0. The scripts that made the checks of Tutorials 5 and 6 are on this site. README.txt says what each one checks and how to run it: window_runs.py, wording.py, your_turn.py, agent_file.py, model_timing.py, one_decision.py, game_copy.py, make_traces.py, compare_with_page.js, page_checks.js, public_timing.py.