first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+102
@@ -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
|
||||
+324
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+112
@@ -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
|
||||
+102
@@ -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
|
||||
+108
@@ -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
|
||||
+617
@@ -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
|
||||
@@ -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
|
||||
+114
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user