From 8d51d6ec6a6d0e6d4bda1f1548a2a76c06118017 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 11 Nov 2026 14:43:39 +0100 Subject: [PATCH] Add browser gamepad support with pad button pictograms Co-Authored-By: deepseek-v4.1-flash (opencode-go) via CodeCraft --- lang/cs.lang | 19 ++++ lang/da.lang | 19 ++++ lang/de.lang | 19 ++++ lang/el.lang | 19 ++++ lang/en.lang | 19 ++++ lang/es.lang | 19 ++++ lang/fi.lang | 19 ++++ lang/hr.lang | 19 ++++ lang/hu.lang | 19 ++++ lang/it.lang | 19 ++++ lang/nl.lang | 19 ++++ lang/no.lang | 19 ++++ lang/pt.lang | 19 ++++ lang/ro.lang | 19 ++++ lang/ru.lang | 19 ++++ lang/sk.lang | 19 ++++ lang/sr.lang | 19 ++++ lang/sv.lang | 19 ++++ src/gfx.rs | 213 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 86 ++++++++++++++---- src/menu.rs | 22 ++++- src/pad.rs | 229 +++++++++++++++++++++++++++++++++++++++++++++++ src/platform.rs | 38 ++++++++ src/settings.rs | 2 + src/ui.rs | 86 +++++++++++++++++- www/dc_plugin.js | 70 +++++++++++++++ 26 files changed, 1066 insertions(+), 22 deletions(-) create mode 100644 src/pad.rs diff --git a/lang/cs.lang b/lang/cs.lang index b19aa3a..fd9435d 100644 --- a/lang/cs.lang +++ b/lang/cs.lang @@ -440,6 +440,25 @@ help.t_long = Dlouhý stisk: informace o místě; ruka: položí tam jednotky help.t_double = Dvojité klepnutí rukou: pohlavek vlastním, pěst hrdinům na tvém území help.t_tools = Lišta nástrojů: kategorie otevřou řadu nástrojů; tlačítko menu otevře menu pauzy help.start_touch = Klepni pro start +# gamepad (Gamepad API v prohlížeči) +set.gamepad = Tlačítka gamepadu +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Použít +pad.back = Zpět +pad.tool = Nástroj +pad.menu = Menu +pad.camera = Kamera +pad.zoom = Zvětšení +help.p_move = Levá páčka – posun mapy +help.p_aim = Pravá páčka – pohyb kurzoru +help.p_rot = D-pad – otáčení a náklon +help.p_zoom = LT / RT – přiblížení a oddálení +help.p_use = A / křížek – použít nástroj, potvrdit (držet: natáhnout obdélník) +help.p_back = B / kolečko – zpět, pauza Start Menu +help.p_tool = LB / RB – volba nástroje Select – tato nápověda + # rivers and bridges tool.bridge.label = Most diff --git a/lang/da.lang b/lang/da.lang index c6bc679..e8e6164 100644 --- a/lang/da.lang +++ b/lang/da.lang @@ -439,6 +439,25 @@ help.t_long = Langt tryk: oplysninger om stedet; hånd: sæt enhederne ned der help.t_double = Dobbelttryk med hånden: bagkast til egne enheder, slå helte på din jord help.t_tools = Værktøjslinje: kategorier åbner en række værktøjer; menuknappen åbner pausemenuen help.start_touch = Tryk for at starte +# gamepad (browserens Gamepad API) +set.gamepad = Gamepad-knapper +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Brug +pad.back = Tilbage +pad.tool = Værktøj +pad.menu = Menu +pad.camera = Kamera +pad.zoom = Zoom +help.p_move = Venstre stick – flyt kortet +help.p_aim = Højre stick – flyt markøren +help.p_rot = D-pad – roter og vip +help.p_zoom = LT / RT – zoom ind og ud +help.p_use = A / kryds – brug værktøj, bekræft (hold: træk et rektangel) +help.p_back = B / cirkel – tilbage, pausemenu Start Menu +help.p_tool = LB / RB – vælg værktøj Select – denne hjælp + # rivers and bridges tool.bridge.label = Bro diff --git a/lang/de.lang b/lang/de.lang index a9ba05e..bebe98a 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -440,6 +440,25 @@ help.t_double = Doppelt tippen mit der Hand: Rückhand für eigene, Faust für H help.t_tools = Werkzeugleiste: Kategorien öffnen eine Reihe Werkzeuge; die Menütaste öffnet das Pausemenü help.start_touch = Tippen zum Starten +# gamepad (Browser-Gamepad-API) +set.gamepad = Gamepad-Tasten +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Benutzen +pad.back = Zurück +pad.tool = Werkzeug +pad.menu = Menü +pad.camera = Kamera +pad.zoom = Zoom +help.p_move = Linker Stick Karte bewegen +help.p_aim = Rechter Stick Zeiger bewegen +help.p_rot = Steuerkreuz Drehen und neigen +help.p_zoom = LT / RT Hinein- und herauszoomen +help.p_use = A / Kreuz Werkzeug benutzen, bestätigen (halten: Rechteck ziehen) +help.p_back = B / Kreis Zurück, Pausenmenü Start Menü +help.p_tool = LB / RB Werkzeug wählen Select Diese Hilfe + # rivers and bridges tool.bridge.label = Brücke tool.bridge.name = Brücke bauen diff --git a/lang/el.lang b/lang/el.lang index 4bd3e1c..48d0205 100644 --- a/lang/el.lang +++ b/lang/el.lang @@ -440,6 +440,25 @@ help.t_long = Παρατεταμένο πάτημα: πληροφορίες γι help.t_double = Διπλό πάτημα με το χέρι: χαστούκι στις δικές σου μονάδες, γροθιά σε ήρωες στο έδαφός σου help.t_tools = Γραμμή εργαλείων: οι κατηγορίες ανοίγουν μια σειρά εργαλείων, το κουμπί μενού ανοίγει το μενού παύσης help.start_touch = Πάτησε για αρχή +# gamepad (Το Gamepad API του φυλλομετρητή) +set.gamepad = Κουμπιά χειριστηρίου +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Χρήση +pad.back = Πίσω +pad.tool = Εργαλείο +pad.menu = Μενού +pad.camera = Κάμερα +pad.zoom = Εστίαση +help.p_move = Αριστερό stick – μετακίνηση χάρτη +help.p_aim = Δεξί stick – μετακίνηση δείκτη +help.p_rot = D-pad – περιστροφή και κλίση +help.p_zoom = LT / RT – μεγέθυνση και σμίκρυνση +help.p_use = A / Σταυρός – χρήση εργαλείου, επιβεβαίωση (κράτημα: ορθογώνιο) +help.p_back = B / Κύκλος – πίσω, μενού παύσης Start Μενού +help.p_tool = LB / RB – επιλογή εργαλείου Select – αυτή η βοήθεια + # rivers and bridges tool.bridge.label = Γέφυρα diff --git a/lang/en.lang b/lang/en.lang index e5c0f5f..19ce809 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -441,6 +441,25 @@ help.t_double = Double tap with the hand: backhand own units, punch heroes on yo help.t_tools = Toolbar: categories open a row of tools; the menu button opens the pause menu help.start_touch = Tap to start +# gamepad (browser Gamepad API) +set.gamepad = Gamepad buttons +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Use +pad.back = Back +pad.tool = Tool +pad.menu = Menu +pad.camera = Camera +pad.zoom = Zoom +help.p_move = Left stick Move the map +help.p_aim = Right stick Move the pointer +help.p_rot = D-pad Rotate and tilt +help.p_zoom = LT / RT Zoom in and out +help.p_use = A / cross Use the tool, confirm (hold: drag a rectangle) +help.p_back = B / circle Back, pause menu Start Menu +help.p_tool = LB / RB Select the tool Select This help + # rivers and bridges tool.bridge.label = Bridge tool.bridge.name = Build Bridge diff --git a/lang/es.lang b/lang/es.lang index 3bb9474..f32b7f0 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -440,6 +440,25 @@ help.t_long = Pulsación larga: información del punto; mano: soltar ahí las un help.t_double = Doble toque con la mano: abofetear unidades propias, golpear héroes en tu suelo help.t_tools = Barra de herramientas: las categorías abren una fila de herramientas; el botón de menú abre el menú de pausa help.start_touch = Toca para empezar +# gamepad (Gamepad API del navegador) +set.gamepad = Botones del mando +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Usar +pad.back = Atrás +pad.tool = Herramienta +pad.menu = Menú +pad.camera = Cámara +pad.zoom = Zoom +help.p_move = Stick izquierdo: mover el mapa +help.p_aim = Stick derecho: mover el puntero +help.p_rot = D-pad: rotar e inclinar +help.p_zoom = LT / RT: acercar y alejar +help.p_use = A / equis: usar herramienta, confirmar (mantener: arrastrar un rectángulo) +help.p_back = B / círculo: atrás, menú de pausa Start Menú +help.p_tool = LB / RB: elegir herramienta Select esta ayuda + # rivers and bridges tool.bridge.label = Puente diff --git a/lang/fi.lang b/lang/fi.lang index 366e3fc..86fab93 100644 --- a/lang/fi.lang +++ b/lang/fi.lang @@ -440,6 +440,25 @@ help.t_long = Pitkä painallus: tietoa kohdasta; käsi: pudota yksiköt siihen help.t_double = Kaksoisnapautus kädellä: läimäytä omia, mukiloi sankareita omalla maalla help.t_tools = Työkalupalkki: kategoriat avaavat työkalurivin; valikkonappi avaa taukovalikon help.start_touch = Napauta aloittaaksesi +# gamepad (selaimen Gamepad API) +set.gamepad = Ohjaimen painikkeet +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Käytä +pad.back = Takaisin +pad.tool = Työkalu +pad.menu = Valikko +pad.camera = Kamera +pad.zoom = Zoom +help.p_move = Vasen sauva – siirrä karttaa +help.p_aim = Oikea sauva – siirrä osoitinta +help.p_rot = D-pad – pyöritä ja kallista +help.p_zoom = LT / RT – lähennä ja loitonna +help.p_use = A / risti – käytä työkalua, vahvista (pidä: vedä suorakulmio) +help.p_back = B / ympyrä – takaisin, taukovalikko Start Valikko +help.p_tool = LB / RB – valitse työkalu Select – tämä ohje + # rivers and bridges tool.bridge.label = Silta diff --git a/lang/hr.lang b/lang/hr.lang index e3b3c62..737a1f2 100644 --- a/lang/hr.lang +++ b/lang/hr.lang @@ -440,6 +440,25 @@ help.t_long = Dugi pritisak: podaci o mjestu; ruka: spusti jedinice ondje help.t_double = Dvostruki dodir rukom: pljusni svoje jedinice, udari junake na svom tlu help.t_tools = Alatna traka: kategorije otvaraju red alata; tipka izbornika otvara izbornik stanke help.start_touch = Dodirni za početak +# gamepad (Gamepad API preglednika) +set.gamepad = Gumbi gamepada +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Koristi +pad.back = Natrag +pad.tool = Alat +pad.menu = Izbornik +pad.camera = Kamera +pad.zoom = Zum +help.p_move = Lijevi stick – pomicanje karte +help.p_aim = Desni stick – pomicanje kursora +help.p_rot = D-pad – rotacija i nagib +help.p_zoom = LT / RT – zumiranje +help.p_use = A / križ – koristi alat, potvrdi (drži: povuci pravokutnik) +help.p_back = B / krug – natrag, izbornik pauze Start Izbornik +help.p_tool = LB / RB – odabir alata Select – ova pomoć + # rivers and bridges tool.bridge.label = Most diff --git a/lang/hu.lang b/lang/hu.lang index e6913ef..f963824 100644 --- a/lang/hu.lang +++ b/lang/hu.lang @@ -440,6 +440,25 @@ help.t_long = Hosszú nyomás: információ a helyről; kéz: az egységek lerak help.t_double = Dupla koppintás a kézzel: saját egységek felpofozása, hősök ütése a saját földeden help.t_tools = Eszköztár: a kategóriák egy eszközsort nyitnak meg; a menügomb a szünetmenüt nyitja meg help.start_touch = Koppints az indításhoz +# gamepad (a böngésző Gamepad API-ja) +set.gamepad = Gamepad gombok +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Használat +pad.back = Vissza +pad.tool = Eszköz +pad.menu = Menü +pad.camera = Kamera +pad.zoom = Nagyítás +help.p_move = Bal bot – térkép mozgatása +help.p_aim = Jobb bot – kurzor mozgatása +help.p_rot = D-pad – forgatás és döntés +help.p_zoom = LT / RT – közelebb és távolabb +help.p_use = A / kereszt – eszköz használata, megerősítés (tartsd: téglalap húzása) +help.p_back = B / kör – vissza, szünetmenü Start Menü +help.p_tool = LB / RB – eszköz választása Select ez a súgó + # rivers and bridges tool.bridge.label = Híd diff --git a/lang/it.lang b/lang/it.lang index 8c4b601..6a19fa8 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -440,6 +440,25 @@ help.t_long = Pressione lunga: informazioni sul punto; mano: posa lì le unità help.t_double = Doppio tocco con la mano: schiaffo alle tue unità, pugno agli eroi sul tuo suolo help.t_tools = Barra: le categorie aprono una fila di strumenti; il pulsante menu apre il menu di pausa help.start_touch = Tocca per iniziare +# gamepad (Gamepad API del browser) +set.gamepad = Pulsanti del gamepad +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Usa +pad.back = Indietro +pad.tool = Strumento +pad.menu = Menu +pad.camera = Camera +pad.zoom = Zoom +help.p_move = Stick sinistro: sposta la mappa +help.p_aim = Stick destro: sposta il puntatore +help.p_rot = D-pad: ruota e inclina +help.p_zoom = LT / RT: zoom avanti e indietro +help.p_use = A / croce: usa lo strumento, conferma (tieni premi: disegna un rettangolo) +help.p_back = B / cerchio: indietro, menu di pausa Start Menu +help.p_tool = LB / RB: scegli strumento Select questa guida + # rivers and bridges tool.bridge.label = Ponte diff --git a/lang/nl.lang b/lang/nl.lang index 4425947..1d3ae06 100644 --- a/lang/nl.lang +++ b/lang/nl.lang @@ -440,6 +440,25 @@ help.t_long = Lang drukken: informatie over de plek; hand: units daar neerzetten help.t_double = Dubbeltikken met de hand: eigen units een mep geven, helden op je grond slaan help.t_tools = Werkbalk: categorieën openen een rij gereedschap; de menuknop opent het pauzemenu help.start_touch = Tik om te beginnen +# gamepad (Gamepad API van de browser) +set.gamepad = Gamepadknoppen +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Gebruiken +pad.back = Terug +pad.tool = Gereedschap +pad.menu = Menu +pad.camera = Camera +pad.zoom = Zoom +help.p_move = linker stick: kaart verplaatsen +help.p_aim = rechter stick: aanwijzer verplaatsen +help.p_rot = d-pad: draaien en kantelen +help.p_zoom = LT / RT: in- en uitzoomen +help.p_use = A / kruis: gereedschap gebruiken, bevestigen (indrukken: rechthoek slepen) +help.p_back = B / cirkel: terug, pauzemenu Start Menu +help.p_tool = LB / RB: gereedschap kiezen Select deze help + # rivers and bridges tool.bridge.label = Brug diff --git a/lang/no.lang b/lang/no.lang index faf2e74..7f0bcf7 100644 --- a/lang/no.lang +++ b/lang/no.lang @@ -439,6 +439,25 @@ help.t_long = Langt trykk: informasjon om stedet; hånd: sett ned enhetene der help.t_double = Dobbelttrykk med hånden: baksveiv egne enheter, slå helter på din grunn help.t_tools = Verktøylinje: kategorier åpner en rad med verktøy; menyknappen åpner pausemenyen help.start_touch = Trykk for å starte +# gamepad (nettleserens Gamepad API) +set.gamepad = Gamepad-knapper +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Bruk +pad.back = Tilbake +pad.tool = Verktøy +pad.menu = Meny +pad.camera = Kamera +pad.zoom = Zoom +help.p_move = Venstre stick – flytt kartet +help.p_aim = Høyre stick – flytt pekeren +help.p_rot = D-pad – roter og vip +help.p_zoom = LT / RT – zoom inn og ut +help.p_use = A / kryss – bruk verktøyet, bekreft (hold: dra et rektangel) +help.p_back = B / sirkel – tilbake, pausemeny Start Meny +help.p_tool = LB / RB – verktøyvalg Select – denne hjelpen + # rivers and bridges tool.bridge.label = Bro diff --git a/lang/pt.lang b/lang/pt.lang index 6c3ec12..785a1bc 100644 --- a/lang/pt.lang +++ b/lang/pt.lang @@ -440,6 +440,25 @@ help.t_long = Toque longo: informação sobre o ponto; mão: largar ali as unida help.t_double = Toque duplo com a mão: esbofetear as tuas unidades, socar heróis no teu chão help.t_tools = Barra de ferramentas: as categorias abrem uma fila de ferramentas; o botão de menu abre o menu de pausa help.start_touch = Toca para começar +# gamepad (Gamepad API do navegador) +set.gamepad = Botões do controle +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Usar +pad.back = Voltar +pad.tool = Ferramenta +pad.menu = Menu +pad.camera = Câmera +pad.zoom = Zoom +help.p_move = Analógico esquerdo: mover o mapa +help.p_aim = Analógico direito: mover o cursor +help.p_rot = D-pad: girar e inclinar +help.p_zoom = LT / RT: aproximar e afastar +help.p_use = A / cruz: usar a ferramenta, confirmar (segurar: arrastar um retângulo) +help.p_back = B / círculo: voltar, menu de pausa Start Menu +help.p_tool = LB / RB: escolher ferramenta Select esta ajuda + # rivers and bridges tool.bridge.label = Ponte diff --git a/lang/ro.lang b/lang/ro.lang index 04852e5..0f32af8 100644 --- a/lang/ro.lang +++ b/lang/ro.lang @@ -440,6 +440,25 @@ help.t_long = Apăsare lungă: informații despre loc; mână: lasă jos unită help.t_double = Atingere dublă cu mâna: palmă unităților tale, pumn eroilor de pe terenul tău help.t_tools = Bara de unelte: categoriile deschid un rând de unelte; butonul de meniu deschide meniul de pauză help.start_touch = Atinge pentru a începe +# gamepad (Gamepad API-ul browserului) +set.gamepad = Butoane gamepad +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Folosește +pad.back = Înapoi +pad.tool = Unealtă +pad.menu = Meniu +pad.camera = Cameră +pad.zoom = Zoom +help.p_move = Stickul stâng – mută harta +help.p_aim = Stickul drept – mută pointerul +help.p_rot = D-pad – rotește și înclină +help.p_zoom = LT / RT – apropiere și depărtare +help.p_use = A / cruce – folosește unealta, confirmă (ține apăsat: trage un dreptunghi) +help.p_back = B / cerc – înapoi, meniul de pauză Start Meniu +help.p_tool = LB / RB – alege unealta Select – acest ajutor + # rivers and bridges tool.bridge.label = Pod diff --git a/lang/ru.lang b/lang/ru.lang index 1ad1e65..592b87c 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -440,6 +440,25 @@ help.t_long = Долгое нажатие: сведения о месте; ру help.t_double = Двойное касание рукой: шлепок своим, удар героям на своей земле help.t_tools = Панель: категории открывают ряд инструментов; кнопка меню открывает паузу help.start_touch = Коснись, чтобы начать +# gamepad (Gamepad API браузера) +set.gamepad = Кнопки геймпада +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Использовать +pad.back = Назад +pad.tool = Инструмент +pad.menu = Меню +pad.camera = Камера +pad.zoom = Масштаб +help.p_move = Левый стик — движение карты +help.p_aim = Правый стик — движение курсора +help.p_rot = Крестовина — поворот и наклон +help.p_zoom = LT / RT — приближение и отдаление +help.p_use = A / крестик — использовать инструмент, подтвердить (удерживать: рамка) +help.p_back = B / кружок — назад, меню паузы Start меню +help.p_tool = LB / RB — выбор инструмента Select эта справка + # rivers and bridges tool.bridge.label = Мост diff --git a/lang/sk.lang b/lang/sk.lang index 56e5736..823610e 100644 --- a/lang/sk.lang +++ b/lang/sk.lang @@ -440,6 +440,25 @@ help.t_long = Dlhé podržanie: informácie o mieste; ruka: položí tam jednotk help.t_double = Dvojité ťuknutie rukou: zaucho vlastným, päsť hrdinom na tvojom území help.t_tools = Lišta nástrojov: kategórie otvoria rad nástrojov; tlačidlo menu otvorí menu pauzy help.start_touch = Ťukni pre štart +# gamepad (Gamepad API prehliadača) +set.gamepad = Tlačidlá gamepadu +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Použiť +pad.back = Späť +pad.tool = Nástroj +pad.menu = Menu +pad.camera = Kamera +pad.zoom = Priblíženie +help.p_move = Ľavá páčka – posun mapy +help.p_aim = Pravá páčka – posun kurzora +help.p_rot = D-pad – otáčanie a náklon +help.p_zoom = LT / RT – priblíženie a oddialenie +help.p_use = A / krížik – použiť nástroj, potvrdiť (držať: natiahnuť obdĺžnik) +help.p_back = B / krúžok – späť, pauza Start Menu +help.p_tool = LB / RB – výber nástroja Select – táto nápoveda + # rivers and bridges tool.bridge.label = Most diff --git a/lang/sr.lang b/lang/sr.lang index 6a9d95b..02dd824 100644 --- a/lang/sr.lang +++ b/lang/sr.lang @@ -440,6 +440,25 @@ help.t_long = Дуги притисак: подаци о месту; рука: help.t_double = Двоструки додир руком: ошамари своје јединице, удари хероје на свом тлу help.t_tools = Трака алатки: категорије отварају ред алатки; дугме менија отвара мени паузе help.start_touch = Додирни за почетак +# gamepad (Gamepad API прегледника) +set.gamepad = Дугмад контролера +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Користи +pad.back = Назад +pad.tool = Алат +pad.menu = Мени +pad.camera = Камера +pad.zoom = Зум +help.p_move = Леви стик – померање карте +help.p_aim = Десни стик – померање показивача +help.p_rot = D-pad – ротација и нагиб +help.p_zoom = LT / RT – зумирање +help.p_use = A / крстић – користи алат, потврди (држати: повући правоугаоник) +help.p_back = B / круг – назад, пауза Start Мени +help.p_tool = LB / RB – избор алата Select – ова помоћ + # rivers and bridges tool.bridge.label = Мост diff --git a/lang/sv.lang b/lang/sv.lang index 89226c5..8adb076 100644 --- a/lang/sv.lang +++ b/lang/sv.lang @@ -440,6 +440,25 @@ help.t_long = Långt tryck: information om platsen; hand: släpp enheterna där help.t_double = Dubbeltryck med handen: örfila egna enheter, slå hjältar på din mark help.t_tools = Verktygsfält: kategorier öppnar en rad verktyg; menyknappen öppnar pausmenyn help.start_touch = Tryck för att börja +# gamepad (webbläsarens Gamepad API) +set.gamepad = Gamepad-knappar +val.xbox = Xbox +val.playstation = PlayStation +val.nintendo = Nintendo +pad.use = Använd +pad.back = Tillbaka +pad.tool = Verktyg +pad.menu = Meny +pad.camera = Kamera +pad.zoom = Zoom +help.p_move = Vänster spak – flytta kartan +help.p_aim = Höger spak – flytta markören +help.p_rot = Pilknappar – rotera och luta +help.p_zoom = LT / RT – zooma in och ut +help.p_use = A / kors – använd verktyget, bekräfta (håll: dra en rektangel) +help.p_back = B / cirkel – tillbaka, pausmeny Start Meny +help.p_tool = LB / RB – välj verktyg Select – denna hjälp + # rivers and bridges tool.bridge.label = Bro diff --git a/src/gfx.rs b/src/gfx.rs index 33bbddc..8123849 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -6,7 +6,7 @@ use macroquad::prelude::*; pub const CELL: f32 = 16.0; pub const ATLAS_COLS: u32 = 16; -pub const ATLAS_ROWS: u32 = 18; // rows 14-17: cells only the high-res atlases fill (see HD_SETS) +pub const ATLAS_ROWS: u32 = 20; // rows 14-17: cells only the high-res atlases fill (see HD_SETS); rows 18-19: gamepad pictograms // atlas indices (4 variants each) pub const T_ROCK: u32 = 0; @@ -98,6 +98,30 @@ pub const T_GIFT: u32 = 220; // a wrapped present (Christmas) pub const T_FIR: u32 = 221; // fir needles with baubles (Christmas) pub const T_EGG: u32 = 222; // 2 variants: a painted egg (Easter) +// gamepad pictograms (rows 18-19 of the atlas): face buttons of the three pad types, d-pad, +// sticks, shoulders and the select/start buttons +pub const I_PAD_XBOX_A: u32 = 288; // A, B, X, Y on coloured discs +pub const I_PAD_XBOX_B: u32 = 289; +pub const I_PAD_XBOX_X: u32 = 290; +pub const I_PAD_XBOX_Y: u32 = 291; +pub const I_PAD_PS_CROSS: u32 = 292; // cross, circle, square, triangle on dark discs +pub const I_PAD_PS_CIRCLE: u32 = 293; +pub const I_PAD_PS_SQUARE: u32 = 294; +pub const I_PAD_PS_TRIANGLE: u32 = 295; +pub const I_PAD_NIN_A: u32 = 296; // A, B, X, Y on light discs +pub const I_PAD_NIN_B: u32 = 297; +pub const I_PAD_NIN_X: u32 = 298; +pub const I_PAD_NIN_Y: u32 = 299; +pub const I_PAD_DPAD: u32 = 300; +pub const I_PAD_STICK_L: u32 = 301; +pub const I_PAD_STICK_R: u32 = 302; +pub const I_PAD_LB: u32 = 303; +pub const I_PAD_RB: u32 = 304; +pub const I_PAD_LT: u32 = 305; +pub const I_PAD_RT: u32 = 306; +pub const I_PAD_START: u32 = 307; +pub const I_PAD_SELECT: u32 = 308; + pub struct Gfx { pub atlas: Texture2D, pub sprites: Texture2D, @@ -335,6 +359,42 @@ fn earth_px(x: i32, y: i32, v: u32) -> Color { shade(base, (big + fine) as i32) } +// ---- gamepad pictograms (16x16 cells; see I_PAD_*) + +// tiny 3x5 letters for the button pictograms +fn pad_glyph(ch: char, x: i32, y: i32) -> bool { + if !(0..3).contains(&x) || !(0..5).contains(&y) { + return false; + } + let rows: [u8; 5] = match ch { + 'A' => [0b010, 0b101, 0b111, 0b101, 0b101], + 'B' => [0b110, 0b101, 0b110, 0b101, 0b110], + 'X' => [0b101, 0b101, 0b010, 0b101, 0b101], + 'Y' => [0b101, 0b101, 0b010, 0b010, 0b010], + 'L' => [0b100, 0b100, 0b100, 0b100, 0b111], + 'R' => [0b110, 0b101, 0b110, 0b101, 0b101], + 'T' => [0b111, 0b010, 0b010, 0b010, 0b010], + _ => [0; 5], + }; + rows[y as usize] >> (2 - x) & 1 == 1 +} + +// a round button: rim, fill, a highlight top left +fn pad_disc(x: i32, y: i32, fill: (i32, i32, i32)) -> Option { + let (dx, dy) = (x - 8, y - 8); + let d2 = dx * dx + dy * dy; + if d2 > 30 { + return None; + } + Some(if d2 >= 22 { + shade((fill.0 / 2, fill.1 / 2, fill.2 / 2), (rnd(x, y, 5) * 4.0) as i32) + } else if dx + dy <= -7 { + shade(fill, 26) + } else { + c(fill.0, fill.1, fill.2) + }) +} + thread_local! { // the interface draws atlas cells from the high-res atlas (the high-res graphics style) static HIGH_RES_UI: std::cell::Cell = const { std::cell::Cell::new(false) }; @@ -1787,6 +1847,129 @@ fn gen_atlas() -> Image { }); cv.cell(D_RUBY, |x, y| gems(x, y, 200, c(255, 150, 170), c(215, 30, 60), c(120, 12, 36))); cv.cell(D_DIAMOND, |x, y| gems(x, y, 210, c(255, 255, 255), c(170, 232, 255), c(80, 150, 200))); + // ---- gamepad pictograms + // a face button with a letter (Xbox, Nintendo) + let face = |ch: char, fill: (i32, i32, i32), dark: bool| { + move |x: i32, y: i32| { + let disc = pad_disc(x, y, fill)?; + if pad_glyph(ch, x - 7, y - 6) { + return Some(if dark { c(42, 44, 50) } else { c(250, 250, 250) }); + } + Some(disc) + } + }; + cv.cell(I_PAD_XBOX_A, face('A', (74, 166, 86), false)); + cv.cell(I_PAD_XBOX_B, face('B', (196, 66, 58), false)); + cv.cell(I_PAD_XBOX_X, face('X', (64, 108, 204), false)); + cv.cell(I_PAD_XBOX_Y, face('Y', (214, 176, 64), false)); + cv.cell(I_PAD_NIN_A, face('A', (200, 202, 210), true)); + cv.cell(I_PAD_NIN_B, face('B', (200, 202, 210), true)); + cv.cell(I_PAD_NIN_X, face('X', (200, 202, 210), true)); + cv.cell(I_PAD_NIN_Y, face('Y', (200, 202, 210), true)); + // PlayStation: the shape symbols on dark discs + let symbol = |sym: u8, col: Color| { + move |x: i32, y: i32| { + let disc = pad_disc(x, y, (58, 60, 68))?; + let (dx, dy) = (x - 8, y - 8); + let on = match sym { + 0 => dx.abs() <= 3 && dy.abs() <= 3 && (dx == dy || dx == -dy), + 1 => { + let d = ((dx * dx + dy * dy) as f32).sqrt(); + d >= 2.6 && d <= 4.1 + } + 2 => dx.abs() <= 3 && dy.abs() <= 3 && (dx.abs() == 3 || dy.abs() == 3), + _ => dy >= -3 && dy <= 3 && (dx.abs() == (dy + 3) / 2 || (dy == 3 && dx.abs() <= 3)), + }; + Some(if on { col } else { disc }) + } + }; + cv.cell(I_PAD_PS_CROSS, symbol(0, c(96, 154, 255))); + cv.cell(I_PAD_PS_CIRCLE, symbol(1, c(240, 96, 96))); + cv.cell(I_PAD_PS_SQUARE, symbol(2, c(238, 130, 234))); + cv.cell(I_PAD_PS_TRIANGLE, symbol(3, c(100, 214, 152))); + // d-pad: a cross with a dark centre + cv.cell(I_PAD_DPAD, |x, y| { + let arm = (5..=10).contains(&x) || (5..=10).contains(&y); + if !arm || !(1..=14).contains(&x) || !(1..=14).contains(&y) { + return None; + } + let mid = (x - 8).abs() <= 1 && (y - 8).abs() <= 1; + let edge = x == 1 || y == 1 || x == 14 || y == 14; + Some(if mid { c(70, 72, 80) } else if edge { c(116, 118, 128) } else { c(172, 174, 186) }) + }); + // sticks: a ring with a knob, L/R in the corner + let stick = |side: char| { + move |x: i32, y: i32| { + let (dx, dy) = (x - 8, y - 8); + let d = ((dx * dx + dy * dy) as f32).sqrt(); + if d <= 6.0 { + return Some(if d >= 4.6 { + c(148, 150, 162) + } else if d <= 2.8 { + c(208, 210, 220) + } else { + c(88, 90, 102) + }); + } + if pad_glyph(side, x - 11, y - 10) { + return Some(c(190, 192, 202)); + } + None + } + }; + cv.cell(I_PAD_STICK_L, stick('L')); + cv.cell(I_PAD_STICK_R, stick('R')); + // shoulders (LB/RB) and triggers (LT/RT): a rounded body with its letters + let bumper = |a: char, b: char, tall: bool| { + move |x: i32, y: i32| { + let (y0, y1) = if tall { (2, 10) } else { (4, 11) }; + let corner = (x == 3 || x == 12) && (y == y0 || y == y1); + if !(3..=12).contains(&x) || !(y0..=y1).contains(&y) || corner { + return None; + } + if pad_glyph(a, x - 4, y - (y0 + 1)) || pad_glyph(b, x - 8, y - (y0 + 1)) { + return Some(c(52, 54, 62)); + } + Some(if x == 3 || x == 12 || y == y0 { + c(128, 130, 140) + } else if y == y1 { + c(94, 96, 106) + } else { + c(190, 192, 202) + }) + } + }; + cv.cell(I_PAD_LB, bumper('L', 'B', false)); + cv.cell(I_PAD_RB, bumper('R', 'B', false)); + cv.cell(I_PAD_LT, bumper('L', 'T', true)); + cv.cell(I_PAD_RT, bumper('R', 'T', true)); + // start: two bars (pause); select: two lines (list) + cv.cell(I_PAD_START, |x, y| { + if !(3..=12).contains(&x) || !(4..=11).contains(&y) { + return None; + } + let bars = (x == 5 || x == 6 || x == 9 || x == 10) && (6..=9).contains(&y); + Some(if bars { + c(232, 234, 242) + } else if x == 3 || y == 4 || x == 12 || y == 11 { + c(68, 70, 80) + } else { + c(118, 120, 132) + }) + }); + cv.cell(I_PAD_SELECT, |x, y| { + if !(2..=13).contains(&x) || !(4..=11).contains(&y) { + return None; + } + let lines = (5..=10).contains(&x) && (y == 6 || y == 9); + Some(if lines { + c(232, 234, 242) + } else if x == 2 || y == 4 || x == 13 || y == 11 { + c(68, 70, 80) + } else { + c(118, 120, 132) + }) + }); cv.img } @@ -3563,6 +3746,34 @@ mod atlas_dump { } out.export_png("/tmp/atlases.png"); } + + // `cargo test --bins dump_pad_icons -- --ignored`: the gamepad pictograms, 6x scaled, in + // /tmp/pad-icons.png (Xbox, PlayStation, Nintendo, then d-pad, sticks and co.) + #[test] + #[ignore] + fn dump_pad_icons() { + let atlas = gen_atlas(); + let ids: [u32; 21] = [ + I_PAD_XBOX_A, I_PAD_XBOX_B, I_PAD_XBOX_X, I_PAD_XBOX_Y, + I_PAD_PS_CROSS, I_PAD_PS_CIRCLE, I_PAD_PS_SQUARE, I_PAD_PS_TRIANGLE, + I_PAD_NIN_A, I_PAD_NIN_B, I_PAD_NIN_X, I_PAD_NIN_Y, + I_PAD_DPAD, I_PAD_STICK_L, I_PAD_STICK_R, I_PAD_LB, I_PAD_RB, I_PAD_LT, I_PAD_RT, + I_PAD_START, I_PAD_SELECT, + ]; + let k = 6; + let cols = 7; + let rows = (ids.len() as u32).div_ceil(cols); + let mut out = Image::gen_image_color((cols * 16 * k) as u16, (rows * 16 * k) as u16, BLACK); + for (i, &id) in ids.iter().enumerate() { + let (ox, oy) = ((id % ATLAS_COLS) * 16, (id / ATLAS_COLS) * 16); + for y in 0..16 * k { + for x in 0..16 * k { + out.set_pixel((i as u32 % cols) * 16 * k + x, (i as u32 / cols) * 16 * k + y, atlas.get_pixel(ox + x / k, oy + y / k)); + } + } + } + out.export_png("/tmp/pad-icons.png"); + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 813d746..5af4fe6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod personality; mod platform; mod portraits; mod orders; +mod pad; mod particles; mod props; mod research; @@ -933,6 +934,7 @@ async fn main() { apply_game_settings(&mut app.game, &app.settings); let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, mclick: -1.0, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); + let mut pad = pad::Pad::new(); let mut auto_touch = platform::coarse_pointer(); let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance let mut menu_dragged = false; // the finger on a menu has swiped (no click when it lifts) @@ -1024,8 +1026,19 @@ async fn main() { let p = touch.finger().or(gpos).unwrap_or(vec2(-1e5, -1e5)); ui::set_pointer(p.x, p.y); } + // ---------------- gamepad: the browser's Gamepad API (the desktop build has none) + pad.poll(app.settings.gamepad); + ui::set_gamepad(pad.connected()); + // the pad's pointer starts where the mouse is and moves with the right stick; a mouse + // movement takes it over again, so both can be used side by side (a finger still wins) + if pad.connected() && !(touch_mode && touch.finger().is_some()) { + let m = mouse_position(); + let moved = mouse_delta_position() != Vec2::ZERO; + let (px, py) = pad.aim(dt, ui::sw(), ui::sh(), (m.0 * ui::dpr(), m.1 * ui::dpr()), moved); + ui::set_pointer(px, py); + } let (ux, uy) = ui::mouse(); // device pixels (interface) - let (mx, my) = if touch_mode { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking) + let (mx, my) = if touch_mode || ui::gamepad_ui() { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking) // ---------------- savegame files: file picker result or dropped onto the window let mut incoming: Option> = platform::take_file(); @@ -1242,8 +1255,8 @@ async fn main() { let keys = app.settings.clone(); let key = |a: Action| keys.key(a); - // ---------------- Esc: chat, dialogs, menus, pause menu - if is_key_pressed(KeyCode::Escape) { + // ---------------- Esc (or the pad's B / Start): chat, dialogs, menus, pause menu + if is_key_pressed(KeyCode::Escape) || pad.pressed(pad::Btn::East) || pad.pressed(pad::Btn::Start) { if ui.chat_open { ui.chat_open = false; ui.chat_text.clear(); @@ -1293,9 +1306,28 @@ async fn main() { inp.ldown = None; } } - if is_key_pressed(key(Action::Help)) { + if is_key_pressed(key(Action::Help)) || pad.pressed(pad::Btn::Select) { ui.show_help = true; } + // the pad cycles the tools: LB back, RB forward + let tool_step = if pad.pressed(pad::Btn::Rb) { + 1 + } else if pad.pressed(pad::Btn::Lb) { + -1 + } else { + 0 + }; + if tool_step != 0 { + let cur = TOOLS.iter().position(|&t| t == ui.tool).unwrap_or(0) as i32; + for k in 1..=TOOLS.len() as i32 { + let i = (cur + tool_step * k).rem_euclid(TOOLS.len() as i32) as usize; + if app.game.tool_allowed(i) && app.game.tool_visible(i) { + ui.tool = TOOLS[i]; + inp.ldown = None; + break; + } + } + } if is_key_pressed(key(Action::Pause)) && app.game.state == 0 { app.game.paused = !app.game.paused; } @@ -1346,11 +1378,19 @@ async fn main() { if is_key_down(key(Action::CamRight)) || is_key_down(KeyCode::Right) { move_cam(pan, 0.0, cam); } + // the pad's left stick moves the map, as W/A/S/D do + if pad.connected() { + let s = pad.stick(pad::Stick::Left); + if s != Vec2::ZERO { + let k = pan * 1.8; + move_cam(s.x * k, -s.y * k, cam); + } + } let edge = 6.0; if !mouse_moved && mouse_delta_position() != Vec2::ZERO { mouse_moved = true; } - if keys.edge_scroll && mouse_moved && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { + if keys.edge_scroll && mouse_moved && !touch_mode && !ui::gamepad_ui() && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { if mx < edge { move_cam(-pan, 0.0, cam); } else if mx > screen_width() - edge { @@ -1363,18 +1403,22 @@ async fn main() { } } let rot = 1.8 * dt * keys.rotate_speed; - if is_key_down(key(Action::RotateLeft)) { + if is_key_down(key(Action::RotateLeft)) || pad.down(pad::Btn::Left) { cam.yaw += rot; } - if is_key_down(key(Action::RotateRight)) { + if is_key_down(key(Action::RotateRight)) || pad.down(pad::Btn::Right) { cam.yaw -= rot; } - if is_key_down(key(Action::TiltUp)) { + if is_key_down(key(Action::TiltUp)) || pad.down(pad::Btn::Up) { cam.pitch += rot * 0.6; } - if is_key_down(key(Action::TiltDown)) { + if is_key_down(key(Action::TiltDown)) || pad.down(pad::Btn::Down) { cam.pitch -= rot * 0.6; } + // the pad's left stick click resets the view + if pad.pressed(pad::Btn::LStick) { + cam.reset_view(); + } // a double click with the middle button: the standard angle and turn (same place) if is_mouse_button_pressed(MouseButton::Middle) && !touch_mode { let now = get_time(); @@ -1405,6 +1449,15 @@ async fn main() { cam.tx += before.x - after.x; cam.tz += before.y - after.y; } + // the pad's triggers zoom (RT in, LT out) + let zoom_in = if pad.down(pad::Btn::Rt) { 1.0 } else { 0.0 } - if pad.down(pad::Btn::Lt) { 1.0 } else { 0.0 }; + if zoom_in != 0.0 && !over_ui { + let before = cam.ground_point(mx, my); + cam.zoom = (cam.zoom * (1.0 - zoom_in * 1.4 * dt * keys.zoom_speed)).clamp(6.0, 45.0); + let after = cam.ground_point(mx, my); + cam.tx += before.x - after.x; + cam.tz += before.y - after.y; + } // touch: two fingers move, zoom and rotate the camera for g in &gestures { if let Gesture::Camera { center, pan, zoom, rotate } = *g { @@ -1421,8 +1474,8 @@ async fn main() { cam.tz += before.y - after.y; } } - // right button: drag = pan, click = drop - if is_mouse_button_pressed(MouseButton::Right) { + // right button (or the pad's X / square): drag = pan, click = drop + if is_mouse_button_pressed(MouseButton::Right) || pad.pressed(pad::Btn::West) { inp.rdown = Some((mx, my)); inp.rlast = (mx, my); inp.rdrag = false; @@ -1441,7 +1494,7 @@ async fn main() { inp.rlast = (mx, my); } } - if is_mouse_button_released(MouseButton::Right) { + if is_mouse_button_released(MouseButton::Right) || pad.released(pad::Btn::West) { if !inp.rdrag && !over_ui && app.game.state == 0 { if inp.ldown.is_some() { inp.ldown = None; @@ -1459,13 +1512,13 @@ async fn main() { // (in menus and dialogs a touch clicks when the finger lifts without a swipe, so that // swiping a list scrolls it instead of picking the entry under the finger) let menu_touch = touch_mode && (in_menu || dialog); - let pressed = if menu_touch { + let pressed = (if menu_touch { !menu_dragged && gestures.iter().any(|g| matches!(g, Gesture::Release(_))) } else if touch_mode { gestures.iter().any(|g| matches!(g, Gesture::Press(_))) } else { is_mouse_button_pressed(MouseButton::Left) - }; + }) || pad.pressed(pad::Btn::South); if touch_mode && pressed { inp.touch_world = playing && !over_ui; inp.inspect = None; @@ -1916,7 +1969,7 @@ app.pending_briefing = briefing_of(&campaign, i); } } } - let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) }; + let held = (if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) }) || pad.down(pad::Btn::South); if inp.minimap_drag { if held && ui.minimap_contains(ux, uy) { let (x, y) = ui.minimap_world(ux, uy); @@ -1976,7 +2029,8 @@ app.pending_briefing = briefing_of(&campaign, i); inp.inspect = None; } } - if !touch_mode && is_mouse_button_released(MouseButton::Left) { + let released = (if touch_mode { false } else { is_mouse_button_released(MouseButton::Left) }) || pad.released(pad::Btn::South); + if released { if let Some(start) = inp.ldown.take() { if playing { finish_rect(&mut app, ui.tool, start, mx, my); diff --git a/src/menu.rs b/src/menu.rs index 4e5604e..2c423d6 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -67,6 +67,7 @@ pub enum Item { Hints, Events, TouchUi, + Gamepad, Voice, Track, } @@ -77,7 +78,7 @@ fn tab_items(tab: usize) -> &'static [Item] { 0 => &[Language, Volume, MusicVolume, Track, Mute, Voice], 1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], - 3 => &[GameSpeed, Hints, Events, TouchUi], + 3 => &[GameSpeed, Hints, Events, TouchUi, Gamepad], 4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines, DebugHoliday], _ => &[], } @@ -110,6 +111,7 @@ fn item_label(it: Item) -> String { Item::Hints => "set.hints", Item::Events => "set.events", Item::TouchUi => "set.touch_ui", + Item::Gamepad => "set.gamepad", Item::Voice => "set.voice", Item::Track => "set.track", }) @@ -169,6 +171,12 @@ fn item_value(it: Item, s: &Settings) -> String { 2 => tr("val.off"), _ => trf("val.auto_state", &[tr(if crate::ui::touch_ui() { "val.on" } else { "val.off" })]), }, + Item::Gamepad => tr(match s.gamepad { + 1 => "val.xbox", + 2 => "val.playstation", + 3 => "val.nintendo", + _ => "val.auto_plain", + }), } } @@ -221,6 +229,7 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::Hints => s.hints = !s.hints, Item::Events => s.events = !s.events, Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d), + Item::Gamepad => s.gamepad = cycle(&[0u8, 1, 2, 3], s.gamepad, d), } } @@ -1514,6 +1523,17 @@ impl Menu { // value with < > buttons let vw = 18.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; + if it == Item::Gamepad { + // the type's face buttons, right before the value: the pictograms the game uses + let kind = crate::pad::kind(); + let isz = 2.0 * CH(); + let gap = CW(); + let mut ix = vx - CW() - 4.0 * isz - 3.0 * gap; + for b in [crate::pad::Btn::North, crate::pad::Btn::West, crate::pad::Btn::East, crate::pad::Btn::South] { + gfx.draw_cell(crate::ui::pad_icon(kind, b), ix, by, isz, isz, WHITE); + ix += isz + gap; + } + } if it == Item::Language { self.lang_field(gfx, vx, by, vw); continue; diff --git a/src/pad.rs b/src/pad.rs new file mode 100644 index 0000000..49e04f6 --- /dev/null +++ b/src/pad.rs @@ -0,0 +1,229 @@ +// Gamepads via the browser's Gamepad API (see www/dc_plugin.js): polling, buttons, sticks and +// the virtual pointer. Native builds have no gamepad input (miniquad does not poll pads), so +// platform's stubs report "no gamepad" there. +// +// The standard Gamepad API mapping: buttons 0-3 = face buttons, 4/5 = shoulders, 6/7 = triggers, +// 8/9 = select/start, 10/11 = stick clicks, 12-15 = d-pad; axes 0/1 = left stick, 2/3 = right. + +use macroquad::prelude::Vec2; +use std::cell::Cell; + +// the standard Gamepad API button order; every variant keeps its index (RStick is 11), even the +// ones no action uses yet +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[allow(dead_code)] +pub enum Btn { + South, // A (Xbox, Nintendo), cross (PlayStation) + East, // B (Xbox), circle (PlayStation), A (Nintendo) + West, // X (Xbox), square (PlayStation), Y (Nintendo) + North, // Y (Xbox), triangle (PlayStation), X (Nintendo) + Lb, + Rb, + Lt, + Rt, + Select, + Start, + LStick, + RStick, // kept for the standard button index (11); no action uses it yet + Up, + Down, + Left, + Right, +} + +impl Btn { + fn bit(self) -> u32 { + 1 << self as u32 + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Stick { + Left, + Right, +} + +// The button pictograms the interface shows (Xbox, PlayStation, Nintendo). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Kind { + Xbox, + PlayStation, + Nintendo, +} + +// Settings value: 0 automatic, 1 Xbox, 2 PlayStation, 3 Nintendo. +pub fn kind_for(setting: u8, detected: i32) -> Kind { + match setting { + 1 => Kind::Xbox, + 2 => Kind::PlayStation, + 3 => Kind::Nintendo, + _ => match detected { + 2 => Kind::PlayStation, + 3 => Kind::Nintendo, + _ => Kind::Xbox, + }, + } +} + +thread_local! { + static KIND: Cell = const { Cell::new(Kind::Xbox) }; +} + +// The kind whose pictograms the interface draws: the setting, or the automatic choice. +pub fn kind() -> Kind { + KIND.with(|c| c.get()) +} + +// Below this deflection a stick rests (controllers do not return to exactly 0). +const DEAD: f32 = 0.18; + +pub struct Pad { + connected: bool, + buttons: u32, + prev: u32, + axes: [f32; 4], + pointer: (f32, f32), // device pixels of the interface + pointer_ready: bool, +} + +impl Pad { + pub fn new() -> Pad { + Pad { connected: false, buttons: 0, prev: 0, axes: [0.0; 4], pointer: (0.0, 0.0), pointer_ready: false } + } + + // Read the pad for this frame; kind_setting = Settings::gamepad. + pub fn poll(&mut self, kind_setting: u8) { + self.prev = if self.connected { self.buttons } else { 0 }; + let slot = crate::platform::gamepad_slot(); + self.connected = slot >= 0; + let detected = if self.connected { crate::platform::gamepad_kind(slot) } else { 0 }; + KIND.with(|c| c.set(kind_for(kind_setting, detected))); + if !self.connected { + self.buttons = 0; + self.axes = [0.0; 4]; + return; + } + self.buttons = crate::platform::gamepad_buttons(slot); + for (i, a) in self.axes.iter_mut().enumerate() { + *a = crate::platform::gamepad_axis(slot, i as i32); + } + } + + pub fn connected(&self) -> bool { + self.connected + } + + pub fn down(&self, b: Btn) -> bool { + self.connected && self.buttons & b.bit() != 0 + } + + pub fn pressed(&self, b: Btn) -> bool { + self.connected && self.buttons & b.bit() != 0 && self.prev & b.bit() == 0 + } + + pub fn released(&self, b: Btn) -> bool { + self.connected && self.buttons & b.bit() == 0 && self.prev & b.bit() != 0 + } + + // stick deflection, dead zone removed and scaled back to 0..1 + pub fn stick(&self, side: Stick) -> Vec2 { + let (i, j) = match side { + Stick::Left => (0, 1), + Stick::Right => (2, 3), + }; + let v = Vec2::new(self.axes[i], self.axes[j]); + let l = v.length(); + if l <= DEAD { + Vec2::ZERO + } else { + v / l * ((l - DEAD) / (1.0 - DEAD)) + } + } + + // Move the interface pointer with the right stick. Mouse movements take it over at once, so + // mouse and pad can be used side by side; (w, h) is the interface size in device pixels. + // Returns the pointer in device pixels. + pub fn aim(&mut self, dt: f32, w: f32, h: f32, mouse: (f32, f32), mouse_moved: bool) -> (f32, f32) { + let w = w.max(1.0); + let h = h.max(1.0); + if !self.pointer_ready || mouse_moved { + self.pointer = (mouse.0.clamp(0.0, w), mouse.1.clamp(0.0, h)); + self.pointer_ready = true; + } + let s = self.stick(Stick::Right); + let speed = w.max(640.0) * 1.15; // a full deflection crosses the screen in about a second + self.pointer.0 = (self.pointer.0 + s.x * speed * dt).clamp(0.0, w); + self.pointer.1 = (self.pointer.1 + s.y * speed * dt).clamp(0.0, h); + self.pointer + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_setting_picks_the_pictograms() { + assert_eq!(kind_for(0, 1), Kind::Xbox); + assert_eq!(kind_for(0, 2), Kind::PlayStation); + assert_eq!(kind_for(0, 3), Kind::Nintendo); + assert_eq!(kind_for(0, 0), Kind::Xbox); + assert_eq!(kind_for(2, 1), Kind::PlayStation); + assert_eq!(kind_for(3, 1), Kind::Nintendo); + assert_eq!(kind_for(1, 2), Kind::Xbox); + } + + #[test] + fn sticks_have_a_dead_zone() { + let mut p = Pad::new(); + p.connected = true; + p.axes = [0.1, -0.12, 0.0, 0.0]; + assert_eq!(p.stick(Stick::Left), Vec2::ZERO); + p.axes = [1.0, 0.0, 0.0, 0.0]; + let s = p.stick(Stick::Left); + assert!((s.x - 1.0).abs() < 0.001 && s.y.abs() < 0.001); + p.axes = [0.0, -0.59, 0.0, 0.0]; + let s = p.stick(Stick::Left); + assert!((s.y + 0.5).abs() < 0.001); + } + + #[test] + fn buttons_report_edges() { + let mut p = Pad::new(); + p.connected = true; + p.buttons = 0; + p.prev = 0; + assert!(!p.down(Btn::South)); + p.buttons = Btn::South.bit(); + assert!(p.pressed(Btn::South) && p.down(Btn::South) && !p.released(Btn::South)); + p.prev = p.buttons; + assert!(!p.pressed(Btn::South) && p.down(Btn::South)); + p.buttons = 0; + p.prev = Btn::South.bit(); + assert!(!p.down(Btn::South) && p.released(Btn::South)); + } + + #[test] + fn a_disconnected_pad_stays_silent() { + let mut p = Pad::new(); + p.buttons = Btn::South.bit(); + p.prev = 0; + assert!(!p.down(Btn::South)); + assert!(!p.pressed(Btn::South)); + } + + #[test] + fn the_pointer_follows_the_mouse_and_the_stick() { + let mut p = Pad::new(); + p.connected = true; + // the first frame takes the mouse position + assert_eq!(p.aim(0.016, 1000.0, 600.0, (100.0, 200.0), false), (100.0, 200.0)); + // the right stick moves it + p.axes = [0.0, 0.0, 1.0, 0.0]; + let (x, _) = p.aim(0.1, 1000.0, 600.0, (0.0, 0.0), false); + assert!(x > 200.0); + // a mouse movement takes it over again (a resting stick moves nothing) + p.axes = [0.0, 0.0, 0.0, 0.0]; + assert_eq!(p.aim(0.1, 1000.0, 600.0, (5.0, 5.0), true), (5.0, 5.0)); + } +} diff --git a/src/platform.rs b/src/platform.rs index decf2da..c209758 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -36,6 +36,10 @@ mod imp { fn dc_ws_take(id: i32) -> i32; fn dc_ws_close(id: i32); fn dc_renderer() -> i32; + fn dc_gamepad_slot() -> i32; + fn dc_gamepad_buttons(slot: i32) -> u32; + fn dc_gamepad_axis(slot: i32, axis: i32) -> f32; + fn dc_gamepad_kind(slot: i32) -> i32; } // starts a request; poll the returned id with http_poll() @@ -189,6 +193,25 @@ mod imp { String::from_utf8(buf).unwrap_or_default() } } + + // the first connected gamepad (-1: none), see www/dc_plugin.js + pub fn gamepad_slot() -> i32 { + unsafe { dc_gamepad_slot() } + } + + // bit i: standard button i is down + pub fn gamepad_buttons(slot: i32) -> u32 { + unsafe { dc_gamepad_buttons(slot) } + } + + pub fn gamepad_axis(slot: i32, axis: i32) -> f32 { + unsafe { dc_gamepad_axis(slot, axis) } + } + + // 0 unknown, 1 Xbox, 2 PlayStation, 3 Nintendo + pub fn gamepad_kind(slot: i32) -> i32 { + unsafe { dc_gamepad_kind(slot) } + } } #[cfg(not(target_arch = "wasm32"))] @@ -364,6 +387,21 @@ mod imp { return "OpenGL".into(); } } + + // The Gamepad API only exists in browsers. The desktop build has no gamepad input yet + // (miniquad does not poll gamepads; that would need a crate like gilrs). + pub fn gamepad_slot() -> i32 { + -1 + } + pub fn gamepad_buttons(_slot: i32) -> u32 { + 0 + } + pub fn gamepad_axis(_slot: i32, _axis: i32) -> f32 { + 0.0 + } + pub fn gamepad_kind(_slot: i32) -> i32 { + 0 + } } pub use imp::*; diff --git a/src/settings.rs b/src/settings.rs index f51a96d..4b13957 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -153,6 +153,7 @@ pub struct Settings { pub render_scale: u8, // old pixel-look divisor (no longer used, kept for old settings files) pub pixel_rows: u16, // graphics style: rows of the world on every screen (270 pixel art, 1080 high-res) pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off + pub gamepad: u8, // gamepad pictograms: 0 automatic (from the pad's id), 1 Xbox, 2 PlayStation, 3 Nintendo pub debug_fps: bool, // show FPS and rendering backend pub debug_frame_time: bool, // show the frame-time line and graph pub debug_ping: bool, // show network ping @@ -195,6 +196,7 @@ impl Default for Settings { render_scale: 1, pixel_rows: PIXEL_ART_ROWS, touch_ui: 0, + gamepad: 0, debug_fps: false, debug_frame_time: false, debug_ping: false, diff --git a/src/ui.rs b/src/ui.rs index 164bc89..c3d6989 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,6 +19,7 @@ use macroquad::prelude::*; // setting; automatic from the resolution in device pixels). thread_local! { static TOUCH: std::cell::Cell = std::cell::Cell::new(false); + static GAMEPAD: std::cell::Cell = std::cell::Cell::new(false); static POINTER: std::cell::Cell<(f32, f32)> = std::cell::Cell::new((-1e5, -1e5)); static VIRTUAL_ROWS: std::cell::Cell = std::cell::Cell::new(0); static UI_TARGET: std::cell::RefCell> = std::cell::RefCell::new(None); @@ -50,6 +51,13 @@ pub fn set_touch(on: bool) { pub fn touch_ui() -> bool { TOUCH.with(|c| c.get()) } +// a gamepad is connected: the interface shows its button pictograms (see pad.rs) +pub fn set_gamepad(on: bool) { + GAMEPAD.with(|c| c.set(on)); +} +pub fn gamepad_ui() -> bool { + GAMEPAD.with(|c| c.get()) +} // in touch mode the pointer is the finger (device pixels), set by the input code each frame pub fn set_pointer(x: f32, y: f32) { POINTER.with(|c| c.set((x, y))); @@ -70,7 +78,7 @@ pub fn sh() -> f32 { (screen_height() * dpr()).round() } pub fn mouse() -> (f32, f32) { - if touch_ui() { + if touch_ui() || gamepad_ui() { return POINTER.with(|c| c.get()); } let (x, y) = mouse_position(); @@ -443,6 +451,35 @@ pub const CATS: [(&str, &[usize]); 7] = [ ("cat.orders", &[8, 45, 46, 7]), ]; +// The atlas cell of a gamepad button's pictogram, in the pad type's own symbols: Xbox A/B/X/Y, +// PlayStation cross/circle/square/triangle, Nintendo A/B/X/Y. +pub fn pad_icon(kind: crate::pad::Kind, b: crate::pad::Btn) -> u32 { + use crate::pad::{Btn, Kind}; + match (kind, b) { + (Kind::Xbox, Btn::South) => I_PAD_XBOX_A, + (Kind::Xbox, Btn::East) => I_PAD_XBOX_B, + (Kind::Xbox, Btn::West) => I_PAD_XBOX_X, + (Kind::Xbox, Btn::North) => I_PAD_XBOX_Y, + (Kind::PlayStation, Btn::South) => I_PAD_PS_CROSS, + (Kind::PlayStation, Btn::East) => I_PAD_PS_CIRCLE, + (Kind::PlayStation, Btn::West) => I_PAD_PS_SQUARE, + (Kind::PlayStation, Btn::North) => I_PAD_PS_TRIANGLE, + (Kind::Nintendo, Btn::South) => I_PAD_NIN_A, + (Kind::Nintendo, Btn::East) => I_PAD_NIN_B, + (Kind::Nintendo, Btn::West) => I_PAD_NIN_X, + (Kind::Nintendo, Btn::North) => I_PAD_NIN_Y, + (_, Btn::Lb) => I_PAD_LB, + (_, Btn::Rb) => I_PAD_RB, + (_, Btn::Lt) => I_PAD_LT, + (_, Btn::Rt) => I_PAD_RT, + (_, Btn::Start) => I_PAD_START, + (_, Btn::Select) => I_PAD_SELECT, + (_, Btn::LStick) => I_PAD_STICK_L, + (_, Btn::RStick) => I_PAD_STICK_R, + _ => I_PAD_DPAD, + } +} + #[derive(Clone, Copy, PartialEq, Debug)] enum TBtn { Cat(usize), @@ -897,7 +934,8 @@ impl Ui { } // ---------------- toolbar: buttons of 6 x 3 characters, icons and labels on the grid - let tb_rows = 5; + // (with a gamepad one row more: the pad's buttons as pictograms underneath) + let tb_rows = if gamepad_ui() { 6 } else { 5 }; let tb_y = sh - tb_rows as f32 * CH(); let tb_y = (tb_y / CH()).floor() * CH(); let tb_h = sh - tb_y; @@ -954,7 +992,7 @@ impl Ui { let label = if tools.len() == 1 { ti.label.clone() } else { tr(key) }; let hovered = !touch_ui() && Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()).contains(vec2(mx, my)); // single tools show their key (desktop) and their description when hovered - let key = if tools.len() == 1 && !touch_ui() { keys[tools[0]].as_str() } else { "" }; + let key = if tools.len() == 1 && !touch_ui() && !gamepad_ui() { keys[tools[0]].as_str() } else { "" }; if hovered && tools.len() == 1 { hover_tool = Some(tools[0]); } @@ -1010,6 +1048,44 @@ impl Ui { let ml: String = ml.chars().take(maxc).collect(); gfx.text(&ml, bx + (bw - gfx.measure(&ml, SMALL())) * 0.5, by + 2.0 * CH() + 4.0 * px, SMALL(), TXT); self.touch_btns.push((r, TBtn::Menu)); + // gamepad: its buttons as pictograms, on the extra row under the buttons + if gamepad_ui() { + let kind = crate::pad::kind(); + let icon = 2.0 * CH(); // a 16 px atlas cell at 2*F + let gap = CW(); + let south = [pad_icon(kind, crate::pad::Btn::South)]; + let east = [pad_icon(kind, crate::pad::Btn::East)]; + let shoulders = [I_PAD_LB, I_PAD_RB]; + let start = [I_PAD_START]; + let sticks = [I_PAD_STICK_L, I_PAD_STICK_R]; + let triggers = [I_PAD_LT, I_PAD_RT]; + let entries: [(&[u32], &str); 6] = [ + (&south, "pad.use"), + (&east, "pad.back"), + (&shoulders, "pad.tool"), + (&start, "pad.menu"), + (&sticks, "pad.camera"), + (&triggers, "pad.zoom"), + ]; + let width = |icons: &[u32], label: &str| icon * icons.len() as f32 + gap * 0.5 * (icons.len() - 1) as f32 + gap + gfx.measure(label, SMALL()); + let total = |n: usize| entries[..n].iter().map(|(i, k)| width(i, &tr(k))).sum::() + gap * 1.5 * (n - 1) as f32; + let avail = sw - 2.0 * CW(); + let mut n = entries.len(); + while n > 2 && total(n) > avail { + n -= 1; + } + let mut lx = grid_center(sw, total(n)); + let ly = tb_y + 4.0 * CH(); + for (icons, key) in entries.iter().take(n) { + for &id in icons.iter() { + gfx.draw_cell(id, lx, ly, icon, icon, WHITE); + lx += icon + gap * 0.5; + } + let label = tr(key); + gfx.text(&label, lx, ly + (icon - GH * SMALL()) * 0.5, SMALL(), TXT); + lx += gfx.measure(&label, SMALL()) + gap * 1.5; + } + } // open category row: (category, x0, button width, columns) let popup = self.open_cat.map(|c| (c, x0, bw, bcols)); // touch: after choosing a tool its description is shown for a moment @@ -1524,7 +1600,9 @@ impl Ui { } fn draw_help(&mut self, gfx: &Gfx) { - let controls: &[&str] = if touch_ui() { + let controls: &[&str] = if gamepad_ui() { + &["help.p_move", "help.p_aim", "help.p_rot", "help.p_zoom", "help.p_use", "help.p_back", "help.p_tool"] + } else if touch_ui() { &["help.t_tap", "help.t_drag", "help.t_two", "help.t_long", "help.t_double", "help.t_tools"] } else { &["help.k_left", "help.k_right", "help.k_slap", "help.k_pan", "help.k_rot", "help.k_reset", "help.k_zoom", "help.k_tools", "help.k_misc", "help.k_misc2"] diff --git a/www/dc_plugin.js b/www/dc_plugin.js index 1fc0c1a..f66a688 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -11,6 +11,16 @@ var fetched = { state: 0 }; // download of a file from the server: 0 idle, 1 running, 2 done, 3 failed var ws = {}; // socket id -> { state, queue, sock } var wsNext = 1; + var gpPad = null; // the gamepad handed out by dc_gamepad_slot (reused for its buttons/axes) + var gpSlot = -1; + // browsers offer the Gamepad API only in a secure context (https or localhost) + function gamepads() { + try { + return navigator.getGamepads ? navigator.getGamepads() : []; + } catch (e) { + return []; + } + } function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); @@ -193,6 +203,66 @@ env.dc_coarse_pointer = function () { try { return window.matchMedia("(pointer: coarse)").matches ? 1 : 0; } catch (e) { return 0; } }; + // ---- gamepads: the game polls these functions once per frame (see src/pad.rs). + // The Gamepad API needs a secure context (https or localhost) and shows a pad only after + // its first button press. slot = the index in navigator.getGamepads(), -1 = no gamepad. + env.dc_gamepad_slot = function () { + gpPad = null; + gpSlot = -1; + var pads = gamepads(); + for (var i = 0; i < pads.length; i++) { + if (pads[i] && pads[i].connected && pads[i].mapping === "standard") { + gpPad = pads[i]; + gpSlot = i; + return i; + } + } + // a pad without the standard mapping (old DirectInput devices): use it anyway + for (var j = 0; j < pads.length; j++) { + if (pads[j] && pads[j].connected) { + gpPad = pads[j]; + gpSlot = j; + return j; + } + } + return -1; + }; + function gamepad(slot) { + if (gpPad && slot === gpSlot) return gpPad; + var pads = gamepads(); + return pads[slot] && pads[slot].connected ? pads[slot] : null; + } + // bit i set: standard button i is down (triggers count from 40 % on) + env.dc_gamepad_buttons = function (slot) { + var p = gamepad(slot); + if (!p) return 0; + var m = 0; + for (var i = 0; i < p.buttons.length && i < 16; i++) { + var b = p.buttons[i]; + if (b && (b.pressed || b.value > 0.4)) m |= 1 << i; + } + return m; + }; + // axis 0/1 = left stick x/y, 2/3 = right stick x/y (all -1..1) + env.dc_gamepad_axis = function (slot, axis) { + var p = gamepad(slot); + if (!p || !p.axes || axis < 0 || axis >= p.axes.length) return 0; + return p.axes[axis]; + }; + // 0 unknown, 1 Xbox, 2 PlayStation, 3 Nintendo (from the pad's id) + // (the Xbox check comes first: its id contains "Wireless Controller" too) + env.dc_gamepad_kind = function (slot) { + var p = gamepad(slot); + if (!p) return 0; + var id = (p.id || "").toLowerCase(); + if (id.indexOf("xbox") >= 0 || id.indexOf("xinput") >= 0 || id.indexOf("microsoft") >= 0) return 1; + if (id.indexOf("nintendo") >= 0 || id.indexOf("switch") >= 0 || id.indexOf("joy-con") >= 0 || + id.indexOf("joycon") >= 0 || id.indexOf("pro controller") >= 0) return 3; + if (id.indexOf("playstation") >= 0 || id.indexOf("dualshock") >= 0 || id.indexOf("dualsense") >= 0 || + id.indexOf("sony") >= 0 || id.indexOf("wireless controller") >= 0 || id.indexOf("054c") >= 0 || + /(^|[^0-9])ps[345]([^0-9]|$)/.test(id)) return 2; + return 0; + }; env.dc_storage_set = function (kp, kl, vp, vl) { try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } };