Really working version
authorBrendan Hansen <brendan.f.hansen@gmail.com>
Wed, 13 Mar 2019 05:12:22 +0000 (00:12 -0500)
committerBrendan Hansen <brendan.f.hansen@gmail.com>
Wed, 13 Mar 2019 05:12:22 +0000 (00:12 -0500)
conf.lua
main.lua
src/genetics.lua
src/neuralnet.lua
src/world.lua

index f2ca2fbcdd2722746ea3a59b926758a922fcdf7b..c66ef232b619432223307d30665824e5921e20a4 100644 (file)
--- a/conf.lua
+++ b/conf.lua
@@ -51,8 +51,10 @@ return {
        ENEMY_COLOR = { 1.0, 0.0, 0.0 };
        BULLET_COLOR = { 0.6, 0.6, 1.0 };
 
-       PLAYER_VISION_SEGMENTS = 16;
+       PLAYER_VISION_SEGMENTS = 32;
        PLAYER_VISION_DISTANCE = 20;
 
-       ENEMY_SIZE = 20;
+       ENEMY_SIZE = 14;
+
+       MAX_NEURONS = 1024;
 }
index 3d0ad3b5ed6291cedbf958a6ec026c07989cd0ae..ce94f6d25b2c97a4f6513878b11b9f26c2f52381 100644 (file)
--- a/main.lua
+++ b/main.lua
@@ -14,11 +14,17 @@ local input
 local pop
 local pop_update
 
-local enemy_spawn = { 100, 100 }
-local enemy
+local update_speed = 30
+
+local fitness_font
+
+local stored_fitnesses = {}
+
+local enemies = {}
 function love.load()
        world, player = World.new()
-       enemy = Enemy.new(unpack(enemy_spawn))
+       local enemy = Enemy.new(0, 0)
+       table.insert(enemies, enemy)
        world:add_entity(enemy)
 
        local wall1 = Wall.new(-20, -20, 840, 20)
@@ -33,10 +39,11 @@ function love.load()
        input = Input:new()
 
        pop = Population.new()
-       pop:create_genomes(100, 16, 8)
+       pop:create_genomes(96, 16, 8)
        pop_update = pop:evolve()
 
        love.graphics.setBackgroundColor(CONF.BACK_COLOR)
+       fitness_font = love.graphics.newFont(24)
 end
 
 function love.keypressed(key)
@@ -47,58 +54,193 @@ function love.keyreleased(key)
        input:keyup(key)
 end
 
+local function get_random_pos()
+       local x = math.random(100) + math.random(100) + 600 * (math.random(2) - 1)
+       local y = math.random(100) + math.random(100) + 500 * (math.random(2) - 1)
+       return x, y
+end
+
 local function network_input(ins, dt)
        player.alive = true
-       for _, v in ipairs(ins) do
-               --print(v)
-       end
-       if ins[1] > 0.5 then input:keydown("w") else input:keyup("w") end
-       if ins[2] > 0.5 then input:keydown("s") else input:keyup("s") end
-       if ins[3] > 0.5 then input:keydown("a") else input:keyup("a") end
-       if ins[4] > 0.5 then input:keydown("d") else input:keyup("d") end
-       if ins[5] > 0.5 then input:keydown("left") else input:keyup("left") end
-       if ins[6] > 0.5 then input:keydown("right") else input:keyup("right") end
-       if ins[7] > 0.5 then input:keydown("up") else input:keyup("up") end
-       if ins[8] > 0.5 then input:keydown("down") else input:keyup("down") end
-
+       if ins[1] > 0.35 then input:keydown("w") else input:keyup("w") end
+       if ins[2] > 0.35 then input:keydown("s") else input:keyup("s") end
+       if ins[3] > 0.35 then input:keydown("a") else input:keyup("a") end
+       if ins[4] > 0.35 then input:keydown("d") else input:keyup("d") end
+       if ins[5] > 0.35 then input:keydown("left") else input:keyup("left") end
+       if ins[6] > 0.35 then input:keydown("right") else input:keyup("right") end
+       if ins[7] > 0.35 then input:keydown("up") else input:keyup("up") end
+       if ins[8] > 0.35 then input:keydown("down") else input:keyup("down") end
+
+       local last_x = player.x
+       local last_y = player.y
 
        world:update(dt, input)
 
-       local fitness = 0.5
+       local fitness = math.sqrt(math.sqrDist(last_x, last_y, player.x, player.y))
+       fitness = fitness - (player.shot and 1 or 0)
+
+       local enemies_alive = 0
+       for _, v in ipairs(enemies) do
+               if v.alive then
+                       enemies_alive = enemies_alive + 1
+               else
+                       if not v.__tagged then
+                               v.__tagged = true
+                               fitness = fitness + 400
+                       end
+               end
+       end
 
-       if not player.alive or not enemy.alive then
-               world:remove_entity(enemy)
-               enemy = Enemy.new(math.random(800), math.random(600))
-               world:add_entity(enemy)
+       if not player.alive or enemies_alive == 0 then
+               for _, v in ipairs(enemies) do
+                       world:remove_entity(v)
+               end
 
-               player.x = 400
-               player.y = 300
+               enemies = {}
 
-               if not enemy.alive then
-                       fitness = fitness + 1000
+               for _ = 1, math.ceil((pop.generation + 1) / 10) do
+                       local enemy = Enemy.new(get_random_pos())
+                       world:add_entity(enemy)
+                       table.insert(enemies, enemy)
+               end
+
+               if player.alive then
+                       fitness = fitness + 2000
+               else
+                       player.x = 400
+                       player.y = 300
                end
        end
 
        return fitness, player.alive
 end
 
+local function generation_step(avg_fitness, _, _)
+       table.insert(stored_fitnesses, avg_fitness)
+end
+
 function love.update(dt)
        if love.keyboard.isDown "escape" then
                love.event.quit()
        end
 
-       for _ = 1, 30 do
+       if love.keyboard.isDown "z" then
+               update_speed = update_speed - 1
+               if update_speed < 1 then
+                       update_speed = 1
+               end
+       end
+
+       if love.keyboard.isDown "x" then
+               update_speed = update_speed + 1
+               if update_speed > 60 then
+                       update_speed = 60
+               end
+       end
+
+       for _ = 1, update_speed do
                local dists = player:get_distances(world)
-               pop_update = pop_update(dists, network_input, dt)
+
+               local inputs = {}
+               for i = 1, 16 do
+                       local v1 = dists[i * 2]
+                       local v2 = dists[(i * 2 + 1) % 32]
+                       local v3 = dists[(i * 2 - 1) % 32]
+
+                       inputs[i] = 1 - ((0.5 * v1 + 0.25 * v2 + 0.25 * v3) / (CONF.ENEMY_SIZE * CONF.PLAYER_VISION_DISTANCE))
+               end
+
+               pop_update = pop_update(inputs, network_input, generation_step, dt)
        end
 end
 
+local function plot_fitness(x, y, scale)
+       love.graphics.push()
+       love.graphics.translate(x, y)
+       love.graphics.scale(scale, scale)
+
+       love.graphics.setColor(0, 0, 0, 0.4)
+       love.graphics.rectangle("fill", -20, -20, 440, 240)
+
+       love.graphics.setFont(fitness_font)
+       love.graphics.setColor(1, 1, 1)
+
+       love.graphics.printf("Average fitness: " .. math.floor(pop.avg_fitness), 0, 0, 400, "left")
+       love.graphics.printf("Highest fitness: " .. math.floor(pop.high_fitness), 0, 20, 400, "left")
+
+       local highest = 0
+       for _, v in ipairs(stored_fitnesses) do
+               if v > highest then
+                       highest = v
+               end
+       end
+
+       local width = 400 / (#stored_fitnesses)
+
+       love.graphics.setColor(0, 0, 1)
+       for i, v in ipairs(stored_fitnesses) do
+               if v < 0 then
+                       v = 0
+               end
+               love.graphics.circle("fill", (i - 1) * width, 200 - v * 100 / highest, 8)
+       end
+
+       love.graphics.pop()
+end
+
+local function draw_network(net, x, y, scale)
+       if net == nil then return end
+
+       love.graphics.push()
+       love.graphics.translate(x, y)
+       love.graphics.scale(scale, scale)
+
+       love.graphics.setColor(0, 0, 0, 0.4)
+       love.graphics.rectangle("fill", -20, -20, 680, 600)
+
+       love.graphics.setColor(1, 1, 1)
+
+       for _, v in pairs(net.neurons) do
+               love.graphics.rectangle("fill", v.x, v.y, 24, 24)
+       end
+
+       for _, neuron in pairs(net.neurons) do
+               local ins = neuron.inputs
+
+               local x1 = neuron.x + 12
+               local y1 = neuron.y + 12
+               for _, conn in pairs(ins) do
+                       local other = net.neurons[conn.from]
+                       local x2 = other.x + 12
+                       local y2 = other.y + 12
+
+                       local col = { 1, 0, 0 }
+                       if conn.weight > 0 then
+                               col = { 0, 0, 1 }
+                       end
+
+                       love.graphics.setColor(col)
+                       love.graphics.setLineWidth(math.sigmoid(conn.weight) * 2)
+                       love.graphics.line(x1, y1, x2, y2)
+               end
+       end
+
+       love.graphics.setLineWidth(2)
+       love.graphics.pop()
+end
+
 function love.draw()
        world:draw()
 
        love.graphics.setColor(0, 0, 0)
-       --love.graphics.printf(tostring(love.timer.getFPS()) .. " FPS", 0, 0, 800, "center")
+       love.graphics.printf(tostring(love.timer.getFPS()) .. " FPS", 0, 0, 800, "left")
+       love.graphics.printf("Generation: " .. pop.generation, 0, 32, 800, "left")
+       love.graphics.printf("Genome: " .. pop.current_genome, 0, 64, 800, "left")
        if pop.genomes[pop.current_genome] ~= nil then
-               love.graphics.printf(pop.generation .. " Generation | " .. pop.current_genome .. " Genome | " .. (pop.genomes[pop.current_genome].fitness) .. " Fitness", 0, 0, 800, "center")
+               love.graphics.printf("Fitness: " .. math.floor(pop.genomes[pop.current_genome].fitness), 0, 96, 800, "left")
+
+               draw_network(pop.genomes[pop.current_genome].network, 580, 0, 1 / 3)
        end
+
+       plot_fitness(250, 0, 3 / 4)
 end
index 36fbfec12068a655fe6dd8b7b3a264317254d086..68bb5bba929f484565f7699f938d946e425cb008 100644 (file)
@@ -1,10 +1,11 @@
 local NN = require "src.neuralnet"
+local conf = require "conf"
 local NeuralNetwork = NN.NeuralNetwork
 
 -- Globals
 local Starting_Weights_Chance = 0.25
 local Starting_Connection_Chance = 2.0
-local Starting_Bias_Chance = 0.4
+local Starting_Bias_Chance = 0.2
 local Starting_Split_Chance = 0.5
 local Starting_Enable_Chance = 0.2
 local Starting_Disable_Chance = 0.4
@@ -12,6 +13,8 @@ local Starting_Disable_Chance = 0.4
 local Reset_Weight_Chance = 0.9
 local Crossover_Chance = 0.75
 
+local MAX_NEURONS = conf.MAX_NEURONS
+
 -- Need a global-ish innovation number, since that depends on the whole training, not just a single genome
 local Current_Innovation = 1
 
@@ -54,6 +57,7 @@ end
 
 -- Genome class --
 
+
 local Genome = {}
 local Genome_mt = { __index = Genome }
 
@@ -64,7 +68,7 @@ function Genome.new(inputs, outputs)
                genes = {};
                fitness = 0;
                network = {}; -- Neural Network
-               high_neuron = inputs + outputs + 1; -- Highest numbered neuron in the genome
+               high_neuron = inputs + 1; -- Highest numbered neuron in the genome
 
                mutations = { -- The different chances of mutating a particular part of the genome
                        ["weights"] = Starting_Weights_Chance; -- Chance of changing the weights
@@ -81,6 +85,8 @@ function Genome.new(inputs, outputs)
 end
 
 function Genome:add_gene(from, to, weight)
+       if from > to then return end
+
        local gene = Gene.new()
        gene.weight = weight
        gene.from = from
@@ -110,8 +116,6 @@ function Genome:create_network()
 
        for i = 1, #self.genes do
                local gene = self.genes[i]
-               print("----------------------")
-               print(gene.innovation, gene.from, gene.to, gene.weight)
 
                if gene.enabled then
                        if not net:has_neuron(gene.to) then
@@ -133,8 +137,7 @@ function Genome:has_gene(from, to)
        for i = 1, #self.genes do
                local gene = self.genes[i]
 
-               if (gene.to == to and gene.from == from)
-                       or (gene.to == from and gene.from == to) then
+               if gene.to == to and gene.from == from then
                        return true
                end
        end
@@ -171,8 +174,15 @@ function Genome:mutate_connections(connect_to_bias)
                return
        end
 
+       -- Must go to a bigger neuron
+       if neuron2 < neuron1 then
+               local tmp = neuron1
+               neuron1 = neuron2
+               neuron2 = tmp
+       end
+
        -- Output cant be input
-       if (neuron1 > self.num_inputs and neuron1 <= self.num_inputs + self.num_outputs) then
+       if neuron1 >= MAX_NEURONS - self.num_outputs then
                return
        end
 
@@ -191,7 +201,6 @@ function Genome:mutate_connections(connect_to_bias)
        end
 
        local weight = math.random() * 4 - 2
-       assert(neuron1 ~= neuron2, "IN MUTATE CONNECTIONS")
        self:add_gene(neuron1, neuron2, weight)
 end
 
@@ -214,23 +223,38 @@ function Genome:mutate_neuron()
        -- Disable the gene beacause we are about to add other to replace it
        gene.enabled = false
 
+       local n1 = gene.from
+       local n2 = self.high_neuron
+       local n3 = gene.to
+
+       local tmp
+       if n1 > n2 then
+               tmp = n1
+               n1 = n2
+               n2 = tmp
+       end
+
+       if n2 > n3 then
+               tmp = n3
+               n3 = n2
+               n2 = tmp
+       end
+
        local gene1 = gene:copy()
-       gene1.from = self.high_neuron
+       gene1.from = n2
+       gene1.to = n3
        gene1.weight = 1.0
        gene1.innovation = Get_Next_Innovation()
        gene1.enabled = true
 
-       assert(gene1.from ~= gene1.to, "IN MUTATE NEURON")
-
        table.insert(self.genes, gene1)
 
        local gene2 = gene:copy()
-       gene2.to = self.high_neuron
+       gene2.from = n1
+       gene2.to = n2
        gene2.innovation = Get_Next_Innovation()
        gene2.enabled = true
 
-       assert(gene2.from ~= gene2.to, "IN MUTATE NEURON")
-
        table.insert(self.genes, gene2)
 end
 
@@ -284,28 +308,28 @@ function Genome:mutate()
                prob = prob - 1
        end
 
-       prob = self.mutations["split"]
+       prob = self.mutations["enable"]
        while prob > 0 do
                if math.random() < prob then
-                       self:mutate_neuron()
+                       self:mutate_enabled(true)
                end
 
                prob = prob - 1
        end
 
-       prob = self.mutations["enable"]
+       prob = self.mutations["disable"]
        while prob > 0 do
                if math.random() < prob then
-                       self:mutate_enabled(true)
+                       self:mutate_enabled(false)
                end
 
                prob = prob - 1
        end
 
-       prob = self.mutations["disable"]
+       prob = self.mutations["split"]
        while prob > 0 do
                if math.random() < prob then
-                       self:mutate_enabled(false)
+                       self:mutate_neuron()
                end
 
                prob = prob - 1
@@ -324,7 +348,7 @@ function Genome:get_random_neuron(can_be_input)
        end
 
        for o = 1, self.num_outputs do
-               neurons[o + self.num_inputs] = true
+               neurons[MAX_NEURONS - o] = true
        end
 
        for i = 1, #genes do
@@ -405,6 +429,7 @@ function Population.new()
                generation = 0;
                current_genome = 0;
                high_fitness = 0;
+               total_fitness = 0;
                avg_fitness = 0;
        }
 
@@ -445,7 +470,7 @@ function Population:kill_worst()
                return a.fitness > b.fitness
        end)
 
-       local count = math.floor(3 * #self.genomes / 4)
+       local count = math.floor(2 * #self.genomes / 3)
        for _ = 1, count do
                table.remove(self.genomes) -- This removes the last (worst) genome
        end
@@ -458,7 +483,7 @@ function Population:kill_worst()
 end
 
 function Population:mate()
-       local count = #self.genomes * 3
+       local count = #self.genomes * 2
 
        -- Double the population size
        for _ = 1, count do
@@ -474,7 +499,7 @@ function Population:evolve()
 
        -- First we need to calculate the fitnesses of every genome
        self.current_genome = 0
-       function evolve_test(inputs, output_func, ...)
+       function evolve_test(inputs, output_func, _, ...)
                if self.current_genome == 0 then
                        self.current_genome = 1
                        self.genomes[self.current_genome]:create_network()
@@ -495,7 +520,15 @@ function Population:evolve()
                        if cont then
                                return evolve_test
                        else
+                               if genome.fitness > self.high_fitness then
+                                       self.high_fitness = genome.fitness
+                               end
+
+                               self.total_fitness = self.total_fitness + genome.fitness
+                               self.avg_fitness = self.total_fitness / self.current_genome
+
                                self.current_genome = self.current_genome + 1
+
                                if self.current_genome <= #self.genomes then
                                        self.genomes[self.current_genome]:create_network()
                                        return evolve_test
@@ -511,11 +544,16 @@ function Population:evolve()
        -- Then we need to kill off the worst of them
        -- Then we breed more
        -- Rinse and repeat!
-       function finish_evolve()
+       function finish_evolve(_, _, generation_step, ...)
+               generation_step(self.avg_fitness, self.high_fitness, ...)
+
                self:kill_worst()
                self:mate()
 
                self.current_genome = 0
+               self.high_fitness = 0
+               self.avg_fitness = 0
+               self.total_fitness = 0
                return evolve_test
        end
 
index 41e6f871f3433d4465f28999c95c1022fdf11211..fd259bf27492c2691fcb7ee3d57774d69bfa4275 100644 (file)
@@ -1,11 +1,17 @@
+local conf = require "conf"
+local MAX_NEURONS = conf.MAX_NEURONS
+
 -- Simple neural network implementation (perceptron)
 
 local Neuron = {}
-function Neuron.new()
+function Neuron.new(x, y)
        local o = {
                value = 0;
                inputs = {};
                dirty = false; -- Means that the value of the neuron has to be recalculated
+
+               x = x;
+               y = y;
        }
        return o
 end
@@ -26,12 +32,12 @@ function NeuralNetwork.new(num_inputs, num_outputs)
 
        -- 1 to num_inputs are input nodes
        for i = 1, num_inputs do
-               o.neurons[i] = Neuron.new()
+               o.neurons[i] = Neuron.new(0, (i - 1) * 32)
        end
 
        -- num_inputs + 1 to num_inputs + num_outputs are output nodes
-       for i = num_inputs + 1, num_inputs + num_outputs do
-               o.neurons[i] = Neuron.new()
+       for i = 1, num_outputs do
+               o.neurons[MAX_NEURONS - i] = Neuron.new(600, (i - 1) * 32)
        end
 
        setmetatable(o, NeuralNetwork_mt)
@@ -55,7 +61,7 @@ function NeuralNetwork:add_connection(from, to, weight, id)
 end
 
 function NeuralNetwork:add_neuron()
-       self.neurons[self.next_neuron] = Neuron.new()
+       self.neurons[self.next_neuron] = Neuron.new(math.random(500) + 100, math.random(400) + 50)
        self.next_neuron = self.next_neuron + 1
        return self.next_neuron - 1
 end
@@ -65,7 +71,7 @@ function NeuralNetwork:create_neuron(num)
                self.next_neuron = num + 1 -- Makes sure the next neuron won't override previous neurons
        end
 
-       self.neurons[num] = Neuron.new()
+       self.neurons[num] = Neuron.new(math.random(400) + 100, math.random(400) + 50)
 end
 
 function NeuralNetwork:has_neuron(num)
@@ -122,7 +128,7 @@ function NeuralNetwork:get_outputs()
        local ret = {}
 
        for i = 1, self.num_outputs do
-               ret[i] = self.neurons[i + self.num_inputs].value
+               ret[i] = self.neurons[MAX_NEURONS - i].value
        end
 
        return ret
index 6c7d03cd1a2c4f989711c147fb704e27c786f17f..d0b049c71970dd5e0812bc0e90ab632f3887f260 100644 (file)
@@ -40,7 +40,7 @@ function Bullet:collide(other, dx, dy, world)
 end
 
 function Bullet:get_rect()
-       local R = 8 * .7
+       local R = 8 * .5
        return { self.x - R, self.y - R, R * 2, R * 2 }
 end
 
@@ -87,6 +87,7 @@ function Player.new()
                fire_cooldown = 0;
 
                distances = {};
+               shot = false;
        }
 
        setmetatable(o, Player_mt)
@@ -135,6 +136,7 @@ end
 
 function Player:fire(vx, vy, world)
        local bullet = Bullet.new(self.x, self.y, vx, vy)
+       self.shot = true
        world:add_entity(bullet)
 end
 
@@ -190,12 +192,10 @@ function Player:get_distances(world)
                end
 
                if not hit_entity then
-                       table.insert(ret, 0)
+                       table.insert(ret, CONF.PLAYER_VISION_DISTANCE * CONF.ENEMY_SIZE)
                end
        end
 
-       assert(#ret == 16, "RET NOT LONG ENOUGH")
-
        return ret
 end
 
@@ -256,10 +256,10 @@ function Enemy:get_rect()
 end
 
 function Enemy:collide(other, dx, dy, world)
-       if other.ENTITY_TYPE == "Enemy" then
-               self.x = self.x - dx
-               self.y = self.y - dy
-       end
+       -- if other.ENTITY_TYPE == "Enemy" then
+       --      self.x = self.x - dx
+       --      self.y = self.y - dy
+       -- end
 
        if other.ENTITY_TYPE == "Player" then
                other.alive = false