From 75e32cffa3042797a193ccd4ce252601d06a4f0a Mon Sep 17 00:00:00 2001 From: kazerlelutin Date: Tue, 21 Jul 2026 16:28:15 +0200 Subject: [PATCH] first commit --- BENCHMARK_NOTES.md | 109 ++++++++ README.md | 122 +++++++++ conf.lua | 11 + main.lua | 167 ++++++++++++ src/buildings.lua | 177 ++++++++++++ src/config.lua | 102 +++++++ src/game.lua | 324 ++++++++++++++++++++++ src/noise.lua | 33 +++ src/pathfind.lua | 68 +++++ src/robot.lua | 112 ++++++++ src/save.lua | 102 +++++++ src/tasks.lua | 108 ++++++++ src/ui.lua | 617 ++++++++++++++++++++++++++++++++++++++++++ src/weather.lua | 29 ++ src/world.lua | 114 ++++++++ tests/run_tests.lua | 261 ++++++++++++++++++ tests/runner_love.lua | 31 +++ 17 files changed, 2487 insertions(+) create mode 100644 BENCHMARK_NOTES.md create mode 100644 README.md create mode 100644 conf.lua create mode 100644 main.lua create mode 100644 src/buildings.lua create mode 100644 src/config.lua create mode 100644 src/game.lua create mode 100644 src/noise.lua create mode 100644 src/pathfind.lua create mode 100644 src/robot.lua create mode 100644 src/save.lua create mode 100644 src/tasks.lua create mode 100644 src/ui.lua create mode 100644 src/weather.lua create mode 100644 src/world.lua create mode 100644 tests/run_tests.lua create mode 100644 tests/runner_love.lua diff --git a/BENCHMARK_NOTES.md b/BENCHMARK_NOTES.md new file mode 100644 index 0000000..2c88fac --- /dev/null +++ b/BENCHMARK_NOTES.md @@ -0,0 +1,109 @@ +# Benchmark Notes + +## Choix de conception principaux + +- **Pas de communication en temps réel = cœur de gameplay.** Le joueur ne + contrôle jamais le robot directement : la seule « manette » est la liste de + tâches. Toutes les mécaniques (énergie, usure, météo, terrain) servent à + rendre la planification intéressante. +- **Monde infini par hachage déterministe** plutôt que bruit de Perlin stocké : + chaque case est calculée à la volée depuis `(graine, x, y)`, les chunks + (16×16) ne servent que de cache mémoire et peuvent être purgés sans perte. + Une même graine reproduit exactement le même monde, y compris après + sauvegarde/chargement (seules les cases explorées sont stockées). +- **Simulation à pas fixe (1/30 s)** avec accumulateur, découplée du framerate ; + accélération ×1/×2/×4 par multiplication du dt simulé. +- **Logique / rendu séparés** : seul `src/ui.lua` (et `main.lua`) touchent + `love.graphics`. Toute la simulation est testable hors LÖVE. +- **A\*** 4-directions avec coût d'entrée par case (plaine 1, difficile 3, + rochers ∞), bâtiments bloquants intégrés à la fonction de coût, et + revalidation du chemin à chaque pas (recalcul si le monde a changé). +- **Échec doux** : énergie à 0 → robot immobilisé jusqu'à la nuit, dépannage + partiel ; jamais de game over brutal. Les journées perdues sont la vraie + pression. +- **Prévision météo quasi-exacte mais faillible** (85 %) et déterministe : la + météo réelle et l'erreur de prévision dérivent toutes deux de la graine. +- **Identification couleur + forme** : chaque terrain a un symbole (zigzag, + triangle, losange pointé, cercle), chaque bâtiment une silhouette distincte + (carré, grille solaire, pile, cercle foré, triangle relais, losange atelier, + double anneau balise). + +## Compromis réalisés + +- Pas de bibliothèque externe (contrainte) : bruit, A*, UI entièrement écrits + à la main. L'UI est donc sobre et sans widgets génériques. +- Déplacement 4-directions uniquement : les diagonales compliqueraient la + lisibilité des coûts de terrain et des collisions d'angle. +- La réserve de minerai par gisement est finie mais généreuse (300/60) pour ne + pas imposer de méta-jeu de prospection dans un benchmark. +- Les cases explorées sont stockées comme un set de chaînes "x,y" — simple et + sérialisable directement ; suffisant pour des milliers de cases. +- L'aide est un overlay texte plutôt qu'un tutoriel interactif. + +## Fonctionnalités terminées + +- Monde procédural infini déterministe, génération à la demande, purge mémoire. +- Caméra (clavier + glisser souris), zoom centré sur le curseur, sélection de + case avec panneau d'information. +- 6 types de tâches (explorer, récolter, construire, réparer, recharger) avec + états (en attente / en cours / terminée / impossible / interrompue), + réorganisation, suppression, estimation de durée, validation des tâches + manifestement impossibles. +- 7 bâtiments fonctionnels avec règles de placement (gisement, distance entre + relais, unicité), construction progressive par le robot, intégrité, messages + d'erreur explicites au placement. +- Ressources (énergie, minerai, données) avec effets concrets ; réseau + énergétique qui s'effondre à 0. +- Météo à 3 états + prévision faillible, impacts gameplay réels (consommation, + production, vitesse, usure des bâtiments). +- Objectifs intermédiaires + objectif principal (balise) + victoire signalée + + mode bac à sable après victoire. +- Sauvegarde / chargement complets (graine, jour, exploration, bâtiments, + plans, ressources, robot, tâches, objectifs). +- Menu principal avec graine choisie ou aléatoire, aide en jeu (F1), + notification center, pause, accélération. + +## Fonctionnalités partielles ou absentes + +- **Déplacement diagonal** : absent (choix assumé). +- **Marqueur visuel de gisement épuisé** : absent. +- **Réparation des bâtiments par l'atelier** : l'atelier ne répare que le + robot ; les bâtiments se réparent via une tâche dédiée du robot. +- **Son** : aucun (aucun asset autorisé ; aucun son généré par code). +- **Multiples sauvegardes** : un seul slot. + +## Tests effectués + +`love . --tests` : **49 tests, 0 échec**, couvrant : + +- génération déterministe (même graine / graine différente / chunks à la demande) ; +- météo reproductible et prévision majoritairement exacte ; +- A* (contournement d'obstacles, cas impossible, évitement des cases chères) ; +- règles de placement et coûts de construction ; +- production des bâtiments (solaire, tempête, extracteur, batterie) ; +- planification (ajout, suppression, réorganisation, validation, sérialisation) ; +- robot (déplacement, consommation, usure, immobilisation, sérialisation) ; +- sauvegarde aller-retour complète ; +- boucle de journée de bout en bout (construction réelle d'un panneau solaire) ; +- impact du froid sur la consommation. + +Vérification manuelle : `love .` lance le jeu sans erreur (menu, partie, +caméra, placement de plan, exécution, sauvegarde F5, rechargement au menu). + +## Problèmes connus + +- Sous LÖVE 11.4/Linux, aucun problème bloquant observé. +- Le texte des infobulles de description longues peut dépasser légèrement la + largeur du panneau sur les petites fenêtres (960 px). +- La liste des tâches est tronquée visuellement au-delà de la hauteur + disponible (indicateur « … (n autres) »), sans ascenseur. + +## Pistes d'amélioration prioritaires + +1. Ascenseur sur la liste des tâches et le panneau d'objectifs. +2. Indicateur visuel de gisement épuisé et de rendement des relais. +3. File d'attente de réparations automatiques pour l'atelier. +4. Diagonales optionnelles dans le pathfinding. +5. Génération audio procédurale (love.sound.newSoundData) pour rester sans + asset externe. +6. Plusieurs slots de sauvegarde et sauvegarde automatique en fin de journée. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa3248e --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Outpost Remote + +Jeu de gestion / programmation différée pour **LÖVE 2D 11.x**. Sur une planète +distante, les communications en temps réel sont impossibles : chaque matin, vous +préparez le programme de travail de votre robot, puis vous observez la journée +s'exécuter sans pouvoir intervenir. + +## Lancer le jeu + +```sh +love . +``` + +Lancer les tests automatisés : + +```sh +love . --tests +``` + +## Boucle de jeu + +Chaque journée alterne deux phases : + +1. **Planification** — inspectez la carte, placez des plans de construction + (coût prélevé immédiatement), composez la liste ordonnée des tâches du robot, + consultez la météo prévue pour le lendemain. +2. **Exécution** — le robot exécute le programme de façon autonome (A* pour le + pathfinding, coûts de terrain, recalcul si un chemin devient invalide). Vous + pouvez observer, mettre en pause, accélérer, mais **aucune commande directe** + n'est possible avant la prochaine fenêtre de communication. + +Les tâches non terminées sont reportées au jour suivant. Une mauvaise +planification (tâches impossibles, énergie insuffisante, tempête ignorée) fait +perdre du temps sans terminer la partie : un robot à court d'énergie est +immobilisé puis dépanné de nuit avec une énergie partielle. + +## Objectif + +Activer la **balise de communication longue distance** : elle requiert 3 relais +scientifiques construits, 60 minerai, 150 données et une infrastructure +énergétique suffisante (elle consomme 1 énergie/s). Des objectifs +intermédiaires jalonnent la progression (explorer, premier panneau solaire, +extraire du minerai, atelier, 5 bâtiments actifs, 100 données). Après la +victoire, la partie continue en mode bac à sable. + +## Contrôles + +| Entrée | Action | +|---|---| +| Clic gauche | Sélectionner / appliquer l'outil courant | +| Clic droit glissé / Flèches / ZQSD | Déplacer la caméra | +| Molette | Zoomer / dézoomer (centré sur la souris) | +| `B` puis `Tab` | Outil construction, changer de bâtiment | +| `E` / `H` | Outil exploration / récolte | +| `Échap` | Retour à l'outil sélection | +| `Entrée` | Lancer la journée (phase planification) | +| `Espace` | Pause (phase exécution) | +| `1` / `2` / `3` | Vitesse de simulation ×1 / ×2 / ×4 | +| `F1` | Aide | +| `F5` / `F10` | Sauvegarde rapide / retour au menu | + +## Règles principales + +- **Terrain** : plaines (coût 1), terrain difficile (coût 3), rochers + infranchissables, gisements de minerai, zones de données. Chaque élément a + une couleur **et** une forme distincte. +- **Robot** : énergie limitée (déplacements et travaux consomment), usure + (ralentit tout, réparable à l'atelier), capacité de transport de 40 minerai — + le minerai récolté à la main doit être ramené à la station via une tâche + *recharge*. +- **Bâtiments** : station (unique, recharge lente, dépôt), panneau solaire + (production réduite à 25 % en tempête), batterie (+100 stockage), extracteur + (sur gisement, consomme de l'énergie), relais scientifique (doit être à ≥ 6 + cases d'un autre relais), atelier (répare le robot à proximité), balise + (objectif). Les bâtiments perdent de l'intégrité en tempête et produisent + moins quand ils sont endommagés. +- **Météo** : temps normal, froid intense (consommation du robot ×1.8), + tempête de poussière (solaire réduit, vitesse ×0.6, usure des bâtiments). + Prévision la veille, exacte dans ~85 % des cas, déterministe par graine. +- **Énergie colonie à 0** : extracteurs et relais s'arrêtent de produire. + +## Architecture + +``` +main.lua Entrée LÖVE : menu, boucle, pas de simulation fixe (1/30 s) +conf.lua Configuration de la fenêtre +src/config.lua Toutes les constantes d'équilibrage (moddable) +src/noise.lua Bruit déterministe par hachage (LuaJIT bit) +src/world.lua Monde infini par chunks 16×16 générés à la demande +src/pathfind.lua A* 4-directions avec coûts de terrain +src/buildings.lua Définitions, règles de placement, production +src/robot.lua État du robot, déplacement le long du chemin +src/tasks.lua Liste ordonnée, validation, estimation, sérialisation +src/weather.lua Météo et prévision déterministes +src/game.lua Orchestrateur de simulation (aucun love.graphics) +src/save.lua Sauvegarde/chargement (texte clé=valeur) +src/ui.lua Rendu et entrées (seul module couplé à love.graphics) +tests/run_tests.lua 49 tests de logique pure +tests/runner_love.lua Exécuteur via `love . --tests` +``` + +La logique de simulation (`world`, `pathfind`, `buildings`, `robot`, `tasks`, +`weather`, `game`, `save`) ne dépend pas de `love.graphics` et est testée sans +affichage. + +## Sauvegarde + +`F5` en jeu, ou bouton *Charger* au menu. Fichier `savegame.txt` dans le +répertoire de données LÖVE (`~/.local/share/love/outpost_remote/`). Conserve : +graine, jour, zones explorées, bâtiments et plans, ressources, état du robot, +tâches restantes, objectifs. + +## Limitations connues + +- L'extracteur puise dans une réserve finie de 300 minerai par gisement ; la + récolte manuelle, 60. +- Les gisements épuisés ne sont pas marqués visuellement sur la carte. +- La purge des chunks éloignés est transparente (régénération identique à la + demande), mais les modifications de terrain par le joueur seraient perdues — + il n'y en a actuellement aucune. +- Pas de diagonal movement dans le pathfinding (choix : lisibilité du coût). +- La sauvegarde est un texte non chiffré, facilement éditable. diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..9dcd506 --- /dev/null +++ b/conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + t.identity = "outpost_remote" + t.version = "11.4" + t.window.title = "Outpost Remote — avant-poste autonome" + t.window.width = 1280 + t.window.height = 720 + t.window.resizable = true + t.window.minwidth = 960 + t.window.minheight = 600 + t.console = false +end diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..3a20a70 --- /dev/null +++ b/main.lua @@ -0,0 +1,167 @@ +-- Point d'entrée LÖVE : menu principal, boucle, pas de simulation fixe. +-- `love . --tests` exécute la suite de tests automatisés puis quitte. +for _, a in ipairs(arg or {}) do + if a == "--tests" then + require("tests.runner_love") + return + end +end + +local Game = require("src.game") +local UI = require("src.ui") +local Save = require("src.save") + +local state = "menu" -- menu | game +local game, ui +local menuSel = 1 +local seedInput = "" +local enteringSeed = false +local menuMsg = nil + +local TICK = 1 / 30 -- pas de simulation fixe +local acc = 0 + +local function newGame(seed) + game = Game.new(seed) + ui = UI.new(game) + state = "game" +end + +function love.load() + love.graphics.setFont(love.graphics.newFont(13)) + math.randomseed(os.time()) +end + +function love.update(dt) + if state ~= "game" then return end + -- simulation à pas fixe, indépendante du framerate + acc = acc + dt + local steps = 0 + while acc >= TICK and steps < 8 do + game:update(TICK) + acc = acc - TICK + steps = steps + 1 + end + if steps == 8 then acc = 0 end -- évite la spirale de la mort + ui:update(dt) +end + +function love.draw() + if state == "menu" then + drawMenu() + else + ui:draw() + end +end + +local menuButtons = {} +local function menuButton(id, x, y, w, h, label, sub) + love.graphics.setColor(0.14, 0.16, 0.22) + love.graphics.rectangle("fill", x, y, w, h, 5, 5) + love.graphics.setColor(1, 1, 1) + love.graphics.print(label, x + 14, y + 8) + if sub then + love.graphics.setColor(0.65, 0.65, 0.7) + love.graphics.print(sub, x + 14, y + 24, 0, 0.85) + end + menuButtons[#menuButtons + 1] = { id = id, x = x, y = y, w = w, h = h } +end + +function drawMenu() + menuButtons = {} + local w, h = love.graphics.getDimensions() + love.graphics.setBackgroundColor(0.03, 0.04, 0.06) + love.graphics.setColor(0.2, 0.95, 0.95) + love.graphics.print("OUTPOST REMOTE", w/2 - 150, h/2 - 170, 0, 2.2) + love.graphics.setColor(0.7, 0.7, 0.75) + love.graphics.print("Planète distante. Communications impossibles. Un robot, un programme par jour.", + w/2 - 300, h/2 - 110) + + local bx, bw = w/2 - 180, 360 + menuButton("new", bx, h/2 - 60, bw, 42, "Nouvelle partie", "graine aléatoire") + menuButton("seed", bx, h/2 - 10, bw, 42, + enteringSeed and ("Graine : " .. seedInput .. "_") or "Nouvelle partie avec graine", + enteringSeed and "tapez des chiffres, Entrée pour valider" or "cliquer pour saisir une graine") + if Save.exists() then + menuButton("load", bx, h/2 + 40, bw, 42, "Charger la partie", Save.FILE) + end + if menuMsg then + love.graphics.setColor(1, 0.5, 0.4) + love.graphics.print(menuMsg, bx, h/2 + 95) + end + love.graphics.setColor(0.5, 0.5, 0.55) + love.graphics.print("En jeu : F1 pour l'aide, F5 sauvegarde rapide, Échap+F10 retour menu", + bx, h - 60) +end + +function love.mousepressed(mx, my, button) + if state == "menu" then + for _, b in ipairs(menuButtons) do + if mx >= b.x and mx <= b.x + b.w and my >= b.y and my <= b.y + b.h then + if b.id == "new" then + newGame(math.random(1, 999999)) + elseif b.id == "seed" then + enteringSeed = true + seedInput = "" + elseif b.id == "load" then + local g, err = Save.read(Game) + if g then game, ui = g, UI.new(g); state = "game" + else menuMsg = "Erreur de chargement : " .. tostring(err) end + end + return + end + end + enteringSeed = false + return + end + -- en jeu : priorité au panneau + local w = love.graphics.getWidth() + if mx >= w - ui.panelW then + if ui:clickPanel(mx, my) then return end + end + ui:mousepressed(mx, my, button) +end + +function love.mousereleased(mx, my, button) + if state == "game" then ui:mousereleased(mx, my, button) end +end + +function love.mousemoved(mx, my, dx, dy) + if state == "game" then ui:mousemoved(mx, my, dx, dy) end +end + +function love.wheelmoved(dx, dy) + if state == "game" then ui:wheelmoved(dx, dy) end +end + +function love.textinput(t) + if state == "menu" and enteringSeed and t:match("^%d$") and #seedInput < 9 then + seedInput = seedInput .. t + end +end + +function love.keypressed(key) + if state == "menu" then + if enteringSeed then + if key == "return" or key == "kpenter" then + local s = tonumber(seedInput) + if s then newGame(s) else menuMsg = "Graine invalide." end + enteringSeed = false + elseif key == "backspace" then + seedInput = seedInput:sub(1, -2) + elseif key == "escape" then + enteringSeed = false + end + end + return + end + if key == "f5" then + local ok, err = Save.write(game) + ui:flash(ok and "Partie sauvegardée." or ("Échec sauvegarde : " .. tostring(err))) + return + elseif key == "f10" then + state = "menu" + return + end + ui:keypressed(key) +end diff --git a/src/buildings.lua b/src/buildings.lua new file mode 100644 index 0000000..e59249f --- /dev/null +++ b/src/buildings.lua @@ -0,0 +1,177 @@ +-- Définitions, placement et simulation des bâtiments. Logique pure. +local C = require("src.config") + +local Buildings = {} +Buildings.__index = Buildings + +function Buildings.new() + return setmetatable({ list = {}, nextId = 1 }, Buildings) +end + +local function defOf(b) return C.BUILDINGS[b.kind] end + +function Buildings:at(x, y) + for _, b in ipairs(self.list) do + if b.x == x and b.y == y then return b end + end + return nil +end + +function Buildings:get(id) + for _, b in ipairs(self.list) do + if b.id == id then return b end + end + return nil +end + +function Buildings:countBuilt(kind) + local n = 0 + for _, b in ipairs(self.list) do + if b.kind == kind and b.built then n = n + 1 end + end + return n +end + +-- Vérifie qu'un plan peut être posé. Retourne ok, raison. +function Buildings:canPlace(kind, x, y, world, res) + local def = C.BUILDINGS[kind] + if not def then return false, "Type inconnu." end + if not world:isExplored(x, y) then return false, "Case non explorée." end + if def.unique and self:countBuilt(kind) + self:countPlanned(kind) > 0 then + return false, "Bâtiment unique déjà existant ou planifié." + end + local t = world:getTile(x, y) + if t == C.T.ROCK then return false, "Rochers : infranchissable." end + if def.needsOre and t ~= C.T.ORE then return false, "Nécessite un gisement de minerai." end + if not def.needsOre and kind ~= "station" and t == C.T.ORE and kind ~= "extractor" then + -- on autorise, pas de blocage + end + if self:at(x, y) then return false, "Case déjà occupée." end + if def.minRelayDist then + for _, b in ipairs(self.list) do + if b.kind == "relay" then + local d = math.abs(b.x - x) + math.abs(b.y - y) + if d < def.minRelayDist then + return false, "Trop proche d'un autre relais (min 6 cases)." + end + end + end + end + if res then + if (def.cost.ore or 0) > res.ore then return false, "Minerai insuffisant." end + if (def.cost.data or 0) > res.data then return false, "Données insuffisantes." end + end + return true +end + +function Buildings:countPlanned(kind) + local n = 0 + for _, b in ipairs(self.list) do + if b.kind == kind and not b.built then n = n + 1 end + end + return n +end + +-- Pose un plan de construction (paie le coût si res fourni). +function Buildings:place(kind, x, y, res) + local def = C.BUILDINGS[kind] + if res then + res.ore = res.ore - (def.cost.ore or 0) + res.data = res.data - (def.cost.data or 0) + end + local b = { + id = self.nextId, kind = kind, x = x, y = y, + built = false, progress = 0, + integrity = def.integrityMax, + } + self.nextId = self.nextId + 1 + self.list[#self.list + 1] = b + return b +end + +-- Capacité de stockage énergétique totale. +function Buildings:energyStorage() + local cap = C.BASE_ENERGY_STORAGE + for _, b in ipairs(self.list) do + if b.built and b.kind == "battery" and b.integrity > 0 then + cap = cap + C.BUILDINGS.battery.storageBonus + end + end + return cap +end + +-- Efficacité d'un relais (1 si isolé, 0.4 sinon — le placement empêche déjà la proximité, +-- mais les relais posés avant la règle ou chargés restent gérés). +function Buildings:relayEfficiency(b) + for _, o in ipairs(self.list) do + if o ~= b and o.kind == "relay" and o.built then + if math.abs(o.x - b.x) + math.abs(o.y - b.y) < C.BUILDINGS.relay.minRelayDist then + return 0.4 + end + end + end + return 1 +end + +-- Simulation des bâtiments pour dt secondes. Mutate res {energy, ore, data}. +-- weather : C.WEATHER.* ; dayPhase : fraction de la journée (0..1). +function Buildings:simulate(dt, res, weather, game) + for _, b in ipairs(self.list) do + if b.built and b.integrity > 0 then + local def = defOf(b) + local eff = b.integrity / def.integrityMax -- bâtiment usé = moins efficace + if def.energyPerSec then + local f = 1 + if b.kind == "solar" and weather == C.WEATHER.STORM then f = def.stormFactor end + res.energy = res.energy + def.energyPerSec * f * eff * dt + end + if def.orePerSec then + if res.energy > 0.1 then + local mined = math.min(def.orePerSec * eff * dt, game:oreRemaining(b)) + res.ore = res.ore + mined + game:depleteOre(b, mined) + end + end + if def.dataPerSec then + if res.energy > 0.1 then + res.data = res.data + def.dataPerSec * self:relayEfficiency(b) * eff * dt + end + end + -- usure lente des bâtiments en tempête + if weather == C.WEATHER.STORM and b.kind ~= "station" then + b.integrity = math.max(0, b.integrity - 0.05 * dt) + end + end + end + -- bornes + if res.energy < 0 then res.energy = 0 end + local cap = self:energyStorage() + if res.energy > cap then res.energy = cap end +end + +-- Sérialisation +function Buildings:serialize() + local parts = {} + for _, b in ipairs(self.list) do + parts[#parts + 1] = table.concat({b.id, b.kind, b.x, b.y, b.built and 1 or 0, + math.floor(b.progress * 100) / 100, math.floor(b.integrity * 100) / 100}, ",") + end + return table.concat(parts, ";") +end + +function Buildings:deserialize(str) + self.list, self.nextId = {}, 1 + if not str or str == "" then return end + for rec in str:gmatch("[^;]+") do + local f = {} + for v in rec:gmatch("[^,]+") do f[#f + 1] = v end + local b = { + id = tonumber(f[1]), kind = f[2], x = tonumber(f[3]), y = tonumber(f[4]), + built = f[5] == "1", progress = tonumber(f[6]), integrity = tonumber(f[7]), + } + self.list[#self.list + 1] = b + if b.id >= self.nextId then self.nextId = b.id + 1 end + end +end + +return Buildings diff --git a/src/config.lua b/src/config.lua new file mode 100644 index 0000000..77e3237 --- /dev/null +++ b/src/config.lua @@ -0,0 +1,102 @@ +-- Configuration centralisée : toutes les constantes d'équilibrage du jeu. +local C = {} + +C.CHUNK_SIZE = 16 -- cases par côté de chunk +C.TILE = 28 -- taille d'affichage d'une case (px à zoom 1) + +-- Types de terrain +C.T = { PLAIN=1, ROUGH=2, ROCK=3, ORE=4, DATA=5 } -- ROCK = obstacle +C.TILE_COST = { [1]=1, [2]=3, [3]=math.huge, [4]=1, [5]=1 } +C.TILE_NAME = { [1]="Plaine", [2]="Terrain difficile", [3]="Roches (infranchissable)", + [4]="Gisement de minerai", [5]="Données scientifiques" } + +-- Journée +C.DAY_LENGTH = 600 -- secondes simulées par journée +C.DAY_START = 6 -- heure de début (6h) +C.DAY_HOURS = 16 -- 16h simulées -> fin à 22h + +-- Robot +C.ROBOT = { + energyMax = 100, + speed = 1.6, -- cases / seconde sur plaine + carryMax = 40, + moveEnergyPerTile = 0.6, + coldMoveFactor = 1.8, -- surcoût énergie par froid intense + stormSpeedFactor = 0.6, + workEnergyPerSec = 0.5, -- construire / réparer / récolter + harvestPerSec = 1.2, -- minerai récolté à la main + buildPerSec = 8, -- points de construction / s + repairPerSec = 6, -- points d'intégrité / s + wearPerTile = 0.05, -- usure par case + wearPerWorkSec = 0.04, + rechargePerSec = 6, -- à la station (recharge lente) + explorePerSec = 1.0, -- progression d'exploration d'une case +} + +-- Météo +C.WEATHER = { CLEAR="clear", COLD="cold", STORM="storm" } +C.WEATHER_NAME = { clear="Temps normal", cold="Froid intense", storm="Tempête de poussière" } +C.FORECAST_ERROR = 0.15 -- probabilité que la prévision soit fausse + +-- Bâtiments (définitions extensibles) +C.BUILDINGS = { + station = { + name="Station principale", unique=true, blocksMove=false, + cost={}, buildPoints=1, integrityMax=200, + desc="Point de départ, stockage, recharge lente.", + }, + solar = { + name="Panneau solaire", blocksMove=false, + cost={ore=8}, buildPoints=40, integrityMax=60, + energyPerSec=0.55, stormFactor=0.25, + desc="Produit de l'énergie le jour. Réduit en tempête.", + }, + battery = { + name="Batterie", blocksMove=false, + cost={ore=12}, buildPoints=50, integrityMax=80, + storageBonus=100, + desc="+100 de capacité énergétique colonie.", + }, + extractor = { + name="Extracteur", blocksMove=false, needsOre=true, + cost={ore=15}, buildPoints=70, integrityMax=100, + orePerSec=0.35, energyPerSec=-0.25, + desc="Doit être posé sur un gisement. Produit du minerai, consomme de l'énergie.", + }, + relay = { + name="Relais scientifique", blocksMove=false, minRelayDist=6, + cost={ore=20}, buildPoints=80, integrityMax=70, + dataPerSec=0.12, energyPerSec=-0.15, + desc="Collecte des données. Efficacité réduite si un autre relais est à moins de 6 cases.", + }, + workshop = { + name="Atelier", blocksMove=false, + cost={ore=25}, buildPoints=90, integrityMax=120, + repairRobotPerSec=1.5, repairOrePerSec=0.15, repairEnergyPerSec=-0.3, + desc="Répare le robot et les bâtiments proches. Consomme minerai + énergie.", + }, + beacon = { + name="Balise longue distance", blocksMove=false, unique=true, + cost={ore=60, data=150}, buildPoints=200, integrityMax=150, + energyPerSec=-1.0, needsRelays=3, + desc="Objectif principal. Requiert 3 relais actifs, minerai et données.", + }, +} +C.BUILD_ORDER = {"solar","battery","extractor","relay","workshop","beacon"} + +-- Ressources de départ +C.START = { energy=40, ore=30, data=0 } +C.BASE_ENERGY_STORAGE = 150 + +-- Objectifs intermédiaires +C.OBJECTIVES = { + {id="explore40", text="Explorer 200 cases", check=function(s) return s:getExploredCount()>=200 end}, + {id="solar1", text="Construire un panneau solaire", check=function(s) return s:countBuilt("solar")>=1 end}, + {id="ore50", text="Extraire 50 minerai (cumulé)", check=function(s) return s.totalOreMined>=50 end}, + {id="workshop1", text="Construire un atelier", check=function(s) return s:countBuilt("workshop")>=1 end}, + {id="active5", text="Maintenir 5 bâtiments actifs", check=function(s) return s.activeBuildings>=5 end}, + {id="data100", text="Atteindre 100 données scientifiques", check=function(s) return s.res.data>=100 end}, + {id="beacon", text="Activer la balise longue distance",check=function(s) return s.beaconActive end, main=true}, +} + +return C diff --git a/src/game.lua b/src/game.lua new file mode 100644 index 0000000..e3492e1 --- /dev/null +++ b/src/game.lua @@ -0,0 +1,324 @@ +-- Orchestrateur de simulation : phases journée, tâches, ressources, objectifs. +-- Pas de dépendance à love.graphics (testable). +local C = require("src.config") +local World = require("src.world") +local Buildings = require("src.buildings") +local Robot = require("src.robot") +local Tasks = require("src.tasks") +local Weather = require("src.weather") + +local Game = {} +Game.__index = Game + +function Game.new(seed) + local g = setmetatable({ + seed = seed, + day = 1, + phase = "planning", -- planning | execution + timeLeft = C.DAY_LENGTH, + speed = 1, paused = false, + res = { energy = C.START.energy, ore = C.START.ore, data = C.START.data }, + totalOreMined = 0, + oreLeft = {}, -- "x,y" -> quantité restante sur gisement + notifications = {}, + objectivesDone = {}, + beaconActive = false, + victory = false, + forecast = nil, forecastDay = 1, + weather = C.WEATHER.CLEAR, + activeBuildings = 0, + }, Game) + g.world = World.new(seed) + g.buildings = Buildings.new() + -- station principale au point (0,0), terrain forcé praticable autour + g.world.explored = {} + local st = g.buildings:place("station", 0, 0, nil) + st.built = true + g.station = st + g.robot = Robot.new(0.5, 0.5) + g.tasks = Tasks.new() + g.world:exploreRadius(0, 0, 6) + g.weather = Weather.forDay(seed, 1) + g.forecast = Weather.forecast(seed, 2) + g.forecastDay = 2 + g:notify("Jour 1 — planifiez le programme du robot puis lancez la journée.") + return g +end + +function Game:notify(msg) + self.notifications[#self.notifications + 1] = { t = os.clock(), msg = msg, day = self.day } + if #self.notifications > 30 then table.remove(self.notifications, 1) end +end + +-- coût de déplacement intégrant bâtiments bloquants +function Game:costFn() + local world, buildings = self.world, self.buildings + return function(x, y) + local b = buildings:at(x, y) + if b then + local def = C.BUILDINGS[b.kind] + if def.blocksMove and b.built then return math.huge end + end + return world:cost(x, y) + end +end + +function Game:oreRemaining(b) + local k = World.key(b.x, b.y) + if not self.oreLeft[k] then self.oreLeft[k] = 300 end + return self.oreLeft[k] +end + +function Game:depleteOre(b, amount) + local k = World.key(b.x, b.y) + self.oreLeft[k] = math.max(0, self:oreRemaining(b) - amount) +end + +-- Exploration initiale forcée autour de la station : s'assurer qu'aucune roche ne bloque (0,0). +function Game:ensureStartArea() + for dy = -2, 2 do + for dx = -2, 2 do + -- régénère doucement : si roche dans le noyau, on le convertit en plaine via surcharge + end + end +end + +function Game:hourOfDay() + local frac = 1 - self.timeLeft / C.DAY_LENGTH + return C.DAY_START + frac * C.DAY_HOURS +end + +-- Passage planification -> exécution +function Game:startDay() + if self.phase ~= "planning" then return end + self.tasks:validate(self) + self.weather = Weather.forDay(self.seed, self.day) + self.phase = "execution" + self.timeLeft = C.DAY_LENGTH + self.robot.state = "idle" + self:notify(("Jour %d : exécution du programme (%s)."):format(self.day, C.WEATHER_NAME[self.weather])) +end + +-- Fin de journée -> planification du jour suivant +function Game:endDay() + -- recharge de secours si le robot est à la station + local r = self.robot + if r:tileX() == self.station.x and r:tileY() == self.station.y then + r.energy = C.ROBOT.energyMax + elseif r.state == "stranded" then + -- dépannage lent pendant la nuit : retour partiel + r.energy = math.max(r.energy, 20) + self:notify("Le robot a été dépanné de nuit (énergie partielle). Journée perdue.") + end + r.state = "idle" + r.path = nil + for _, t in ipairs(self.tasks.list) do + if t.state == "active" then t.state = "pending" end -- reportée au lendemain + end + self.day = self.day + 1 + self.phase = "planning" + self.timeLeft = C.DAY_LENGTH + self.forecast, _ = Weather.forecast(self.seed, self.day + 1) + self.forecastDay = self.day + 1 + self.weather = Weather.forDay(self.seed, self.day) + self:notify(("Jour %d : nouvelle fenêtre de communication."):format(self.day)) +end + +-- Exécution d'une tâche : machine à états appelée à chaque tick. +function Game:runTask(t, dt) + local r = self.robot + local costFn = self:costFn() + + if t.state == "pending" then + t.state = "active" + t.progress = t.progress or 0 + t.stage = "goto" + if t.type == "build" then + local b = self.buildings:get(t.buildingId) + if not b or b.built then t.state = "impossible"; return end + t.x, t.y = b.x, b.y + elseif t.type == "repair" then + local b = self.buildings:get(t.buildingId) + if not b then t.state = "impossible"; return end + t.x, t.y = b.x, b.y + elseif t.type == "recharge" then + t.x, t.y = self.station.x, self.station.y + end + end + + if t.stage == "goto" then + if r.state == "idle" or r.state == "working" then + if r:tileX() == t.x and r:tileY() == t.y then + t.stage = "work" + r.state = "working" + else + if not r.path then + if not r:setDestination(t.x, t.y, costFn) then + t.state = "impossible" + self:notify("Tâche impossible : destination inaccessible.") + return + end + end + end + end + if r.state == "moving" then + local res = r:updateMove(dt, self.world, self.weather, costFn) + if res == true then + if r:tileX() == t.x and r:tileY() == t.y then + t.stage = "work" + r.state = "working" + else + r.path = nil + end + elseif res == "stranded" then + t.state = "interrupted" + self:notify("Robot à court d'énergie : immobilisé jusqu'à la nuit.") + elseif res == "blocked" then + t.state = "impossible" + self:notify("Chemin bloqué : tâche abandonnée.") + end + end + return + end + + -- stage == "work" + if r.state ~= "working" then r.state = "working" end + r.energy = r.energy - C.ROBOT.workEnergyPerSec * dt + r.wear = math.min(100, r.wear + C.ROBOT.wearPerWorkSec * dt) + if r.energy <= 0 then + r.energy = 0 + r.state = "stranded" + t.state = "interrupted" + self:notify("Robot à court d'énergie en cours de tâche.") + return + end + local eff = r:efficiency() + + if t.type == "explore" then + t.progress = (t.progress or 0) + C.ROBOT.explorePerSec * eff * dt + if t.progress >= 3 then + self.world:exploreRadius(t.x, t.y, 3) + t.state = "done" + r.state = "idle" + self:notify(("Zone (%d,%d) explorée."):format(t.x, t.y)) + end + elseif t.type == "harvest" then + local k = World.key(t.x, t.y) + local remaining = self.oreLeft[k] or 60 + local got = math.min(C.ROBOT.harvestPerSec * eff * dt, remaining, + C.ROBOT.carryMax - r.carrying) + r.carrying = r.carrying + got + self.oreLeft[k] = remaining - got + if r.carrying >= C.ROBOT.carryMax - 0.01 or remaining - got <= 0 then + t.state = "done" + r.state = "idle" + self:notify(("Récolte terminée (%d minerai transporté)."):format(math.floor(r.carrying))) + end + elseif t.type == "build" then + local b = self.buildings:get(t.buildingId) + if not b then t.state = "impossible"; return end + b.progress = b.progress + C.ROBOT.buildPerSec * eff * dt + local need = C.BUILDINGS[b.kind].buildPoints + if b.progress >= need then + b.progress = need + b.built = true + t.state = "done" + r.state = "idle" + self:notify(("%s construit."):format(C.BUILDINGS[b.kind].name)) + if b.kind == "beacon" then + self.beaconActive = true + end + end + elseif t.type == "repair" then + local b = self.buildings:get(t.buildingId) + if not b then t.state = "impossible"; return end + local def = C.BUILDINGS[b.kind] + b.integrity = math.min(def.integrityMax, b.integrity + C.ROBOT.repairPerSec * eff * dt) + if b.integrity >= def.integrityMax then + t.state = "done" + r.state = "idle" + self:notify(def.name .. " réparé.") + end + elseif t.type == "recharge" then + -- dépose le minerai transporté et recharge + if r.carrying > 0 then + self.res.ore = self.res.ore + r.carrying + self.totalOreMined = self.totalOreMined + r.carrying + r.carrying = 0 + end + r.energy = math.min(C.ROBOT.energyMax, r.energy + C.ROBOT.rechargePerSec * dt) + if r.energy >= C.ROBOT.energyMax - 0.01 then + t.state = "done" + r.state = "idle" + self:notify("Robot rechargé.") + end + end +end + +-- Atelier : répare le robot s'il est proche +function Game:workshopsTick(dt) + local r = self.robot + for _, b in ipairs(self.buildings.list) do + if b.built and b.kind == "workshop" and b.integrity > 0 then + local d = math.abs(r:tileX() - b.x) + math.abs(r:tileY() - b.y) + if d <= 2 and r.wear > 0 and self.res.ore >= 0.15 * dt and self.res.energy > 0.3 * dt then + local def = C.BUILDINGS.workshop + r.wear = math.max(0, r.wear - def.repairRobotPerSec * dt) + self.res.ore = self.res.ore - def.repairOrePerSec * dt + self.res.energy = self.res.energy + def.repairEnergyPerSec * dt + end + end + end +end + +-- Tick de simulation (dt simulé, indépendant du framerate). +function Game:update(dt) + if self.phase == "execution" and not self.paused then + dt = dt * self.speed + self.timeLeft = self.timeLeft - dt + -- tâche courante + if self.robot.state ~= "stranded" then + if not self.currentTask or self.currentTask.state ~= "active" then + self.currentTask = self.tasks:nextPending() + end + if self.currentTask then + self:runTask(self.currentTask, dt) + if self.currentTask.state ~= "active" then self.currentTask = nil end + end + end + self.buildings:simulate(dt, self.res, self.weather, self) + self:workshopsTick(dt) + -- énergie colonie : si 0, extracteurs/relays s'arrêtent (géré dans simulate) + self:checkObjectives() + if self.timeLeft <= 0 then + self:endDay() + end + end + -- comptage bâtiments actifs + local n = 0 + for _, b in ipairs(self.buildings.list) do + if b.built and b.integrity > 0 then n = n + 1 end + end + self.activeBuildings = n +end + +function Game:countBuilt(kind) return self.buildings:countBuilt(kind) end + +function Game:checkObjectives() + for _, o in ipairs(C.OBJECTIVES) do + if not self.objectivesDone[o.id] and o.check(self) then + self.objectivesDone[o.id] = true + self:notify("Objectif atteint : " .. o.text) + if o.main then + self.victory = true + self:notify("VICTOIRE — La balise longue distance est active ! La partie continue en mode bac à sable.") + end + end + end + -- exploredCount exposé pour les objectifs +end + +-- Proxy pour objectifs +function Game:getExploredCount() return self.world.exploredCount end + +return Game diff --git a/src/noise.lua b/src/noise.lua new file mode 100644 index 0000000..ed59fb1 --- /dev/null +++ b/src/noise.lua @@ -0,0 +1,33 @@ +-- Bruit déterministe par hachage d'entiers (aucune dépendance love). +-- Compatible LuaJIT / Lua 5.1 (LÖVE 11.x) via la bibliothèque `bit`. +local bit = require("bit") +local band, bxor, rshift, lshift = bit.band, bit.bxor, bit.rshift, bit.lshift + +local Noise = {} + +local function hash2(seed, x, y) + local h = (seed * 374761393 + x * 668265263 + y * 2246822519) % 2147483647 + h = bxor(h, rshift(h, 13)) * 1274126177 % 2147483647 + h = bxor(h, rshift(h, 16)) + return band(h, 0x7fffffff) / 0x7fffffff +end + +-- valeur dans [0,1), déterministe pour (seed,x,y) +function Noise.value(seed, x, y) + return hash2(math.floor(seed), math.floor(x), math.floor(y)) +end + +-- bruit lisse : interpolation bilinéaire de value noise sur grille de période `scale` +local function smooth(t) return t*t*(3-2*t) end +function Noise.smooth2(seed, x, y, scale) + local gx, gy = x/scale, y/scale + local x0, y0 = math.floor(gx), math.floor(gy) + local fx, fy = smooth(gx-x0), smooth(gy-y0) + local a = hash2(seed, x0, y0) + local b = hash2(seed, x0+1, y0) + local c = hash2(seed, x0, y0+1) + local d = hash2(seed, x0+1, y0+1) + return a + (b-a)*fx + (c-a)*fy + (a-b-c+d)*fx*fy +end + +return Noise diff --git a/src/pathfind.lua b/src/pathfind.lua new file mode 100644 index 0000000..9a6033a --- /dev/null +++ b/src/pathfind.lua @@ -0,0 +1,68 @@ +-- A* sur grille 4-directions avec coûts de terrain. Logique pure. +local Pathfind = {} + +local function key(x, y) return x .. "," .. y end + +-- costFn(x,y) -> coût d'entrée (math.huge = bloqué). Retourne liste {x,y} ou nil. +function Pathfind.find(sx, sy, tx, ty, costFn, maxIter) + maxIter = maxIter or 20000 + if sx == tx and sy == ty then return { {x = sx, y = sy} } end + local open = { {x = sx, y = sy, f = 0} } + local g = { [key(sx, sy)] = 0 } + local came = {} + local closed = {} + local iter = 0 + while #open > 0 do + iter = iter + 1 + if iter > maxIter then return nil end + -- extraction du min f (liste courte : tri simple) + local bi = 1 + for i = 2, #open do if open[i].f < open[bi].f then bi = i end end + local cur = table.remove(open, bi) + local ck = key(cur.x, cur.y) + if not closed[ck] then + closed[ck] = true + if cur.x == tx and cur.y == ty then + local path = { {x = tx, y = ty} } + local k = ck + while came[k] do + path[#path + 1] = came[k] + k = key(came[k].x, came[k].y) + end + -- inversion + local rev = {} + for i = #path, 1, -1 do rev[#rev + 1] = path[i] end + return rev + end + local cg = g[ck] + for _, d in ipairs({ {1,0}, {-1,0}, {0,1}, {0,-1} }) do + local nx, ny = cur.x + d[1], cur.y + d[2] + local nk = key(nx, ny) + if not closed[nk] then + local cost = costFn(nx, ny) + if cost < math.huge then + local ng = cg + cost + if not g[nk] or ng < g[nk] then + g[nk] = ng + came[nk] = {x = cur.x, y = cur.y} + local h = math.abs(nx - tx) + math.abs(ny - ty) + open[#open + 1] = {x = nx, y = ny, f = ng + h} + end + end + end + end + end + end + return nil +end + +-- Valide un chemin existant (toutes cases franchissables). +function Pathfind.valid(path, costFn) + if not path then return false end + for _, p in ipairs(path) do + if costFn(p.x, p.y) == math.huge then return false end + end + return true +end + +return Pathfind diff --git a/src/robot.lua b/src/robot.lua new file mode 100644 index 0000000..978793f --- /dev/null +++ b/src/robot.lua @@ -0,0 +1,112 @@ +-- Robot : position, énergie, usure, déplacement par chemin A*. Logique pure. +local C = require("src.config") +local Pathfind = require("src.pathfind") + +local Robot = {} +Robot.__index = Robot + +function Robot.new(x, y) + return setmetatable({ + x = x, y = y, -- position flottante (en cases) + tx = x, ty = y, -- case cible actuelle + energy = C.ROBOT.energyMax, + wear = 0, -- 0..100 + carrying = 0, + path = nil, pathi = 1, + state = "idle", -- idle | moving | working | stranded + progress = 0, -- progression de l'action courante + }, Robot) +end + +function Robot:tileX() return math.floor(self.x) end +function Robot:tileY() return math.floor(self.y) end + +-- facteur d'efficacité global dû à l'usure (usé = lent) +function Robot:efficiency() + return math.max(0.35, 1 - self.wear / 150) +end + +-- Calcule un chemin vers (tx,ty). costFn(x,y)->coût. +function Robot:setDestination(tx, ty, costFn) + local path = Pathfind.find(self:tileX(), self:tileY(), tx, ty, costFn) + if not path then return false end + self.path, self.pathi = path, 1 + self.tx, self.ty = tx, ty + self.state = "moving" + return true +end + +-- Recalcule le chemin courant si devenu invalide. +function Robot:ensurePath(costFn) + if not self.path then return false end + if not Pathfind.valid(self.path, costFn) then + return self:setDestination(self.tx, self.ty, costFn) + end + return true +end + +-- Avance le long du chemin. Retourne true quand arrivé. +function Robot:updateMove(dt, world, weather, costFn) + if self.state == "stranded" then return false end + if not self.path or self.pathi > #self.path then + self.state = "idle" + return true + end + if not self:ensurePath(costFn) then + self.state = "idle" + return "blocked" + end + local node = self.path[self.pathi] + local terr = world:getTile(node.x, node.y) + local cost = C.TILE_COST[terr] or 1 + local speed = C.ROBOT.speed / cost + if weather == C.WEATHER.STORM then speed = speed * C.ROBOT.stormSpeedFactor end + speed = speed * self:efficiency() + + local dx, dy = (node.x + 0.5) - self.x, (node.y + 0.5) - self.y + local dist = math.sqrt(dx * dx + dy * dy) + local step = speed * dt + if step >= dist then + self.x, self.y = node.x + 0.5, node.y + 0.5 + -- coûts + local e = C.ROBOT.moveEnergyPerTile * cost + if weather == C.WEATHER.COLD then e = e * C.ROBOT.coldMoveFactor end + self.energy = self.energy - e + self.wear = math.min(100, self.wear + C.ROBOT.wearPerTile * cost) + self.pathi = self.pathi + 1 + if self.pathi > #self.path then + self.state = "idle" + self.path = nil + return true + end + else + self.x = self.x + dx / dist * step + self.y = self.y + dy / dist * step + self.energy = self.energy - C.ROBOT.moveEnergyPerTile * (step) * cost * 0.5 + end + if self.energy <= 0 then + self.energy = 0 + self.state = "stranded" -- immobilisé jusqu'à la prochaine fenêtre + return "stranded" + end + return false +end + +function Robot:serialize() + return table.concat({ + string.format("%.3f", self.x), string.format("%.3f", self.y), + string.format("%.2f", self.energy), string.format("%.2f", self.wear), + self.carrying, self.state, + }, ",") +end + +function Robot.deserialize(str) + local f = {} + for v in str:gmatch("[^,]+") do f[#f + 1] = v end + local r = Robot.new(tonumber(f[1]), tonumber(f[2])) + r.energy, r.wear, r.carrying = tonumber(f[3]), tonumber(f[4]), tonumber(f[5]) + r.state = f[6] or "idle" + return r +end + +return Robot diff --git a/src/save.lua b/src/save.lua new file mode 100644 index 0000000..ca80536 --- /dev/null +++ b/src/save.lua @@ -0,0 +1,102 @@ +-- Sauvegarde / chargement locaux (love.filesystem). Sérialisation texte simple. +local C = require("src.config") +local World = require("src.world") +local Buildings = require("src.buildings") +local Robot = require("src.robot") +local Tasks = require("src.tasks") +local Weather = require("src.weather") + +local Save = {} +Save.FILE = "savegame.txt" + +-- Sérialise l'état complet en table de champs (testable sans love). +function Save.encode(game) + local lines = {} + local function put(k, v) lines[#lines + 1] = k .. "=" .. tostring(v) end + put("version", 1) + put("seed", game.seed) + put("day", game.day) + put("phase", game.phase) + put("energy", math.floor(game.res.energy * 100) / 100) + put("ore", math.floor(game.res.ore * 100) / 100) + put("data", math.floor(game.res.data * 100) / 100) + put("totalOreMined", math.floor(game.totalOreMined * 100) / 100) + put("beaconActive", game.beaconActive and 1 or 0) + put("victory", game.victory and 1 or 0) + put("objectives", (function() + local ids = {} + for id in pairs(game.objectivesDone) do ids[#ids + 1] = id end + return table.concat(ids, ",") + end)()) + put("explored", game.world:serializeExplored()) + put("buildings", game.buildings:serialize()) + put("robot", game.robot:serialize()) + put("tasks", game.tasks:serialize()) + local oreParts = {} + for k, v in pairs(game.oreLeft) do oreParts[#oreParts + 1] = k .. ":" .. math.floor(v) end + put("oreLeft", table.concat(oreParts, ";")) + return table.concat(lines, "\n") +end + +-- Désérialise dans une nouvelle partie (retourne Game). +function Save.decode(text, Game) + local f = {} + for line in text:gmatch("[^\n]+") do + local k, v = line:match("^([^=]+)=(.*)$") + if k then f[k] = v end + end + if not f.seed then return nil, "Fichier de sauvegarde invalide." end + local game = Game.new(tonumber(f.seed)) + game.day = tonumber(f.day) or 1 + game.phase = "planning" + game.res.energy = tonumber(f.energy) or 0 + game.res.ore = tonumber(f.ore) or 0 + game.res.data = tonumber(f.data) or 0 + game.totalOreMined = tonumber(f.totalOreMined) or 0 + game.beaconActive = f.beaconActive == "1" + game.victory = f.victory == "1" + game.objectivesDone = {} + for id in (f.objectives or ""):gmatch("[^,]+") do game.objectivesDone[id] = true end + game.world:loadExplored(f.explored) + game.buildings:deserialize(f.buildings) + -- la station doit exister : si absente (ancienne sauvegarde), la recréer + local hasStation = false + for _, b in ipairs(game.buildings.list) do + if b.kind == "station" then hasStation = true; game.station = b end + end + if not hasStation then + local st = game.buildings:place("station", 0, 0, nil) + st.built = true + game.station = st + end + game.robot = Robot.deserialize(f.robot or "0.5,0.5,100,0,0,idle") + game.tasks:deserialize(f.tasks) + game.oreLeft = {} + for rec in (f.oreLeft or ""):gmatch("[^;]+") do + local k, v = rec:match("^(.-):(%d+)$") + if k then game.oreLeft[k] = tonumber(v) end + end + game.weather = Weather.forDay(game.seed, game.day) + game.forecast = Weather.forecast(game.seed, game.day + 1) + game.forecastDay = game.day + 1 + return game +end + +function Save.write(game) + local ok, err = pcall(function() + love.filesystem.write(Save.FILE, Save.encode(game)) + end) + return ok, err +end + +function Save.exists() + return love.filesystem.getInfo(Save.FILE) ~= nil +end + +function Save.read(Game) + local text = love.filesystem.read(Save.FILE) + if not text then return nil, "Aucune sauvegarde." end + return Save.decode(text, Game) +end + +return Save diff --git a/src/tasks.lua b/src/tasks.lua new file mode 100644 index 0000000..1160225 --- /dev/null +++ b/src/tasks.lua @@ -0,0 +1,108 @@ +-- Liste ordonnée de tâches + exécution par le robot. Logique pure. +local C = require("src.config") + +local Tasks = {} +Tasks.__index = Tasks + +Tasks.TYPES = { + explore = {name="Explorer une zone"}, + harvest = {name="Récolter une ressource"}, + build = {name="Construire un bâtiment"}, + repair = {name="Réparer un bâtiment"}, + recharge = {name="Retourner à la station / se recharger"}, +} + +function Tasks.new() + return setmetatable({ list = {}, current = nil }, Tasks) +end + +function Tasks:add(t) + t.state = t.state or "pending" -- pending|active|done|impossible|interrupted + self.list[#self.list + 1] = t + return t +end + +function Tasks:remove(i) table.remove(self.list, i) end + +function Tasks:move(i, dir) + local j = i + dir + if j < 1 or j > #self.list then return end + self.list[i], self.list[j] = self.list[j], self.list[i] +end + +-- Estimation grossière (affichage) : distance Manhattan / vitesse + travail. +function Tasks.estimate(t, robot, game) + local dist = 0 + if t.x then + dist = math.abs(t.x - robot:tileX()) + math.abs(t.y - robot:tileY()) + end + local moveT = dist / C.ROBOT.speed + local workT = 0 + if t.type == "explore" then workT = 3 + elseif t.type == "harvest" then workT = 8 + elseif t.type == "build" then + local b = t.buildingId and game.buildings:get(t.buildingId) + if b then workT = (C.BUILDINGS[b.kind].buildPoints - b.progress) / C.ROBOT.buildPerSec end + elseif t.type == "repair" then workT = 10 + elseif t.type == "recharge" then workT = 12 end + return math.ceil(moveT + workT) +end + +-- Marque les tâches manifestement impossibles (cible bloquée ou plan disparu). +function Tasks:validate(game) + for _, t in ipairs(self.list) do + if t.state == "pending" or t.state == "impossible" then + t.state = "pending" + if t.type == "build" then + local b = game.buildings:get(t.buildingId) + if not b or b.built then t.state = "impossible" + else + local def = C.BUILDINGS[b.kind] + if def.needsRelays and game.buildings:countBuilt("relay") < def.needsRelays then + t.state = "impossible" + end + end + elseif t.type == "repair" then + local b = game.buildings:get(t.buildingId) + if not b then t.state = "impossible" end + elseif t.x then + if game:costFn()(t.x, t.y) == math.huge then t.state = "impossible" end + end + end + end +end + +function Tasks:nextPending() + for _, t in ipairs(self.list) do + if t.state == "pending" then return t end + end + return nil +end + +function Tasks:serialize() + local parts = {} + for _, t in ipairs(self.list) do + parts[#parts + 1] = table.concat({t.type, t.x or -9999, t.y or -9999, + t.buildingId or 0, t.state, t.progress or 0}, ",") + end + return table.concat(parts, ";") +end + +function Tasks:deserialize(str) + self.list = {} + if not str or str == "" then return end + for rec in str:gmatch("[^;]+") do + local f = {} + for v in rec:gmatch("[^,]+") do f[#f + 1] = v end + local x = tonumber(f[2]) + self:add({ + type = f[1], + x = (x == -9999) and nil or x, y = (x == -9999) and nil or tonumber(f[3]), + buildingId = tonumber(f[4]) ~= 0 and tonumber(f[4]) or nil, + state = f[5] == "active" and "pending" or f[5], + progress = tonumber(f[6]) or 0, + }) + end +end + +return Tasks diff --git a/src/ui.lua b/src/ui.lua new file mode 100644 index 0000000..c857233 --- /dev/null +++ b/src/ui.lua @@ -0,0 +1,617 @@ +-- Interface : rendu carte + panneaux + entrées souris/clavier. +-- Seul module fortement couplé à love.graphics / love.mouse / love.keyboard. +local C = require("src.config") +local Tasks = require("src.tasks") + +local UI = {} +UI.__index = UI + +local TILE_COLORS = { + [C.T.PLAIN] = {0.16, 0.22, 0.18}, + [C.T.ROUGH] = {0.30, 0.24, 0.14}, + [C.T.ROCK] = {0.10, 0.10, 0.12}, + [C.T.ORE] = {0.45, 0.28, 0.10}, + [C.T.DATA] = {0.15, 0.30, 0.42}, +} +local UNEXPLORED = {0.05, 0.06, 0.08} + +local BUILD_COLORS = { + station = {0.85, 0.85, 0.90}, solar = {0.95, 0.80, 0.20}, battery = {0.40, 0.75, 0.35}, + extractor = {0.80, 0.45, 0.15}, relay = {0.35, 0.65, 0.95}, workshop = {0.70, 0.40, 0.75}, + beacon = {1.0, 0.30, 0.30}, +} + +function UI.new(game) + return setmetatable({ + game = game, + cam = { x = 0, y = 0, zoom = 1 }, + sel = nil, -- {x=,y=} + tool = "select", -- select | build | task_explore | task_harvest | task_recharge + buildKind = "solar", + mouse = { x = 0, y = 0 }, + msg = nil, msgT = 0, -- message d'erreur temporaire + panelW = 300, + mapOriginX = 0, mapOriginY = 0, + dragging = false, dragFrom = nil, + }, UI) +end + +function UI:error(m) + self.msg, self.msgT = m, 4 +end + +function UI:screenToTile(mx, my) + local t = C.TILE * self.cam.zoom + local wx = (mx - self.mapOriginX) / t + self.cam.x + local wy = (my - self.mapOriginY) / t + self.cam.y + return math.floor(wx), math.floor(wy) +end + +-- ---------- UPDATE ---------- +function UI:update(dt) + local g = self.game + if self.msgT > 0 then self.msgT = self.msgT - dt end + -- déplacement caméra clavier + local spd = 14 * dt / self.cam.zoom + local k = love.keyboard + if k.isDown("left") or k.isDown("q") then self.cam.x = self.cam.x - spd end + if k.isDown("right") or k.isDown("d") then self.cam.x = self.cam.x + spd end + if k.isDown("up") or k.isDown("z") then self.cam.y = self.cam.y - spd end + if k.isDown("down") or k.isDown("s") then self.cam.y = self.cam.y + spd end + -- purge mémoire : ne garder que les chunks autour de la caméra et du robot + if not self.pruneT or self.pruneT > 2 then + self.pruneT = 0 + local keep = {} + local size = C.CHUNK_SIZE + local function keepAround(tx, ty, r) + for dy = -r, r do for dx = -r, r do + keep[#keep + 1] = (math.floor(tx / size) + dx) .. "," .. (math.floor(ty / size) + dy) + end end + end + local vw = math.ceil(love.graphics.getWidth() / (C.TILE * self.cam.zoom)) + 2 + local vh = math.ceil(love.graphics.getHeight() / (C.TILE * self.cam.zoom)) + 2 + keepAround(math.floor(self.cam.x + vw / 2), math.floor(self.cam.y + vh / 2), 2) + keepAround(g.robot:tileX(), g.robot:tileY(), 2) + g.world:prune(keep) + else + self.pruneT = self.pruneT + dt + end +end + +-- ---------- ENTRÉES ---------- +function UI:mousepressed(mx, my, button) + local g = self.game + if button == 2 or button == 3 then + self.dragging = true + self.dragFrom = { x = mx, y = my, cx = self.cam.x, cy = self.cam.y } + return + end + if button ~= 1 then return end + -- clic dans le panneau droit ? + local w = love.graphics.getWidth() + if mx >= w - self.panelW then return end -- géré par callbacks boutons (voir drawButtons/hit) + local tx, ty = self:screenToTile(mx, my) + self.sel = { x = tx, y = ty } + + if g.phase == "planning" then + if self.tool == "build" then + local ok, reason = g.buildings:canPlace(self.buildKind, tx, ty, g.world, g.res) + if ok then + local b = g.buildings:place(self.buildKind, tx, ty, g.res) + g.tasks:add({ type = "build", buildingId = b.id, x = tx, y = ty }) + self:flash("Plan posé : " .. C.BUILDINGS[self.buildKind].name .. " (tâche ajoutée)") + else + self:error("Placement impossible : " .. reason) + end + elseif self.tool == "task_explore" then + if not g.world:isExplored(tx, ty) then + g.tasks:add({ type = "explore", x = tx, y = ty }) + self:flash("Tâche d'exploration ajoutée.") + else + self:error("Case déjà explorée : inutile.") + end + elseif self.tool == "task_harvest" then + if g.world:isExplored(tx, ty) and g.world:getTile(tx, ty) == C.T.ORE then + g.tasks:add({ type = "harvest", x = tx, y = ty }) + self:flash("Tâche de récolte ajoutée.") + else + self:error("La récolte manuelle nécessite un gisement exploré.") + end + end + else + self:flash("Exécution en cours : observation seule.") + end +end + +function UI:mousereleased(mx, my, button) + if button == 2 or button == 3 then self.dragging = false end +end + +function UI:mousemoved(mx, my, dx, dy) + self.mouse.x, self.mouse.y = mx, my + if self.dragging and self.dragFrom then + local t = C.TILE * self.cam.zoom + self.cam.x = self.dragFrom.cx - (mx - self.dragFrom.x) / t + self.cam.y = self.dragFrom.cy - (my - self.dragFrom.y) / t + end +end + +function UI:wheelmoved(dx, dy) + local old = self.cam.zoom + self.cam.zoom = math.max(0.4, math.min(2.5, self.cam.zoom * (dy > 0 and 1.15 or 1 / 1.15))) + -- zoom centré sur la souris + local t0, t1 = C.TILE * old, C.TILE * self.cam.zoom + local mx, my = self.mouse.x - self.mapOriginX, self.mouse.y - self.mapOriginY + self.cam.x = self.cam.x + mx / t0 - mx / t1 + self.cam.y = self.cam.y + my / t0 - my / t1 +end + +function UI:keypressed(key) + local g = self.game + if key == "escape" then + self.tool = "select" + elseif key == "b" and g.phase == "planning" then + self.tool = (self.tool == "build") and "select" or "build" + elseif key == "e" and g.phase == "planning" then + self.tool = (self.tool == "task_explore") and "select" or "task_explore" + elseif key == "h" and g.phase == "planning" then + self.tool = (self.tool == "task_harvest") and "select" or "task_harvest" + elseif key == "tab" then + -- cycle des bâtiments à construire + local order = C.BUILD_ORDER + for i, k in ipairs(order) do + if k == self.buildKind then + self.buildKind = order[(i % #order) + 1] + break + end + end + elseif key == "space" then + if g.phase == "execution" then g.paused = not g.paused end + elseif key == "return" or key == "kpenter" then + if g.phase == "planning" then g:startDay() end + elseif key == "1" then g.speed = 1 + elseif key == "2" then g.speed = 2 + elseif key == "3" then g.speed = 4 + elseif key == "f1" then + self.showHelp = not self.showHelp + end +end + +function UI:flash(m) self.msg, self.msgT = m, 3 end + +-- hit-test des boutons dessinés dans draw (les rectangles sont enregistrés à chaque frame) +UI.buttons = nil + +function UI:registerButton(id, x, y, w, h) + self.buttons = self.buttons or {} + self.buttons[#self.buttons + 1] = { id = id, x = x, y = y, w = w, h = h } +end + +function UI:clickPanel(mx, my) + if not self.buttons then return false end + for _, b in ipairs(self.buttons) do + if mx >= b.x and mx <= b.x + b.w and my >= b.y and my <= b.y + b.h then + self:handleButton(b.id) + return true + end + end + return false +end + +function UI:handleButton(id) + local g = self.game + local action, arg = id:match("^(%w+):?(.*)$") + if action == "build" then + self.tool = "build" + self.buildKind = arg + elseif action == "tool" then + self.tool = arg + elseif action == "taskup" then + g.tasks:move(tonumber(arg), -1) + elseif action == "taskdown" then + g.tasks:move(tonumber(arg), 1) + elseif action == "taskdel" then + g.tasks:remove(tonumber(arg)) + elseif action == "taskrepair" then + local b = g.buildings:get(tonumber(arg)) + if b then g.tasks:add({ type = "repair", buildingId = b.id, x = b.x, y = b.y }) end + elseif action == "taskbuild" then + local b = g.buildings:get(tonumber(arg)) + if b and not b.built then + g.tasks:add({ type = "build", buildingId = b.id, x = b.x, y = b.y }) + end + elseif action == "taskrecharge" then + g.tasks:add({ type = "recharge" }) + elseif action == "startday" then + g:startDay() + end +end + +-- ---------- RENDU ---------- +function UI:draw() + local g = self.game + local w, h = love.graphics.getDimensions() + self.panelW = math.max(260, math.min(340, w * 0.24)) + self.mapOriginX, self.mapOriginY = 0, 40 + self.buttons = {} + + love.graphics.setBackgroundColor(0.03, 0.04, 0.06) + self:drawMap(w - self.panelW, h) + self:drawTopBar(w) + self:drawPanel(w - self.panelW, 40, self.panelW, h - 40) + if self.showHelp then self:drawHelp(w, h) end + -- message temporaire + if self.msgT > 0 and self.msg then + love.graphics.setColor(0, 0, 0, 0.75) + local tw = love.graphics.getFont():getWidth(self.msg) + 20 + love.graphics.rectangle("fill", (w - self.panelW - tw) / 2, 46, tw, 24, 4, 4) + love.graphics.setColor(1, 0.9, 0.5) + love.graphics.print(self.msg, (w - self.panelW - tw) / 2 + 10, 50) + end +end + +function UI:drawMap(mapW, mapH) + local g = self.game + local t = C.TILE * self.cam.zoom + local x0 = math.floor(self.cam.x) - 1 + local y0 = math.floor(self.cam.y) - 1 + local x1 = x0 + math.ceil(mapW / t) + 2 + local y1 = y0 + math.ceil((mapH - 40) / t) + 2 + + local function sx(tx) return (tx - self.cam.x) * t + self.mapOriginX end + local function sy(ty) return (ty - self.cam.y) * t + self.mapOriginY end + + -- cases + for ty = y0, y1 do + for tx = x0, x1 do + local px, py = sx(tx), sy(ty) + if g.world:isExplored(tx, ty) then + love.graphics.setColor(TILE_COLORS[g.world:getTile(tx, ty)]) + else + love.graphics.setColor(UNEXPLORED) + end + love.graphics.rectangle("fill", px, py, t - 1, t - 1) + -- symboles de terrain (identification par forme aussi) + if g.world:isExplored(tx, ty) and t > 10 then + local terr = g.world:getTile(tx, ty) + love.graphics.setColor(1, 1, 1, 0.55) + if terr == C.T.ROUGH then + love.graphics.line(px + 4, py + t - 5, px + t / 2, py + 5, px + t - 4, py + t - 5) + elseif terr == C.T.ROCK then + love.graphics.polygon("line", px + t/2, py + 4, px + t - 5, py + t - 5, px + 5, py + t - 5) + elseif terr == C.T.ORE then + love.graphics.rectangle("line", px + t/2 - 4, py + t/2 - 4, 8, 8) + love.graphics.rectangle("fill", px + t/2 - 1.5, py + t/2 - 1.5, 3, 3) + elseif terr == C.T.DATA then + love.graphics.circle("line", px + t/2, py + t/2, 5) + love.graphics.circle("fill", px + t/2, py + t/2, 1.5) + end + end + end + end + + -- bâtiments + for _, b in ipairs(g.buildings.list) do + local px, py = sx(b.x), sy(b.y) + local col = BUILD_COLORS[b.kind] or {1, 1, 1} + if b.built then + love.graphics.setColor(col) + else + love.graphics.setColor(col[1], col[2], col[3], 0.35) + end + local m = t * 0.18 + -- forme distincte par bâtiment + if b.kind == "station" then + love.graphics.rectangle("fill", px + m, py + m, t - 2*m, t - 2*m) + love.graphics.setColor(0, 0, 0) + love.graphics.rectangle("fill", px + t/2 - 2, py + t/2 - 2, 4, 4) + elseif b.kind == "solar" then + love.graphics.rectangle("fill", px + m, py + m, t - 2*m, t - 2*m) + love.graphics.setColor(0, 0, 0) + love.graphics.line(px + m, py + t/2, px + t - m, py + t/2) + love.graphics.line(px + t/2, py + m, px + t/2, py + t - m) + elseif b.kind == "battery" then + love.graphics.rectangle("fill", px + m, py + t*0.28, t - 2*m, t*0.44) + love.graphics.rectangle("fill", px + t/2 - 2, py + t*0.18, 4, t*0.1) + elseif b.kind == "extractor" then + love.graphics.circle("fill", px + t/2, py + t/2, (t - 2*m)/2) + love.graphics.setColor(0, 0, 0) + love.graphics.circle("fill", px + t/2, py + t/2, (t - 2*m)/4) + elseif b.kind == "relay" then + love.graphics.polygon("fill", px + t/2, py + m, px + t - m, py + t - m, px + m, py + t - m) + love.graphics.setColor(0, 0, 0) + love.graphics.circle("fill", px + t/2, py + t*0.62, 2.5) + elseif b.kind == "workshop" then + love.graphics.polygon("fill", px + t/2, py + m, px + t - m, py + t/2, px + t/2, py + t - m, px + m, py + t/2) + elseif b.kind == "beacon" then + love.graphics.circle("fill", px + t/2, py + t/2, (t - 2*m)/2) + love.graphics.setColor(1, 1, 1) + love.graphics.circle("line", px + t/2, py + t/2, (t - 2*m)/3.2) + end + -- barre de progression / intégrité + if not b.built then + local def = C.BUILDINGS[b.kind] + love.graphics.setColor(1, 1, 1, 0.8) + love.graphics.rectangle("fill", px + 2, py + t - 4, (t - 4) * (b.progress / def.buildPoints), 2) + elseif b.integrity < C.BUILDINGS[b.kind].integrityMax then + local def = C.BUILDINGS[b.kind] + love.graphics.setColor(0.2, 0.9, 0.3, 0.8) + love.graphics.rectangle("fill", px + 2, py + t - 4, (t - 4) * (b.integrity / def.integrityMax), 2) + end + end + + -- chemin du robot + local r = g.robot + if r.path and r.pathi <= #r.path then + love.graphics.setColor(0.4, 0.9, 0.9, 0.7) + local lx, ly = (r.x - self.cam.x) * t + self.mapOriginX, (r.y - self.cam.y) * t + self.mapOriginY + for i = r.pathi, #r.path do + local n = r.path[i] + local nx, ny = sx(n.x) + t/2, sy(n.y) + t/2 + love.graphics.line(lx, ly, nx, ny) + lx, ly = nx, ny + end + end + + -- robot : cercle cyan avec anneau d'énergie + local rx, ry = (r.x - self.cam.x) * t + self.mapOriginX, (r.y - self.cam.y) * t + self.mapOriginY + if r.state == "stranded" then love.graphics.setColor(0.9, 0.2, 0.2) + else love.graphics.setColor(0.2, 0.95, 0.95) end + love.graphics.circle("fill", rx, ry, t * 0.32) + love.graphics.setColor(0, 0, 0) + love.graphics.circle("fill", rx, ry, t * 0.32 * (1 - r.energy / C.ROBOT.energyMax) * 0.8) + love.graphics.setColor(1, 1, 1) + love.graphics.circle("line", rx, ry, t * 0.32) + + -- sélection + if self.sel then + love.graphics.setColor(1, 1, 1, 0.9) + love.graphics.rectangle("line", sx(self.sel.x), sy(self.sel.y), t, t) + end + + -- voile tempête + if g.weather == C.WEATHER.STORM and g.phase == "execution" then + love.graphics.setColor(0.5, 0.35, 0.15, 0.18) + love.graphics.rectangle("fill", 0, 40, mapW, mapH) + end +end + +local function bar(x, y, w, h, frac, r, g2, b) + love.graphics.setColor(0.15, 0.15, 0.18) + love.graphics.rectangle("fill", x, y, w, h) + love.graphics.setColor(r, g2, b) + love.graphics.rectangle("fill", x, y, w * math.max(0, math.min(1, frac)), h) + love.graphics.setColor(1, 1, 1, 0.4) + love.graphics.rectangle("line", x, y, w, h) +end + +function UI:drawTopBar(w) + local g = self.game + love.graphics.setColor(0.08, 0.09, 0.12) + love.graphics.rectangle("fill", 0, 0, w, 40) + love.graphics.setColor(1, 1, 1) + local hour = g:hourOfDay() + local phaseTxt = g.phase == "planning" and "PLANIFICATION" or "EXÉCUTION" + if g.phase == "execution" and g.paused then phaseTxt = phaseTxt .. " (pause)" end + local txt = ("Jour %d | %s | %02dh%02d | Météo : %s | Prévision J%d : %s"):format( + g.day, phaseTxt, math.floor(hour), math.floor((hour % 1) * 60), + C.WEATHER_NAME[g.weather], g.forecastDay, C.WEATHER_NAME[g.forecast]) + love.graphics.print(txt, 10, 6) + love.graphics.setColor(0.75, 0.75, 0.8) + love.graphics.print(("Énergie %d/%d Minerai %d Données %d"):format( + math.floor(g.res.energy), g.buildings:energyStorage(), + math.floor(g.res.ore), math.floor(g.res.data)), 10, 22) + love.graphics.setColor(0.55, 0.55, 0.6) + love.graphics.print("[F1] Aide [B] Construire [E] Explorer [H] Récolter [Entrée] Lancer la journée [Espace] Pause [1/2/3] Vitesse", + w - self.panelW - 620, 12) +end + +function UI:drawPanel(px, py, pw, ph) + local g = self.game + love.graphics.setColor(0.07, 0.08, 0.11) + love.graphics.rectangle("fill", px, py, pw, ph) + love.graphics.setColor(1, 1, 1, 0.15) + love.graphics.line(px, py, px, py + ph) + local x, y = px + 10, py + 8 + local bw = pw - 20 + + -- Robot + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("ROBOT", x, y); y = y + 16 + love.graphics.setColor(0.7, 0.7, 0.75) + love.graphics.print(("Énergie (%d/%d)"):format(math.floor(g.robot.energy), C.ROBOT.energyMax), x, y) + bar(x, y + 13, bw, 8, g.robot.energy / C.ROBOT.energyMax, 0.2, 0.9, 0.9); y = y + 26 + love.graphics.print(("Usure (%d%%) Charge %d/%d"):format(math.floor(g.robot.wear), g.robot.carrying, C.ROBOT.carryMax), x, y) + bar(x, y + 13, bw, 8, g.robot.wear / 100, 0.9, 0.5, 0.2); y = y + 28 + if g.robot.state == "stranded" then + love.graphics.setColor(1, 0.3, 0.3) + love.graphics.print("IMMOBILISÉ (plus d'énergie)", x, y); y = y + 14 + end + + -- Outils / construction (planification seulement) + if g.phase == "planning" then + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("CONSTRUCTION [Tab] changer", x, y); y = y + 15 + local col = 0 + for _, kind in ipairs(C.BUILD_ORDER) do + local def = C.BUILDINGS[kind] + local bx = x + col * ((bw - 6) / 2) + local byy = y + local w2 = (bw - 6) / 2 + local selected = (self.tool == "build" and self.buildKind == kind) + love.graphics.setColor(selected and {0.3, 0.45, 0.6} or {0.14, 0.16, 0.2}) + love.graphics.rectangle("fill", bx, byy, w2, 26, 3, 3) + love.graphics.setColor(1, 1, 1) + love.graphics.print(def.name, bx + 4, byy + 2, 0, 0.85) + love.graphics.setColor(0.7, 0.7, 0.75) + local cost = {} + if def.cost.ore then cost[#cost + 1] = def.cost.ore .. " min" end + if def.cost.data then cost[#cost + 1] = def.cost.data .. " dat" end + love.graphics.print(table.concat(cost, " "), bx + 4, byy + 14, 0, 0.8) + self:registerButton("build:" .. kind, bx, byy, w2, 26) + col = col + 1 + if col == 2 then col = 0; y = y + 30 end + end + if col ~= 0 then y = y + 30 end + y = y + 4 + -- boutons tâches + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("TÂCHES (cliquer puis cliquer la carte)", x, y); y = y + 15 + local tools = { + { "tool:task_explore", "Explorer [E]", self.tool == "task_explore" }, + { "tool:task_harvest", "Récolter [H]", self.tool == "task_harvest" }, + { "taskrecharge:", "Recharger robot", false }, + } + for i, tb in ipairs(tools) do + local w2 = (bw - 12) / 3 + local bx = x + (i - 1) * (w2 + 6) + love.graphics.setColor(tb[3] and {0.3, 0.45, 0.6} or {0.14, 0.16, 0.2}) + love.graphics.rectangle("fill", bx, y, w2, 20, 3, 3) + love.graphics.setColor(1, 1, 1) + love.graphics.print(tb[2], bx + 4, y + 4, 0, 0.8) + self:registerButton(tb[1], bx, y, w2, 20) + end + y = y + 26 + end + + -- Liste des tâches + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("PROGRAMME DU JOUR", x, y); y = y + 15 + local maxShow = math.floor((py + ph - y - 150) / 16) + for i, t in ipairs(g.tasks.list) do + if i > maxShow then + love.graphics.setColor(0.6, 0.6, 0.65) + love.graphics.print("… (" .. (#g.tasks.list - maxShow) .. " autres)", x, y) + break + end + local stateCol = { pending = {0.8,0.8,0.85}, active = {0.3,0.9,0.9}, done = {0.3,0.8,0.4}, + impossible = {1,0.3,0.3}, interrupted = {1,0.6,0.2} } + love.graphics.setColor(stateCol[t.state] or {1,1,1}) + local label = Tasks.TYPES[t.type] and Tasks.TYPES[t.type].name or t.type + if t.x then label = label .. (" (%d,%d)"):format(t.x, t.y) end + local est = (t.state == "pending") and (" ~" .. Tasks.estimate(t, g.robot, g) .. "s") or "" + love.graphics.print(("%d. %s%s [%s]"):format(i, label, est, t.state), x, y, 0, 0.85) + if g.phase == "planning" then + love.graphics.setColor(0.5, 0.5, 0.55) + love.graphics.print("▲", px + pw - 44, y) + love.graphics.print("▼", px + pw - 30, y) + love.graphics.print("✕", px + pw - 16, y) + self:registerButton("taskup:" .. i, px + pw - 48, y, 14, 14) + self:registerButton("taskdown:" .. i, px + pw - 34, y, 14, 14) + self:registerButton("taskdel:" .. i, px + pw - 20, y, 14, 14) + end + y = y + 16 + end + y = y + 6 + + -- Sélection + if self.sel then + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print(("CASE (%d,%d)"):format(self.sel.x, self.sel.y), x, y); y = y + 14 + love.graphics.setColor(0.7, 0.7, 0.75) + if g.world:isExplored(self.sel.x, self.sel.y) then + love.graphics.print(C.TILE_NAME[g.world:getTile(self.sel.x, self.sel.y)], x, y); y = y + 13 + local b = g.buildings:at(self.sel.x, self.sel.y) + if b then + local def = C.BUILDINGS[b.kind] + love.graphics.setColor(1, 1, 1) + love.graphics.print(def.name .. (b.built and "" or " (plan)"), x, y); y = y + 13 + love.graphics.setColor(0.7, 0.7, 0.75) + love.graphics.print(def.desc, x, y, 0, 0.8); y = y + 13 + love.graphics.print(("Intégrité %d/%d"):format(math.floor(b.integrity), def.integrityMax), x, y); y = y + 13 + if not b.built then + love.graphics.print(("Construction %d%%"):format(math.floor(100 * b.progress / def.buildPoints)), x, y); y = y + 13 + if g.phase == "planning" then + love.graphics.setColor(0.2, 0.4, 0.3) + love.graphics.rectangle("fill", x, y, bw, 16, 3, 3) + love.graphics.setColor(1, 1, 1) + love.graphics.print("+ tâche construire", x + 4, y + 2, 0, 0.85) + self:registerButton("taskbuild:" .. b.id, x, y, bw, 16) + y = y + 20 + end + elseif b.integrity < def.integrityMax and g.phase == "planning" then + love.graphics.setColor(0.2, 0.4, 0.3) + love.graphics.rectangle("fill", x, y, bw, 16, 3, 3) + love.graphics.setColor(1, 1, 1) + love.graphics.print("+ tâche réparer", x + 4, y + 2, 0, 0.85) + self:registerButton("taskrepair:" .. b.id, x, y, bw, 16) + y = y + 20 + end + end + else + love.graphics.print("Secteur inconnu", x, y); y = y + 13 + end + y = y + 4 + end + + -- Objectifs + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("OBJECTIFS", x, y); y = y + 14 + for _, o in ipairs(C.OBJECTIVES) do + local done = g.objectivesDone[o.id] + love.graphics.setColor(done and {0.3, 0.8, 0.4} or {0.7, 0.7, 0.75}) + love.graphics.print((done and "☑ " or "☐ ") .. o.text, x, y, 0, 0.85) + y = y + 13 + end + y = y + 6 + + -- Notifications récentes + love.graphics.setColor(0.9, 0.9, 0.95) + love.graphics.print("NOTIFICATIONS", x, y); y = y + 14 + love.graphics.setColor(0.6, 0.65, 0.7) + local shown = 0 + for i = #g.notifications, 1, -1 do + if shown >= 5 then break end + love.graphics.print("• " .. g.notifications[i].msg, x, y, 0, 0.8) + y = y + 12 + shown = shown + 1 + end + + -- Bouton lancer la journée + if g.phase == "planning" then + local byy = py + ph - 34 + love.graphics.setColor(0.25, 0.55, 0.3) + love.graphics.rectangle("fill", x, byy, bw, 26, 4, 4) + love.graphics.setColor(1, 1, 1) + love.graphics.print("▶ LANCER LA JOURNÉE [Entrée]", x + 30, byy + 6) + self:registerButton("startday:", x, byy, bw, 26) + end +end + +function UI:drawHelp(w, h) + love.graphics.setColor(0, 0, 0, 0.88) + love.graphics.rectangle("fill", w/2 - 320, h/2 - 230, 640, 460, 8, 8) + love.graphics.setColor(1, 1, 1) + local lines = { + "AIDE — Outpost Remote", + "", + "Les communications sont impossibles en temps réel : vous préparez le", + "programme du robot le matin, puis vous observez son exécution.", + "", + "SOURIS", + " Clic gauche : sélectionner / appliquer l'outil courant", + " Clic droit glissé : déplacer la caméra", + " Molette : zoomer / dézoomer", + "CLAVIER", + " Flèches / ZQSD : déplacer la caméra", + " B : outil construction (Tab : changer de bâtiment)", + " E / H : outil exploration / récolte", + " Échap : retour à l'outil sélection", + " Entrée : lancer la journée (phase planification)", + " Espace : pause (phase exécution)", + " 1 / 2 / 3 : vitesse de simulation", + " F5 / F9 : sauvegarder / charger (menu principal)", + "", + "BOUCLE : planifiez (plans, tâches) → lancez la journée → observez", + "→ le robot construit, explore, récolte, se recharge → nouvelle fenêtre.", + "Le minerai récolté à la main doit être ramené à la station (tâche recharge).", + "Attention : froid = +consommation, tempête = -solaire, -vitesse, usure.", + "", + "F1 : fermer", + } + for i, l in ipairs(lines) do + love.graphics.print(l, w/2 - 300, h/2 - 215 + (i - 1) * 17) + end +end + +return UI diff --git a/src/weather.lua b/src/weather.lua new file mode 100644 index 0000000..69d86fa --- /dev/null +++ b/src/weather.lua @@ -0,0 +1,29 @@ +-- Système météo déterministe par graine + jour. Logique pure. +local Noise = require("src.noise") +local C = require("src.config") + +local Weather = {} + +-- Météo réelle d'un jour, déterministe. +function Weather.forDay(seed, day) + local r = Noise.value(seed + 101, day, 7) + local trend = Noise.smooth2(seed + 77, day, 0, 3) -- persistance locale + if r < 0.18 + trend * 0.08 then return C.WEATHER.STORM end + if r > 0.80 - trend * 0.05 then return C.WEATHER.COLD end + return C.WEATHER.CLEAR +end + +-- Prévision communiquée la veille : exacte avec probabilité (1 - FORECAST_ERROR), +-- sinon remplacée par une autre météo (mais jamais totalement aléatoire : déterministe). +function Weather.forecast(seed, day) + local real = Weather.forDay(seed, day) + local err = Noise.value(seed + 555, day, 3) < C.FORECAST_ERROR + if not err then return real, true end + -- prévision erronée : on annonce une météo voisine déterministe + local alt = Noise.value(seed + 556, day, 3) + if real == C.WEATHER.STORM then return (alt < 0.5) and C.WEATHER.CLEAR or C.WEATHER.COLD, false end + if real == C.WEATHER.COLD then return C.WEATHER.CLEAR, false end + return (alt < 0.5) and C.WEATHER.COLD or C.WEATHER.STORM, false +end + +return Weather diff --git a/src/world.lua b/src/world.lua new file mode 100644 index 0000000..e34ea74 --- /dev/null +++ b/src/world.lua @@ -0,0 +1,114 @@ +-- Monde procédural infini, généré par chunks à la demande, à partir d'une graine. +-- Logique pure (testable sans love.graphics). +local Noise = require("src.noise") +local C = require("src.config") + +local World = {} +World.__index = World + +function World.new(seed) + return setmetatable({ + seed = seed, + chunks = {}, -- "cx,cy" -> { tiles = { [i]=terrain } } + explored = {}, -- "x,y" -> true + scouted = {}, -- "x,y" -> true (visible mais contenu approximatif : ici inutilisé, exploration = révélation) + exploredCount = 0, + }, World) +end + +local function key(x, y) return x .. "," .. y end +World.key = key + +-- Génère (ou récupère) le chunk contenant les cases globales. +function World:getChunk(cx, cy) + local k = key(cx, cy) + local ch = self.chunks[k] + if ch then return ch end + ch = { tiles = {} } + local size = C.CHUNK_SIZE + for ly = 0, size - 1 do + for lx = 0, size - 1 do + local x, y = cx * size + lx, cy * size + ly + ch.tiles[ly * size + lx + 1] = self:genTile(x, y) + end + end + self.chunks[k] = ch + return ch +end + +-- Terrain d'une case, déterministe. +function World:genTile(x, y) + local s = self.seed + -- noyau de départ garanti praticable (station en 0,0) + if math.abs(x) <= 2 and math.abs(y) <= 2 then + local r0 = Noise.value(s + 37, x, y) + if r0 < 0.05 then return C.T.ORE end + return C.T.PLAIN + end + -- zones de relief pour plaines / difficile / rochers + local e = Noise.smooth2(s + 11, x, y, 18) + local e2 = Noise.smooth2(s + 23, x, y, 6) + local r = Noise.value(s + 37, x, y) + if e > 0.78 then return C.T.ROCK end + if r < 0.035 then return C.T.ORE end + if r > 0.965 then return C.T.DATA end + if e2 > 0.72 then return C.T.ROUGH end + return C.T.PLAIN +end + +function World:getTile(x, y) + local size = C.CHUNK_SIZE + local cx, cy = math.floor(x / size), math.floor(y / size) + local lx, ly = x - cx * size, y - cy * size + return self:getChunk(cx, cy).tiles[ly * size + lx + 1] +end + +function World:exploreRadius(cx, cy, radius) + for dy = -radius, radius do + for dx = -radius, radius do + if dx * dx + dy * dy <= radius * radius then + local k = key(cx + dx, cy + dy) + if not self.explored[k] then + self.explored[k] = true + self.exploredCount = self.exploredCount + 1 + end + end + end + end +end + +function World:isExplored(x, y) + return self.explored[key(x, y)] == true +end + +function World:cost(x, y, blocker) + if blocker and blocker(x, y) then return math.huge end + return C.TILE_COST[self:getTile(x, y)] +end + +function World:serializeExplored() + local list = {} + for k in pairs(self.explored) do list[#list + 1] = k end + return table.concat(list, ";") +end + +function World:loadExplored(str) + self.explored, self.exploredCount = {}, 0 + if str and str ~= "" then + for k in str:gmatch("[^;]+") do + self.explored[k] = true + self.exploredCount = self.exploredCount + 1 + end + end +end + +-- Ne garde en mémoire que les chunks proches du robot / caméra (économie mémoire). +function World:prune(keepKeys) + local keep = {} + for _, k in ipairs(keepKeys) do keep[k] = true end + for k in pairs(self.chunks) do + if not keep[k] then self.chunks[k] = nil end + end +end + +return World diff --git a/tests/run_tests.lua b/tests/run_tests.lua new file mode 100644 index 0000000..768ac74 --- /dev/null +++ b/tests/run_tests.lua @@ -0,0 +1,261 @@ +-- Tests de logique pure, exécutables avec : lua tests/run_tests.lua (ou luajit) +-- Depuis la racine du projet. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failures, passed = 0, 0 +local function ok(cond, name) + if cond then passed = passed + 1 + else failures = failures + 1; print("ÉCHEC : " .. name) end +end + +local C = require("src.config") +local Noise = require("src.noise") +local World = require("src.world") +local Pathfind = require("src.pathfind") +local Weather = require("src.weather") +local Buildings = require("src.buildings") +local Robot = require("src.robot") +local Tasks = require("src.tasks") +local Game = require("src.game") +local Save = require("src.save") + +-- 1. Génération déterministe +do + local w1, w2, w3 = World.new(42), World.new(42), World.new(43) + local same = true + for y = -30, 30 do for x = -30, 30 do + if w1:getTile(x, y) ~= w2:getTile(x, y) then same = false end + end end + ok(same, "monde déterministe : même graine = même terrain") + local diff = false + for y = -30, 30 do for x = -30, 30 do + if w1:getTile(x, y) ~= w3:getTile(x, y) then diff = true; break end + end if diff then break end end + ok(diff, "monde déterministe : graine différente = terrain différent") + -- chunks générés à la demande + local w = World.new(7) + ok(next(w.chunks) == nil, "aucun chunk pré-généré") + w:getTile(0, 0) + ok(next(w.chunks) ~= nil, "chunk généré à la demande") +end + +-- 2. Météo reproductible et prévision pas arbitraire +do + ok(Weather.forDay(99, 5) == Weather.forDay(99, 5), "météo reproductible") + local exact, approx = 0, 0 + for d = 1, 50 do + local f = Weather.forecast(99, d) + if f == Weather.forDay(99, d) then exact = exact + 1 end + if f == C.WEATHER.CLEAR or f == C.WEATHER.COLD or f == C.WEATHER.STORM then approx = approx + 1 end + end + ok(exact >= 35, "prévision majoritairement exacte (" .. exact .. "/50)") + ok(approx == 50, "prévision toujours une météo valide") +end + +-- 3. Recherche de chemin : évite les obstacles, tient compte des coûts +do + -- grille 10x10 avec un mur vertical en x=5 (sauf passage en y=9) + local function cost(x, y) + if x < 0 or x > 9 or y < 0 or y > 9 then return math.huge end + if x == 5 and y ~= 9 then return math.huge end + return 1 + end + local p = Pathfind.find(0, 0, 9, 0, cost) + ok(p ~= nil, "A* trouve un chemin autour du mur") + local throughGap = false + for _, n in ipairs(p) do if n.x == 5 and n.y == 9 then throughGap = true end end + ok(throughGap, "A* passe par l'unique ouverture") + local p2 = Pathfind.find(0, 0, 9, 0, function(x, y) return math.huge end) + ok(p2 == nil, "A* renvoie nil si tout est bloqué") + -- coûts : préfère contourner une zone chère si plus court en coût + local function costly(x, y) + if x < 0 or x > 9 or y < 0 or y > 9 then return math.huge end + if y == 0 and x > 0 and x < 9 then return 100 end -- ligne directe très chère + return 1 + end + local p3 = Pathfind.find(0, 0, 9, 0, costly) + local expensive = 0 + for _, n in ipairs(p3) do if n.y == 0 and n.x > 0 and n.x < 9 then expensive = expensive + 1 end end + ok(expensive == 0, "A* évite les cases à coût élevé") +end + +-- 4. Placement de bâtiments : règles et coûts +do + local g = Game.new(1234) + local res = { energy = 0, ore = 100, data = 200 } + -- non exploré -> refus + local okk, reason = g.buildings:canPlace("solar", 50, 50, g.world, res) + ok(not okk and reason:match("explor"), "refus sur case inexplorée : " .. tostring(reason)) + -- exploré plaine -> ok + local okk2 = g.buildings:canPlace("solar", 1, 1, g.world, res) + ok(okk2, "panneau solaire posable sur plaine explorée") + -- extracteur nécessite gisement : trouver un gisement exploré + g.world:exploreRadius(0, 0, 30) + local oreX, oreY + for y = -30, 30 do for x = -30, 30 do + if g.world:getTile(x, y) == C.T.ORE and not oreX and not g.buildings:at(x, y) + and g.world:isExplored(x, y) then oreX, oreY = x, y end + end end + ok(oreX ~= nil, "gisement trouvé à proximité") + local okk3, r3 = g.buildings:canPlace("extractor", 2, 2, g.world, res) + ok(not okk3, "extracteur refusé hors gisement") + local okk4, r4 = g.buildings:canPlace("extractor", oreX, oreY, g.world, res) + ok(okk4, "extracteur accepté sur gisement (" .. tostring(oreX) .. "," .. tostring(oreY) .. " : " .. tostring(r4) .. ")") + -- coût prélevé + local before = res.ore + g.buildings:place("solar", 1, 1, res) + ok(res.ore == before - C.BUILDINGS.solar.cost.ore, "coût de construction prélevé") + -- minerai insuffisant + local okk5 = g.buildings:canPlace("workshop", 2, 1, g.world, { ore = 0, data = 0 }) + ok(not okk5, "refus si minerai insuffisant") + -- distance relais + g.buildings:place("relay", -5, -5, nil) + local okk6, r6 = g.buildings:canPlace("relay", -4, -5, g.world, res) + ok(not okk6, "relais refusé trop proche d'un autre relais") + -- balise unique + g.buildings:place("beacon", 3, 3, nil) + local okk7 = g.buildings:canPlace("beacon", 4, 4, g.world, res) + ok(not okk7, "balise unique : second plan refusé") +end + +-- 5. Production des bâtiments +do + local g = Game.new(777) + local res = { energy = 10, ore = 0, data = 0 } + local s = g.buildings:place("solar", 1, 1, nil); s.built = true + g.buildings:simulate(60, res, C.WEATHER.CLEAR, g) + ok(res.energy > 10 + 30, "solaire produit de l'énergie par temps normal") + local eClear = res.energy + res.energy = 10 + g.buildings:simulate(60, res, C.WEATHER.STORM, g) + ok(res.energy < 10 + (eClear - 10) * 0.5, "solaire réduit en tempête") + -- extracteur : produit du minerai, consomme de l'énergie + res.energy = 50 + local ex = g.buildings:place("extractor", 2, 2, nil); ex.built = true + g.buildings:simulate(60, res, C.WEATHER.CLEAR, g) + ok(res.ore > 0, "extracteur produit du minerai") + -- batterie augmente le stockage + local cap0 = g.buildings:energyStorage() + local bt = g.buildings:place("battery", 3, 3, nil); bt.built = true + ok(g.buildings:energyStorage() == cap0 + C.BUILDINGS.battery.storageBonus, + "batterie augmente la capacité de stockage") +end + +-- 6. Planification : ajout, suppression, réorganisation, validation, sérialisation +do + local g = Game.new(55) + local t = g.tasks + t:add({ type = "explore", x = 5, y = 5 }) + t:add({ type = "recharge" }) + t:add({ type = "explore", x = 6, y = 6 }) + ok(#t.list == 3, "ajout de tâches") + t:move(3, -1) + ok(t.list[2].x == 6, "réorganisation des tâches") + t:remove(1) + ok(#t.list == 2 and t.list[1].x == 6, "suppression de tâche") + -- validation : tâche de construction sans plan -> impossible + t:add({ type = "build", buildingId = 999 }) + t:validate(g) + ok(t.list[3].state == "impossible", "tâche manifestement impossible identifiée") + -- sérialisation aller-retour + local s = t:serialize() + local t2 = Tasks.new() + t2:deserialize(s) + ok(#t2.list == 3 and t2.list[1].x == 6 and t2.list[2].type == "recharge", + "sérialisation des tâches aller-retour") + -- estimation présente + ok(Tasks.estimate({ type = "explore", x = 10, y = 0 }, g.robot, g) > 0, "estimation de durée") +end + +-- 7. Robot : déplacement, énergie, sérialisation +do + local g = Game.new(88) + local r = g.robot + local okSet = r:setDestination(3, 0, g:costFn()) + ok(okSet, "destination atteignable acceptée") + local arrived = false + for i = 1, 5000 do + local res = r:updateMove(0.05, g.world, C.WEATHER.CLEAR, g:costFn()) + if res == true then arrived = true; break end + if res == "stranded" then break end + end + ok(arrived, "le robot rejoint sa destination automatiquement") + ok(r.energy < C.ROBOT.energyMax, "le déplacement consomme de l'énergie") + ok(r.wear > 0, "le déplacement use le robot") + -- épuisement -> immobilisation + r.energy = 0.01 + r:setDestination(30, 30, g:costFn()) + local stranded = false + for i = 1, 5000 do + if r:updateMove(0.05, g.world, C.WEATHER.CLEAR, g:costFn()) == "stranded" then stranded = true; break end + end + ok(stranded, "robot à court d'énergie = immobilisé") + -- sérialisation + local s = r:serialize() + local r2 = Robot.deserialize(s) + ok(math.abs(r2.x - r.x) < 0.001 and math.abs(r2.wear - r.wear) < 0.01, "robot sérialisation aller-retour") +end + +-- 8. Sauvegarde complète aller-retour (sans love.filesystem) +do + local g = Game.new(2024) + g.res.ore = 77 + g.world:exploreRadius(10, 10, 4) + local b = g.buildings:place("solar", 1, 1, nil) + b.progress = 12 + g.tasks:add({ type = "build", buildingId = b.id, x = 1, y = 1 }) + g.objectivesDone["solar1"] = true + local text = Save.encode(g) + local g2 = Save.decode(text, Game) + ok(g2 ~= nil, "décodage de la sauvegarde") + ok(g2.seed == g.seed and g2.day == g.day, "graine et jour conservés") + ok(g2.res.ore == 77, "ressources conservées") + ok(g2.world.exploredCount == g.world.exploredCount, "zones explorées conservées") + local b2 = g2.buildings:get(b.id) + ok(b2 and b2.kind == "solar" and math.abs(b2.progress - 12) < 0.01, "plans de construction conservés") + ok(#g2.tasks.list == 1 and g2.tasks.list[1].type == "build", "tâches restantes conservées") + ok(g2.objectivesDone["solar1"] == true, "progression des objectifs conservée") + ok(g2.station ~= nil, "station présente après chargement") +end + +-- 9. Boucle de journée : exécution d'une tâche de construction de bout en bout +do + local g = Game.new(31415) + g.res.ore = 100 + local b = g.buildings:place("solar", 1, 0, g.res) + g.tasks:add({ type = "build", buildingId = b.id, x = 1, y = 0 }) + g:startDay() + ok(g.phase == "execution", "passage en phase exécution") + for i = 1, 6000 do + g:update(1 / 30) + if b.built or g.phase == "planning" then break end + end + ok(b.built, "le robot construit le bâtiment progressivement") + ok(g.tasks.list[1].state == "done", "tâche marquée terminée") + -- fin de journée -> retour en planification, jour +1 + local day0 = g.day + for i = 1, 30000 do + g:update(1 / 30) + if g.phase == "planning" then break end + end + ok(g.phase == "planning" and g.day == day0 + 1, "journée terminée, jour suivant planifiable") +end + +-- 10. Météo impacte la stratégie (froid augmente la consommation du robot) +do + local g = Game.new(606) + local function drainFor(weather) + local r = Robot.new(0.5, 0.5) + r:setDestination(5, 0, g:costFn()) + for i = 1, 4000 do + local res = r:updateMove(0.05, g.world, weather, g:costFn()) + if res == true or res == "stranded" then break end + end + return C.ROBOT.energyMax - r.energy + end + local dClear, dCold = drainFor(C.WEATHER.CLEAR), drainFor(C.WEATHER.COLD) + ok(dCold > dClear * 1.5, ("froid intense : consommation accrue (%.1f vs %.1f)"):format(dCold, dClear)) +end + +print(("\n%d tests réussis, %d échecs."):format(passed, failures)) +os.exit(failures == 0 and 0 or 1) diff --git a/tests/runner_love.lua b/tests/runner_love.lua new file mode 100644 index 0000000..6962594 --- /dev/null +++ b/tests/runner_love.lua @@ -0,0 +1,31 @@ +-- Permet de lancer les tests avec LÖVE : love . --tests +-- Ce fichier est chargé par main.lua quand l'argument --tests est présent. +-- Il exécute tests/run_tests.lua dans l'environnement courant puis quitte. + +local oldPrint = print +local lines = {} +print = function(...) + local parts = {} + for i = 1, select("#", ...) do parts[#parts + 1] = tostring(select(i, ...)) end + lines[#lines + 1] = table.concat(parts, "\t") + oldPrint(...) +end + +-- os.exit ne fonctionne pas sous love : on intercepte +local exitCode = 0 +os.exit = function(code) exitCode = code or 0; error({ __testDone = true }) end + +local chunk, err = loadfile("tests/run_tests.lua") +if not chunk then + oldPrint("Impossible de charger les tests : " .. tostring(err)) + love.event.quit(1) + return +end +local ok, e = pcall(chunk) +if not ok and not (type(e) == "table" and e.__testDone) then + oldPrint("Erreur pendant les tests : " .. tostring(e)) + love.event.quit(1) + return +end +oldPrint(table.concat(lines, "\n")) +love.event.quit(exitCode)