Playdate

Post Thumbnail

Наше поколение выросло вместе с первыми консолями. Мы все с теплотой вспоминаем время, когда у нас появилась первая денди.

Всякие геймбои и прочие забугорные штуки прошли мимо меня, но я все равно ностальгирую по тем временам, когда залипали в денди и старые телики, пытались пройти уровень за уровнем в каком-нибудь Чип и Дейле.

Сейчас те старые игры впечатляют как произведения технического искусства. Разработчикам приходилось мириться с жесткими ограничениями. Тем не менее, у них получалось делать классные штуки.

Теперь у нас есть самые разные движки и большие мощности для игр. Но чем больше возможностей, тем сложнее начать что-то делать, сложнее справиться с проблемой пустого листа.

Поэтому ретро-игры все еще пользуются популярностью и среди игроков, и среди разработчиков. Даже появляются новые консоли с, казалось бы, безумными ограничениями.

Playdate

Playdate — это компактная портативная игровая консоль от компании Panic и Teenage Engineering. Teenage Engineering — это мои любимые дизайнеры, а Panic вообще-то издают игры.

У консоли черно-белый экран 2,7 дюйма без подсветки, необычное механическое управление (крутилка-кранк) и свой большой каталог и магазин игр с фишкой "сезонов".

Во что же играть на такой ограниченной консоли? Во все, что душе угодно! В каталоге довольно много игр и стоимость в пределах 2-5 баксов. Из Росии их, конечно же, купить не так просто

Почти все игры очень необычные. Даже разглядывание страничек игр - уже завораживает. Кажется чем-то невероятным - сделать что-то на псевдо 3D с растрированием оттенков. Чтобы писать под Playdate нужны руки из того места.

SDK

Чтобы написать свое приложение или игру под playdate, кроме самой железки, вам понадобится скачать SDK.

И у нас сразу появляется выбор между двумя стульями - C или Lua. Но, на самом деле, выбор значительно больше. C, Lua, Go, Rust, Zig, C++ и это только из самого интересного. Все это работает через сишные биндинги.

В принципе, можно использовать любой язык у которого нет лишнего рантайма и который может интегрироваться с C

Я буду использовать Go, а точнее TinyGo - это такое подмножество языка Go, у которого отпилен рантайм и который может собираться под микропроцессорные архитектуры

Я буду разрабатывать и собирать все на Mac, на других системах все не сильно отличается

Качаем

Чтобы начать, нам нужно в первую очередь скачать SDK с официального сайта

С сайта скачивается zip архив в котором лежит .pkg. Запускаем его и проходим все шаги. У меня SDK встало в директорию /Users/artem/Developer/PlaydateSDK

В комплекте идут все необходимые инструменты - симулятор для запуска приложений на компе, набор утилит для сборки ваших проектов и библиотека для использования в ваших играх

VSCode

Я обожаю VSCode, поэтому расскажу как разрабатывать под playdate в VSCode. Несмотря на то, что разрабатывать мы в итоге будем на Go, я для полноты картины расскажу как все настроить для Lua и для C

Lua

Для Lua необходимо установить несколько расширений

Оба плагина нужны для поддержки максимально полного синтаксиса Lua в VSCode и всех возможностей Lua

И рекомендую установить еще плагин Playdate Debug

Для сборки и запуска приложений в VSCode нужно создать папку source, в которой будут лежать все наши .lua исходники. Например, сделаем что-то супер простое в source/main.lua:

import 'CoreLibs/objects'

function playdate.update()
    print("Hello, world!")
end

В папке source должен быть еще один файл - pdxinfo. В этом файле будут указаны все необходимые параметры нашего приложения:

name=luatemplate
author=Artem Kovardin
description=Sprite example code.
bundleID=ru.kodikapusta.luatemplate
imagePath=system/

Затем нужно создать файл tasks.json в папке .vscode. Пример моего файла:

{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "pdc",
      "problemMatcher": ["$pdc-lua", "$pdc-external"],
      "label": "Playdate: Build"
    },
    {
      "type": "playdate-simulator",
      "problemMatcher": ["$pdc-external"],
      "label": "Playdate: Run"
    },
    {
      "label": "Playdate: Build and Run",
      "dependsOn": ["Playdate: Build", "Playdate: Run"],
      "dependsOrder": "sequence",
      "problemMatcher": [],
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}

И вторым шагом необходимо добавить launch.json, в котором будут описаны настройки для запуска нашей программы

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "playdate",
      "request": "launch",
      "name": "Playdate: Debug",
      "preLaunchTask": "${defaultBuildTask}"
    }
  ]
}

Если у вас возникли сложности с настройкой VSCode для разработки на Lua, то рекомендую посмотреть вот этот видос

Шаблон проекта можно найти здесь

И еще небольшое дополнение — рекомендую добавить в настройки проекта (например, в settings.json) такой параметр:

{
  "Lua.runtime.nonstandardSymbol": [
    "+=",
    "-=",
    "*=",
    "/="
  ]
}

Тогда в VSCode эти операторы не будут помечаться как ошибки

C

Настройка VSCode для разработки на C не сильно отличается от того, что я описал выше для Lua.

Как и для Lua, для работы с C вам нужно поставить нужный плагин в VSCode для работы с C/C++

Начинаем с создания исходника main.c, но складываем этот файл в папку src.

#include <stdio.h>
#include <stdlib.h>

#include "pd_api.h"

static int update(void* userdata);

static int update(void* userdata) {
	PlaydateAPI* pd = userdata;

	pd->system->logToConsole("Hello World!");	

	return 1;
}

А в папке source оставляем только файл pdxinfo с примерно таким содержанием:

name=ctemplate
author=Artem Kovardin
description=A small demo of the C API
bundleID=ru.kodikapusta.ctemplate
imagePath=

В проекте нужны файлы Makefile и CMakeLists.txt

Для сборки проекта в VSCode нужно добавить файл tasks.json в папку .vscode по аналогии с Lua.

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "make",
      "type": "shell",
      "command": "make",
      "problemMatcher": [],
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}

И делаем launch.json

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug",
      "type": "cppdbg",
      "request": "launch",
      "program": "${userHome}/Developer/PlaydateSDK/bin/Playdate Simulator.app",
      "args": ["${workspaceFolder}/ctemplate.pdx"],
      "stopAtEntry": false,
      "cwd": "${fileDirname}",
      "environment": [],
      "externalConsole": false,
      "MIMode": "lldb",
      "preLaunchTask": "make"
    }
  ]
} 

Чтобы редактор мог подхватить все импорты и разрабатывать было удобно - стоит добавить еще несколько параметров в settings.json

{
  "C_Cpp.default.includePath": ["${userHome}/Developer/PlaydateSDK/C_API"],
  "C_Cpp.default.defines": ["TARGET_EXTENSION", "PLAYDATE_SIMULATOR"],
  "[c]": {
    "editor.defaultFormatter": "ms-vscode.cpptools"
  },
  "C_Cpp.formatting": "vcFormat",
  "files.associations": {
    "*.h": "c"
  }
}

Запускаем проект и наслаждаемся результатом

Другие языки

Кроме C и Lua под playdate есть готовые шаблоны для C++, Zig и даже неплохая статья про разработку на Rust.

Обязательно все это попробую, но позже. А сейчас перейдем к самому интересному

TinyGo и pdgo

Для начала нам нужно установить весь необходимый тулинг. Устанавливать отдельно компилятор TinyGo не нужно, так как нам нужен пропатченный компилятор из набора pdgo

Чтобы поставить весь тулинг достаточно выполнить одну команду

curl -fsSL https://raw.githubusercontent.com/playdate-go/pdgo/main/install.sh | bash

В принципе, вы можете почитать скрипт и посмотреть что он делает. Вкратце у вас будут установлены такие инструменты:

  • pdgoc - утилита для сборки проекта playdate(по аналогии с pdc)
  • Кастомный TinyGo с поддержкой сборки под playdate
  • И автоматически настроится PATH

Скрипт установки автоматически скомпилирует LLVM из исходных кодов, это займет ~9 минут на Apple Silicon M5 Pro (15 ядер CPU) и ~25–30 минут на Apple Silicon M1 (8-ядерный CPU), это потребуется сделать всего один раз. Так-то запаситесь терпением

После установки тулинга, можно начать с простого проекта. Структура проекта должна выглядеть так

your-project/
├── Source/
│   ├── main.go
│   ├── go.mod
│   ├── go.sum
│   └── assets/ (images, sounds, etc.)

Обратите внимание, что тут нет файла pdxinfo как во всех остальных примерах. Информация для сборки будет указана во время самой сборки. Для этого нужно будет запустить pdgoc вот так:

pdgoc -device -sim \
  -name=MyApp \
  -author=YourName \
  -desc="My App" \
  -bundle-id=com.yourname.myapp \
  -version=1.0 \
  -build-number=1

В main.go для начала нужно добавить простой код:

package main

import (
	"github.com/playdate-go/pdgo"
)

var pd *pdgo.PlaydateAPI

func initGame() {
	
}

func update() int {
	
}

func main() {}

И в принципе ваш проект уже можно собирать и запускать. Но тут есть пара нюансов

Во-первых, в VSCode пока для Go так просто не настроить сборку как для примеров выше. Придется запускать команды руками

Во-вторых, нужно собирать отдельно версию для симулятора и версию для запуска на железке

Я для этого завел в проекте Taskfile.yml с парой команд:

version: '3'

tasks:
  test:
    cmds:
      - cd Source && CGO_CFLAGS="-I{{.HOME}}/Developer/PlaydateSDK/C_API -DTARGET_EXTENSION=1" go test ./... -v

  build:
    cmds:
      - |
        GOTOOLCHAIN=go1.25.0 pdgoc -sim \
          -name="Gopher" \
          -author="kodikapusta" \
          -desc="Gopher protocol example" \
          -bundle-id=ru.kodikapusta.gopher \
          -version=1.0 \
          -build-number=1

  deploy:
    cmds:
      - |
        GOTOOLCHAIN=go1.25.0 pdgoc -sim -device  \
          -name="Gopher" \
          -author="kodikapusta" \
          -desc="Gopher protocol example" \
          -bundle-id=ru.kodikapusta.gopher \
          -version=1.0 \
          -build-number=1 \
          -deploy

Тут для симулятора собирается версия командой task build, а для девайса версия собирается командой task deploy и сразу же отправляется на девайс. И уже видно, что в команде сразу указываются все параметры, которые до этого мы указывали в файле pdxinfo.

Теперь давайте отвлечемся от playdate и вспомним старые добрые времена

Gopher

Игры, это хорошо. Но мне всегда интересно писать какие-то сетевые приложения. И playdate тоже умеет работать с сетью. Поэтому давайте напишем простое сетевое приложение, которое будет выводить новости kodikapusta.ru

Это можно было бы сделать по http. Но сам playdate — это же ретро-консоль, поэтому давайте использовать ретро-протокол. И отлично будет использовать протокол Gopher

Gopher - это протокол передачи данных через интернет, который был очень популярен до того как HTTP занял его место. Он ушёл в небытие ещё в далёком 1993-м. Со временем он перестал поддерживаться браузерами, поэтому при попытке перейти по gopher://-ссылке какой-нибудь Firefox с сожалением выдаёт, что такой протокол не поддерживается

Но Gopher хоть и ушёл, да не совсем. Как это часто бывает с устаревшими технологиями, у Gopher осталось некоторое количество фанатов — не так много, но для поддержки комьюнити хватает.

В чем прикол протокола - он имеет более жёсткую структуру размещения информации, очень похожую на структуру каталогов в файловой системе, которая состоит из вложенных друг в друга каталогов и файлов. Иерархическая структура ссылок позволила пользователям текстовых терминалов очень быстро находить нужную информацию. Это действительно очень похоже на просмотр файлов в командной строке

Протокол не поддерживает каких-то украшательств, языков разметки и джаваскриптов. Только файлы и директории.

Несмотря на древность протокола, все еще существует множество клиентов и мой любимый это Bombadillo.

Но протокол прекрасен тем, что вам не нужен клиент, достаточно обычного telnet

telnet gopher.floodgap.com 70

После подключения укажите корень:

telnet gopher.floodgap.com 70
Trying 76.79.210.36...
Connected to gopher.floodgap.com.
Escape character is '^]'.
/

И в ответ вы увидите пачку строчек

iWelcome to Floodgap Systems' official gopher server.		error.host	1
iFloodgap has served the gopher community since 1999		error.host	1
i(formerly gopher.ptloma.edu).		error.host	1
i 		error.host	1
iWe run Bucktooth 0.2.9 on xinetd as our server system.		error.host	1
igopher.floodgap.com is an IBM Power 520 Express with a 2-way		error.host	1
i4.2GHz POWER6 CPU and 8GB of RAM, running AIX 6.1 +patches.		error.host	1
iSend gopher@floodgap.com your questions and suggestions.		error.host	1
i 		error.host	1
i***********************************************************		error.host	1
i**              OVER 20 YEARS SERVING YOU!               **		error.host	1
i**               Plain text is beautiful!                **		error.host	1
i***********************************************************		error.host	1
i 		error.host	1
0Does this gopher menu look correct?	/gopher/proxy	gopher.floodgap.com	70
i(plus using the Floodgap Public Gopher Proxy)		error.host	1
0Are you running a bot? Are we blocking you?	/responsible-bot	gopher.floodgap.com	70
iPlease read this document. Please don't cost us money.		error.host	1
i(blocklist updated 27 April 2026)		error.host	1
1Super-Dimensional Fortress: SDF Gopherspace		sdf.org	70
iGet your own Gopherspace and shell account!		error.host	1
i 		error.host	1
i--- Getting started with Gopher -------------------------------		error.host	1
1Getting started with gopher, software, more	/gopher	gopher.floodgap.com	70
i(what is Gopherspace? We tell you! And find out how		error.host	1
ito create your own Gopher world!)		error.host	1
i 		error.host	1
0Using your web browser to explore Gopherspace	/gopher/wbgopher	gopher.floodgap.com	70
.

Каждая строка — это элемент меню. Все строки формируются в таком формате:

[тип] [отображаемый текст] [путь] [хост] [порт]

Какими бывают типы:

  • i - информационная строка
  • 1 - ссылка на другое Gopher-меню, на которое можно перейти
  • 0 - текстовый файл, который можно скачать

По сути, каждая строчка, которая начинается не с i - это "ссылка" на другое меню или на файл

Давайте реализуем свой простой Gopher сервер

Пишем сервер

Очевидно, что в Go нет встроенного Gopher сервера(а было бы прикольно). Поэтому воспользуемся обычным net.Listen

func main() {
	addr := ":" + port

	listener, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}

	defer listener.Close()

	log.Printf("Gopher server started on %s:%s", host, port)

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Connection error: %v", err)

			continue
		}

		// тут будет вся основная логика
		go handler(conn)
	}
}

Ниже пример реализации handler функции

func handler(conn net.Conn) {
	defer conn.Close()

	sel, err := selector(conn)
	if err != nil {
		log.Printf("Read error: %v", err)
		return
	}

	log.Printf("Request: %q", sel)

	switch {
	case sel == "" || sel == "/":
		menu(conn, rootMenu)
	case sel == "/stories" || sel == "/stories/":
		menu(conn, storiesMenu)
	case strings.HasPrefix(sel, "/"):
		file(conn, sel)
	default:
		fail(conn, "Unknown selector")
	}
}

Чтобы понять, куда нам нужно перейти, в какую директорию или какой файл скачать - нам нужно получить селектор. Селектор это просто строка, которая начинается со слеша / и заканчивается \r\n.

func selector(conn net.Conn) (string, error) {
	reader := bufio.NewReader(conn)
	line, err := reader.ReadString('\n')
	if err != nil {
		return "", err
	}
	return strings.TrimRight(line, "\r\n"), nil
}

Кусочек кода

case sel == "/stories" || sel == "/stories/":
	menu(conn, storiesMenu)

Обрабатывает переходы в другое меню. Меню формируется очень просто:

func menu(conn net.Conn, entries []menuEntry) {
	writer := bufio.NewWriter(conn)
	for _, entry := range entries {
		switch entry.Type {
		case 'i': // info
			fmt.Fprintf(writer, "i%s\tfake\tfake\t0\r\n", entry.Display)
		default: // file
			fmt.Fprintf(writer, "%c%s\t%s\t%s\t%s\r\n",
				entry.Type, entry.Display, entry.Selector, host, port)
		}
	}
	writer.WriteString(".\r\n")
	writer.Flush()
}

Тут мы просто пробегаемся по списку всех айтемов и формируем строку для каждого айтема со всеми нужными для протокола параметрами.

Обратите внимание, можно сделать немного украшательства в меню с помощью пробелов и пустых строк. Пустые строки - это айтемы с типом i и пустыми строками в названии

var rootMenu = []menuEntry{
	{'i', "Привет всем любителям старины!", ""},
	{'i', "", ""},
	{'i', "", ""},
	{'0', "О закерах (фантастический рассказ)", "/about_zakers.txt"},
	{'1', "Ещё рассказы", "/stories"},
}

Это все доступное нам форматирование и это прекрасно.

Кроме просмотра меню, в моей реализации протокола можно еще скачивать текстовые(и только текстовые) файлы:

func file(conn net.Conn, selector string) {
	cleanPath := filepath.Clean(strings.TrimPrefix(selector, "/"))
	fullPath := filepath.Join(dataDir, cleanPath)

	data, err := os.ReadFile(fullPath)
	if err != nil {
		fail(conn, "File not found")
		return
	}

	writer := bufio.NewWriter(conn)
	writer.Write(data)
	if !strings.HasSuffix(string(data), "\n") {
		writer.WriteString("\r\n")
	}
	writer.WriteString(".\r\n")
	writer.Flush()
}

Тут все очень просто - просто по запросу отдаем файл в соединение

Реализуем клиент

Сервер готов, теперь перейдем к самому интересному - реализуем клиент для Gopher протокола, который будет работать с сервером

Шрифты

Со шрифтами в playdate придется заморочиться. Не получится просто взять и использовать ttf шрифты, вам нужно будет конвертировать их в специальный формат для playdate. Специальный формат - это картинка с буквами и текстовый файл с координаиами каждой буквы. Добры люди позаботились о нас и сделали простенький сервис для конвертирования ttf шрифтов в формат для playdate

Я немного поигрался со шрифтами, результаты экспериментов можно посмотреть в папке source/assets/fonts. В итоге, выбрал JetBrainsMono-ExtraLight

Кстати, есть еще инструмент для работы со шрифтами от самих разработчиков - caps

Работа с сетью

В playdate уже есть некоторое базовое API для работы с сетью, которое нам отлично подойдет.

Есть две возможности: использовать поддержку HTTP, реализованную в самой SDK или использовать обычный TCP. Очевидно, что для реализации клиента gopher протокола нам понадобится именно TCP.

В pdgo для доступа к TCP нужно использовать c.pd.Network.TCP(), например:

tcp := c.pd.Network.TCP()
conn := tcp.NewConnection(c.server, c.port, false)

if conn == nil {
  return nil, fmt.Errorf("failed to create connection")
}

Погнали теперь реализуем все вместе

Клиент

Начнем с инициализации всех зависимостей в файле source/main.go:

package main

import (
	"gopher/gopher"

	"github.com/playdate-go/pdgo"
)

var pd *pdgo.PlaydateAPI

// Server configuration - change these to match your test server
const (
	server = "192.168.0.108"
	port   = 7070
)

var (
	client     *gopher.Client
	controller *Controller
	view       *View
)

func initGame() {
	font, err := pd.Graphics.LoadFont("assets/fonts/JetBrainsMono-ExtraLight")
	if err != nil {
		pd.System.LogToConsole("Error on load font: " + err.Error())
	} else {
		pd.Graphics.SetFont(font)
	}

	pd.System.LogToConsole("Networking Demo: Starting")

	client = gopher.New(pd, server, port)
	view = NewView(pd.Graphics, pd.System)
	controller = NewController(view, client, pd.System)
}

// ...

Тут мы сначала инициализируем шрифт, затем клиент для gopher протокола и потом вьюв и контроллер. Чуть позже разберем каждый компонент, а пока посмотрим на функцию update()

func update() int {
	// чистим все что у нас есть на экране
	pd.Graphics.Clear(pdgo.SolidWhite)

	// рендерим данные 
	view.Render()

	// получаем состояние нажатых кнопок
	_, pushed, _ := pd.System.GetButtonState()
	// проверяем что нажата кнопка B
	if pushed&pdgo.ButtonB != 0 {
		pd.System.LogToConsole("Press button A")

		// обрабатываем нажатие кнопки B
		controller.PressB()

	}

	if pushed&pdgo.ButtonA != 0 {
		pd.System.LogToConsole("Press button A")

		// обрабатываем нажатие кнопки A
		controller.PressA(view.Cursor)
	}

  // обработка крутилки
	controller.Crank()

	// Рисуем FPS
	pd.System.DrawFPS(0, 0)

	return 1
}

Дальше посмотрим на контроллер и обработку нажатия кнопки B

func (c *Controller) PressB() {
	reply := c.client.RequestAccess(func(allowed bool) {
		c.system.LogToConsole(fmt.Sprintf("TCPAccessCallback: %v", allowed))
	})

	if reply != pdgo.AccessAsk {
		c.system.LogToConsole(fmt.Sprintf("TCP access reply immediate: %d", int(reply)))
	}

	if reply != pdgo.AccessAllow {
		c.system.LogToConsole("TCP access denied")

		return
	}

	c.client.GetDirectory("", func(items []gopher.Item) {
		c.view.Data(Data{
			Items: items,
		})

	}, func(err error) {
		c.system.LogToConsole(fmt.Sprintf("Error: %v", err))
	})
}

В этом обработчике в первую очередь пытаемся получить доступ к сетевым функциям. Если доступ получен, то пытаемся загрузить корневое меню(или директорию) вызвав c.client.GetDirectory(). В этот метод передается обработчик успешного ответа сервера и обработчик ошибки. В случае успеха мы передаем во view данные, полученные из клиента

Теперь разберемся с client = gopher.New(pd, server, port). Реализация клиента для протокола вынесена в отдельный пакет gopher, а потом снова вернемся к контроллеру, чтобы посмотреть как реализована прокрутка контента и переход по меню

Итак, один из основных методов клиента - это GetDirectory

func (c *Client) GetDirectory(selector string, onItems func([]Item), onError func(error)) {
	conn, err := c.connect()

	if err != nil {
		onError(err)

		return
	}

	c.begin(conn, selector, stateDir, onItems, nil, onError)
}

Первым шагом мы устанавливаем соединение и вызываем обработчик onError если что-то пошло не так

Вся магия происходит в методе c.begin(conn, selector, stateDir, onItems, nil, onError)


func (c *Client) begin(
	conn Connection,
	request string,
	st state,
	onItems func([]Item),
	onData func([]byte),
	onError func(error),
) {
	c.pd.System.LogToConsole("client begin")

	if conn == nil {
		return
	}

	conn.SetConnectionClosedCallback(func(err pdgo.PDNetErr) {
		c.pd.System.LogToConsole("connection close callback")

		avail := conn.GetBytesAvailable()
		if err != pdgo.NetOK {
			c.pd.System.LogToConsole(fmt.Sprintf("TCP request complete, err=%d", int(err)))

			onError(fmt.Errorf("TCP request failed, err=%d", int(err)))

			return
		} else {
			c.pd.System.LogToConsole(fmt.Sprintf("TCP request complete, %d bytes available", avail))
		}

		data := []byte{}

		for avail > 0 {
			buf := make([]byte, 256)

			n := conn.Read(buf)
			if n > 0 {
				data = append(data, buf...)
			}
			avail = conn.GetBytesAvailable()
		}

		switch st {
		case stateDir:
			items := c.processDir(data)

			if onItems != nil {
				onItems(items)
			}
		case stateFile:
			if onData != nil {
				onData(data)
			}
		}
	})

	conn.Open(func(err pdgo.PDNetErr) {
		c.pd.System.LogToConsole("connection open callback")

		if err != pdgo.NetOK {
			onError(fmt.Errorf("TCP open failed, err=%d", int(err)))

			return
		}
		conn.Write([]byte(request + "\r\n"))
	})
}

Обратите внимание, что на каждый запрос мы заново подключаемся к серверу. Напомню, что после получения ответа на запрос, сервер обрывает соединение на своей стороне:

func handler(conn net.Conn) {
	defer conn.Close()
  // ...
}

Теперь самое интересное - посмотрим как реализована прокрутка и переход по меню. Для этого нужно посмотреть на метод view.Render()


func (v *View) Render() {
	pos := -v.crnk

	if pos > 0 {
		pos = 0
		v.crnk = 0
	}

	v.graphics.Clear(pdgo.SolidWhite)

	if len(v.data.Items) == 0 && v.data.File == "" {
		v.graphics.DrawText("Connecting:", 10, 10)

		return
	}

	cursor := gopher.Item{}
	for _, item := range v.data.Items {
		pos += fontSize

		switch item.Type {
		case 'i':
			v.graphics.DrawText(item.Name, marginLeft, int(pos))
			pos += float32(strings.Count(item.Name, "\n")) * fontSize
		case '0':
			if int(pos) >= 40 && int(pos) <= 40+5 {
				cursor = item
			}

			v.graphics.DrawText("TXT |  "+item.Name, marginLeft, int(pos))
		case '1':
			if int(pos) >= 40 && int(pos) <= 40+5 {
				cursor = item
			}

			v.graphics.DrawText("DIR |  "+item.Name, marginLeft, int(pos))
		}
	}

	v.Cursor = cursor

	if v.data.File != "" {
		v.graphics.DrawText(v.data.File, marginLeft, int(pos))
	}

	v.graphics.DrawText(">", 1, 40)
}

После того, как мы получили список айтемов, мы определяем в каких координатах их нужно отрендерить. Тут нам нужно получать значение положения крэнка v.crnk и использовать его для рассчета позиции

Кроме самих айтемов меню, еще рендерится стрелка курсора v.graphics.DrawText(">", 1, 40). И тут начинается самое интересное. У нас нет ни мышки ни клавиатуры, но как-то выполнять навигацию нужно. Поэтому, мы будем использовать крэнк для вертикальной прокрутки текста на экране, а курсор будет указывать на активный пункт меню, в который мы перейдем, если нажмем кнопку A. Это работает так: когда текст прокручивается по экрану, то проверяется позиция каждого пункта меню

if int(pos) >= 40 && int(pos) <= 40+5 {
  cursor = item
}

И если позиция меню совпадает с позицией курсора >, то это меню сохраняем в специальную переменную, которая используется как раз при нажатии кнопки A

func (c *Controller) PressA(cursor gopher.Item) {
	c.system.LogToConsole("Pressed A: " + cursor.Selector)

	if cursor.Type == '1' {
		c.client.GetDirectory(cursor.Selector, func(items []gopher.Item) {
			c.view.Data(Data{
				Items: items,
			})

		}, func(err error) {
			c.system.LogToConsole(fmt.Sprintf("Error: %v", err))
		})
	}

	if cursor.Type == '0' {
		c.client.GetFile(cursor.Selector, func(file []byte) {
			c.view.Data(Data{
				File: string(file),
			})

		}, func(err error) {
			c.system.LogToConsole(fmt.Sprintf("Error: %v", err))
		})
	}
}

Вот таким нехитрым способом получается простая онлайн читалка на playdate

Gemini

Gemini, или Project Gemini, — это прикладной интернет-протокол для доступа к удаенным документам, аналогичный HTTP и Gopher. Он использует специальный формат документов, обычно называемый "gemtext", который позволяет создавать ссылки на другие документы.

Дизайн вдохновлен Gopher, но с современными дополнениями, такими как обязательное использование Transport Layer Security (TLS) для соединений и гипертекстовый формат в качестве собственного типа контента.

Протокол сравнительно новый. Проект Gemini был запущен в июне 2019 года компанией Solderpunk. Дополнительную работу проделало неформальное сообщество пользователей.

Дизайн намеренно не предусматривает простого расширения, чтобы соответствовать цели проекта — простоте

Протокол классический - запрос от клиента, ответ от сервера. На каждый запрос устанавливается новое соединение.

Ресурсы Gemini идентифицируются и размещаются в сети с помощью URL, используя схему URI gemini://. Запрос Gemini состоит только из такого URL, завершающегося символом CRLF; заголовок ответа Gemini состоит из двузначного кода состояния, пробела и поля "meta", также завершающегося символом CRLF. Если серверу удается найти запрошенный файл, в поле "meta" указывается MIME-тип возвращаемого файла, а после заголовка следуют данные файла.

Пример запроса от клиента

gemini://example.com/

Пример ответа от сервера:

20 text/gemini
# Example Title
Welcome to my Gemini capsule.
* Example list item
=> gemini://link.to/another/resource Link text

Формат Gemtext ориентирован на строки, и первые три символа строки определяют ее тип. Синтаксис очень похож на маркдаун (это он и есть), который включает разметку для заголовков, элементов простого списка, предварительно отформатированного текста, цитат и строк со ссылками. Как и в гипертексте HTTP, URI кодируются как гиперссылки для создания взаимосвязанных гипертекстовых документов в "веб-пространстве" Gemini, которое пользователи называют Geminispace

Что-то среднее между Gopher и HTTP, где вместо HTML у вас маркдаун

Когда-нибудь эти протоколом я тоже займусь

Читаем новости "код и капуста" на playdate

Для "код и капуста" я тоже добавил gopher сервер и теперь у вас есть возможность смотреть новости прям в playdate

Получилось такое маленькое и симпатичное приложение с новостями. Вы можете посмотреть пример сервера и скачать приложение на playdate c GitHub

Кстати, для playdate есть и браузер для обычных сайтов. Но это, конечно, не так интересно

Ссылки

И целая подборка ссылок про протокол Gemini