first commit

This commit is contained in:
2026-07-21 16:28:15 +02:00
commit 75e32cffa3
17 changed files with 2487 additions and 0 deletions
+109
View File
@@ -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.
+122
View File
@@ -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.
+11
View File
@@ -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
+167
View File
@@ -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
+177
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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
+261
View File
@@ -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)
+31
View File
@@ -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)