From: Brendan Hansen Date: Wed, 13 Mar 2019 05:12:22 +0000 (-0500) Subject: Really working version X-Git-Url: https://git.brendanfh.com/?a=commitdiff_plain;h=f8f548ac42bab3bfef1e8701790554f0e82aaeab;p=genetic-shooter.git Really working version --- diff --git a/conf.lua b/conf.lua index f2ca2fb..c66ef23 100644 --- 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; } diff --git a/main.lua b/main.lua index 3d0ad3b..ce94f6d 100644 --- 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 diff --git a/src/genetics.lua b/src/genetics.lua index 36fbfec..68bb5bb 100644 --- a/src/genetics.lua +++ b/src/genetics.lua @@ -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 diff --git a/src/neuralnet.lua b/src/neuralnet.lua index 41e6f87..fd259bf 100644 --- a/src/neuralnet.lua +++ b/src/neuralnet.lua @@ -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 diff --git a/src/world.lua b/src/world.lua index 6c7d03c..d0b049c 100644 --- a/src/world.lua +++ b/src/world.lua @@ -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