# Duane's Dungeon - a rogue-like game # Copyright (C) 2023 Duane Robertson # creature.gd - anything moving on the map # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program. If not, see . extends Entity class_name Creature const ADJACENT = 1.5 # * 2 const DAMAGE_RATIO = 6.0 const DODGE_DIE = 12 const DODGE_STREAKBREAKER = 4 const EXHAUST_DAMAGE_PENALTY = 2.0 const EXHAUST_DODGE_PENALTY = 2 const HATE_DISTANCE = 5.0 const HATE_MONSTER = 2.0 const HATE_PLAYER = 2.4 # 2.4 const INITIAL_DODGE = 2 const INITIAL_ENERGY = 10 const INITIAL_STRENGTH = 10 const MADNESS = 50 const MNAMES = 'res://monster_names.json' const OCCUPIED_RATE = 4.0 const SECOND_WIND = 5 const TOO_FAR_TO_CHASE = 10 const XP_FACTOR = 100.0 static var name_index := {} static var random_names:Array static var species_classes:Dictionary static var species_names:Array var accuracy := 0 var automated := true var damage_mult := 2.0 var defense := {} var dodge := INITIAL_DODGE var dodge_streak := 0 var energy := 0 var energy_bar_1:TextureProgressBar var energy_max := INITIAL_ENERGY var extra_level := 0.0 var faction := '' var gear := {} var hate := {} var hide_bars := false var invulnerable := false var last_move := Vector2i.ZERO var leader := 0 var map_off:Vector2i = Vector2i(5, 5) var movement := 1 var moving:bool = false var noise := 'growls' var player var regen := 0 var regen_e := 0 var size := 3 var smt := false # Did it move_toward successfully? var spell_book := {} var spell_chance := {} var strength := 0 var strength_bar_1:TextureProgressBar var strength_max := INITIAL_STRENGTH var target:Entity # Assigning Entity type causes circular references? var target_loc static func load_species(): for fn in DirAccess.get_files_at('res://creatures'): if not fn.ends_with('.gd'): continue var nm = fn.replace('.gd', '') species_names.append(nm) species_classes[nm] = load('res://creatures/%s' % [fn]) # print(species_classes) static func seed_names(): var fin = FileAccess.open(MNAMES, FileAccess.READ) var json = JSON.new() var error = json.parse(fin.get_as_text()) if error != OK: return random_names = json.data['names'] for nam in random_names: name_index[nam] = null var syl = json.data['syllables'] for i in 1000: var n = syl[randi() % len(syl)] n += syl[randi() % len(syl)] # print(n.capitalize()) name_index[n.capitalize()] = null G.debug('Using %d random names' % len(name_index)) static func get_random_name(): var a := [] for n in name_index.keys(): if name_index[n] == null: a.append(n) if a.is_empty(): return var nam = a[randi() % len(a)] # print(nam) name_index[nam] = true return nam static func release_name(nam:String): name_index[nam] = null # Called when the node enters the scene tree for the first time. func _ready(): add_to_group('creatures') super() if hide_bars: show_bars(false) # needs the bars to exist reset_stats(not reloaded) func attack(targ:Entity) -> void: show_message('%s attacks %s' % [name, targ.name]) # Reserved for special combat behavior. if not on_combat(self, targ): return # Combat costs energy. var exhausted := false ###################### if status.has('rage'): pass ###################### elif energy > 0: change_energy(-1, self) elif (not unliving) and randi() % SECOND_WIND == 0: change_energy(vary(SECOND_WIND - 1), self) show_message('Second Wind') else: exhausted = true show_message('Exhausted') # Calculate accuracy penalty (and bonus), and see # if the target dodges the attack. var acc_pen:int = size - targ.size acc_pen -= get_gear_attr('accuracy') acc_pen += EXHAUST_DODGE_PENALTY if exhausted else 0 var dod_tot = targ.get_gear_attr('dodge') + acc_pen if randi() % DODGE_DIE < dod_tot: if targ.dodge_streak >= DODGE_STREAKBREAKER: targ.dodge_streak = 0 else: if targ.is_in_group('player'): targ.show_message('Dodge') else: targ.show_message('%s dodges' % [targ.name]) targ.add_sprite_text(Color.RED, 'D') return # Calculate the damage penalty, including armor and apply. var dg_pen:float = EXHAUST_DAMAGE_PENALTY if exhausted else 1.0 var curr_dm:float = get_gear_damage() var dmg:float = get_gear_attr('strength') dmg = ceil(dmg / DAMAGE_RATIO * curr_dm) dmg = vary(int(ceil(dmg / dg_pen))) dmg *= targ.get_gear_armor() if close_to_player(): G.debug('%s does %0.f damage to %s' % [name, dmg, targ.name]) # Subtract the damage from the target's strength. if on_hit(self, targ, dmg) and targ.on_hit(self, targ, dmg): targ.change_strength(- dmg, self) func auto_move() -> void: if target: if not is_instance_valid(target) \ or (not target.is_in_group('creatures')) \ or target.is_dead: G.debug('nulling dead target') target = null # if target == null and leader: # target = dungeon.get_entity(leader) # if target == null: # leader = 0 if target or target_loc: var tpos:Vector2i if target: tpos = target.grid_position else: tpos = target_loc var vec:Vector2i = tpos - grid_position var dist2:float = abs(vec.length()) if dist2 < ADJACENT: if target: if leader and map.get_entity(leader) == target: target = null else: attack(target) else: target_loc = null elif target: var dir = self.move_path(target.grid_position) move(dir) # # elif not follow_path.empty(): # var p: Vector2 = follow_path.pop_front() # if follow_path.empty(): # follow_path.clear() # for l in lines: # l.queue_free() # lines.clear() # else: # var p2:Vector2 = follow_path[-1] # var dist3 := p2.distance_squared_to(tpos) # if dist3 > dist2: # follow_path.clear() # for l in lines: # l.queue_free() # lines.clear() # if p: # vec = p - grid_position ## if (is_player and web_check(vec, tpos)) \ # if not move(vec.normalized().round()): # follow_path.clear() # for l in lines: # l.queue_free() # lines.clear() # # else: # if (is_player and web_check(vec, tpos)) \ # or not move(vec.normalized().round()): # if not path_to(tpos, true): # target = null # target_loc = null # if auto_exploring: # auto_exploring = false # if is_player: # automated = false # # if auto_exploring: # auto_explore() # elif auto_exploring: # auto_explore() else: var tar = Vector2i(randi() % 11 - 5, randi() % 11 - 5) + grid_position var dir = self.move_path(tar) # var dir: Vector2 = Vector2(randi() % 3 - 1, randi() % 3 - 1) move(dir) # smt = false # if target: # dir = self.move_path(target.grid_position) # else: # tar = Vector2i(randi() % 11 - 5, randi() % 11 - 5) + grid_position # dir = self.move_path(tar) # # if not dir: # dir = Vector2i(randi() % 3 - 1, randi() % 3 - 1) # else: # smt = true # # G.debug('success ', ename) # move(dir) func change_energy(amt: int, _source=null) -> bool: var ret := true var mxe := get_gear_attr('energy') if energy + amt < 0: ret = false else: energy = int(clamp(energy + amt, 0, mxe)) if energy_bar_1: energy_bar_1.max_value = mxe energy_bar_1.value = energy if get('energy_bar_2'): get('energy_bar_2').max_value = mxe get('energy_bar_2').value = energy return ret func change_strength(amt: int, source=null) -> bool: if amt < 0: if source: if not on_combat(source, self): return false if invulnerable: return false if status.has('sleep'): status.remove('sleep') if leader and source and leader == source.uid: leader = 0 var mxs := get_gear_attr('strength') strength = int(min(strength + amt, mxs)) if strength < 1: die(source) if amt != 0 and source and source.is_in_group('creatures'): var astr := str(abs(amt)) var col := Color.WHITE # var lead # if source.get('leader'): # lead = dungeon.get_entity(leader) if amt < 0 and is_in_group('player'): col = Color.RED # elif amt < 0 and lead and lead.is_in_group('player'): # col = Color.RED else: col = Color.GREEN add_sprite_text(col, astr) # Update target's hate table. if source != self: hate[source] = float(hate.get(source, 0) - amt) if strength_bar_1: strength_bar_1.max_value = mxs strength_bar_1.value = strength if get('strength_bar_2'): get('strength_bar_2').max_value = mxs get('strength_bar_2').value = strength return true func die(source=null) -> void: is_dead = true visible = false if source and source.is_in_group('creatures'): var lead if source.get('leader'): lead = map.get_entity(source.leader) if source == self: pass elif lead: lead.victory(self) else: source.victory(self) func get_faction(): # if leader: # var l = map.get_entity(leader) # if l: # return l.faction return faction func get_gear_armor() -> float: var attr: float = defense.get('weapon', 1.0) # if gear.size() > 0: # var gattr := 0.0 # for slot in 8: # var g = gear.get(slot) # if g: # gattr += g.armor # var enc: Dictionary = Item.enchantments[g.enchantment] # if enc.stat == 'armor': # gattr += g.level # if gattr > 0: # gattr = pow(gattr, ATTRIBUTE_SCALER) # gattr = min(9, gattr) # gattr = (10 - gattr) / 10.0 # attr *= gattr ###################################### # scaler? ###################################### attr *= status.multiply_value('weapon') ###################################### return attr func get_gear_attr(attr_s:String) -> int: var attr:float = 0.0 if attr_s != 'lives': attr = get(attr_s) if attr_s == 'strength' or attr_s == 'energy': attr = get(attr_s + '_max') # if gear.size() > 0: # var gattr := 0.0 # for slot in 8: # var g: Item = gear.get(slot) # if g: # var enc: Dictionary = Item.enchantments[g.enchantment] # if enc.stat == attr_s: # gattr += g.level # # if gattr > 0 and attr_s != 'lives': # gattr = pow(gattr, ATTRIBUTE_SCALER) # # attr += gattr ###################################### # scaler? ###################################### attr += status.add_value(attr_s) ###################################### return int(attr) func get_gear_damage() -> float: var attr:float = damage_mult # if gear.size() > 0: # if gear.get(Item.ITEM_SLOT_WEAPON): # attr = gear[Item.ITEM_SLOT_WEAPON].damage_mult ## print('base damage: ', attr) # # var gattr := 0.0 # for slot in 8: # var g: Item = gear.get(slot) # if g: # var enc: Dictionary = Item.enchantments[g.enchantment] # if enc.stat == 'damage': # gattr += g.level # #print('damage +', g.level) # # if gattr > 0: # gattr = pow(gattr, ATTRIBUTE_SCALER) # #print('dam gattr ', gattr) # # attr += gattr ###################################### # scaler? ###################################### attr *= status.multiply_value('damage') ###################################### return attr func get_gear_defense(dtype:String) -> float: if dtype == 'weapon': return get_gear_armor() var attr:float = defense.get(dtype, 1.0) if dtype != 'magic': attr = min(defense.get('magic', 1.0), attr) # if gear.size() > 0: # var gattr := 0.0 # for slot in 8: # var g = gear.get(slot) # if g: # var enc: Dictionary = Item.enchantments[g.enchantment] # if enc.stat == 'magic_resistance': # gattr += g.level # # if gattr > 0: # gattr = pow(gattr, ATTRIBUTE_SCALER) # gattr = (10 - min(9, gattr)) / 10.0 # attr *= gattr ###################################### # scaler? ###################################### attr *= status.multiply_value(dtype) ###################################### return attr ############################################## # ?????????????????????????????? ############################################## func get_rectangles() -> void: # This updates the entity's proximity rectangle. # It's only really used by the player's entity. var pp:Vector2i = grid_position var ppo:Vector2i = pp + last_move * 5 act_rect = Rect2i(ppo, Vector2i.ONE) act_rect = act_rect.grow(ACT_RAD) func look_around(game_turn := 0) -> void: var pot:Node2D var mhate:float = 0 # G.stopwatch() target = null for area in vision_area.get_overlapping_areas(): # Raycasting does a reasonable job on line of sight, # but putting collision objects in the tiles slows # the game down, especially on android. var ent:Node2D = area.get_parent() if ent == self or not ent.is_in_group('creatures'): if not ent.is_in_group('creatures'): assert(false) continue # Caching this seems not to help. # if not dungeon.line_of_sight(grid_position, ent.grid_position, vision): # continue if ent.is_dead: pass elif get_faction() == 'mad' or ent.get_faction() != get_faction(): if ent.get_faction() == 'ignore': pass elif ent.invulnerable: pass elif ent.get_faction() == 'player': var curr_hate = hate.get(ent, 0) if ent.is_in_group('player') and curr_hate == 0 and noise: var what = species if species else name ent.show_message('%s %s' % [what, noise]) hate[ent] = max(curr_hate, HATE_PLAYER) else: var curr_hate = hate.get(ent, 0) hate[ent] = max(curr_hate, HATE_MONSTER) # t = OS.get_ticks_msec() - t # G.time_los += t # G.total_time_los += t for ent in hate.keys(): if (not is_instance_valid(ent)) or ent.is_dead: hate.erase(ent) elif hate[ent] > 0: var vec:Vector2i = grid_position - ent.grid_position var dist2:float = abs(vec.length()) if dist2 > TOO_FAR_TO_CHASE: G.debug('%s is out of range' % [ent.name]) hate.erase(ent) continue var ahate:float = hate[ent] - dist2 / HATE_DISTANCE if ahate > mhate: mhate = ahate pot = ent target = pot func make_sprite(icon:String, sp_color:String): var eb:TextureProgressBar = TextureProgressBar.new() var sb:TextureProgressBar = TextureProgressBar.new() super(icon, sp_color) var img = Image.create(12, 1, false,Image.FORMAT_RGBA8) img.fill(Color.WHITE) var tex = ImageTexture.create_from_image(img) eb.texture_progress = tex sb.texture_progress = tex eb.position = Vector2(2, 14) sb.position = Vector2(2, 15) eb.modulate = Color('4040ffa0') sb.modulate = Color('ff4040a0') sprite.add_child(eb) sprite.add_child(sb) strength_bar_1 = sb energy_bar_1 = eb func move(dir:Vector2i) -> bool: assert(dir.abs().x < 2) assert(dir.abs().y < 2) var nloc := grid_position + dir if not walkable(nloc): return false if map.entity_at(nloc): return false map.astar.set_point_weight_scale(grid_position, 1.0) grid_position = nloc map.astar.set_point_weight_scale(grid_position, OCCUPIED_RATE) target_pos = Vector2(grid_position * tile_size) moving = true return true func move_path(tar:Vector2i) -> Vector2i: if not walkable(tar): return Vector2i.ZERO var path = map.astar.get_point_path(grid_position, tar) if len(path) > 1: return (path[1] as Vector2i) - grid_position return Vector2i.ZERO func on_combat(attacker:Creature, defender:Creature) -> bool: # The base on_combat just runs the status' on_combat's. # var ret:bool = status.on_combat(attacker, defender) var ret = true return ret func on_hit(attacker:Entity, defender:Entity, dmg:float) -> bool: return status.on_hit(attacker, defender, dmg) func reset_stats(revive:=true) -> void: if revive: strength = get_gear_attr('strength') energy = get_gear_attr('energy') change_strength(0) change_energy(0) # calc_level() func set_species_attributes(): assert(species) if not faction: faction = species # for sp in spell_chance.keys(): # spell_book[sp] = G.spells[sp] ## print('%s learns %s' % [species, sp]) if species != 'player': var nam = Creature.get_random_name() if nam: name = nam func show_bars(vis:bool): strength_bar_1.visible = vis energy_bar_1.visible = vis # name_label.visible = vis and G.settings['show_names'] func turn(): var game_turn := 0 # if userdata.has('game_turn'): # game_turn = userdata['game_turn'] super() # G.debug('creature turn') if not player: player = get_tree().get_nodes_in_group('player')[0] # Get targets and set hate. if automated and not status.get('inactive'): if leader == 0 and not is_in_group('player') \ and randi() % MADNESS == 0: show_message('%s goes MAD' % [name]) faction = 'mad' look_around(game_turn) if automated and not status.get('inactive'): if true or status.auto_move(): if true: # or not (len(spell_book) and simple_spell_ai()): auto_move() func victory(ent:Entity) -> void: change_energy(1) change_strength(1) if level > map.level: return var xp = ent.level * ent.level # if is_player: # print('player kills %s for %d xp' % [ent.species, xp]) kills += xp var new_level = floor(sqrt(kills / XP_FACTOR)) + 1 if new_level > level: show_message('Achieved level %d' % [new_level]) level = new_level