# ../silent_hill/silent_hill.py (npc edition)
# Python
import random
# Source.Python
from colors import Color
from commands.server import ServerCommand
from core import PLATFORM
from cvars import ConVar
from engines.precache import Model
from engines.server import server, server_game_dll
from engines.sound import Sound
from entities.constants import RenderMode, RenderEffects
from entities.entity import BaseEntity, Entity
from entities.helpers import index_from_edict, pointer_from_edict
from events import Event
from listeners import OnEntityDeleted, OnLevelInit, OnLevelEnd
from listeners.tick import Delay, Repeat
from mathlib import NULL_VECTOR, Vector
from memory import (Convention, DataType, find_binary, get_virtual_function,
NULL)
from memory.hooks import PreHook
from players import PlayerGenerator
from players.entity import Player
from stringtables import string_tables
# How long until the apocalypse starts again (in seconds)?
INTERVALS = (30, 45, 60, 90)
# Seconds until the thick Silent Hill-like fog starts fading away.
FOG_FADE_DELAY = 60
# How long should the fading of the fog take (in seconds)?
FOG_FADE_TIME = 60
# Should NPCs spawn during the thick fog phase? (True/False)
NPCS_ENABLED = True
# Maximum number of NPCs at any given time.
NPCS_MAX = 5
# Apply a glowing green effect that can be seen through the fog when the
# manhacks spawn? (True/False)
MANHACKS_SPAWN_EFFECT = False
# How much health should manhacks spawn with?
MANHACKS_HEALTH = 100
# How much damage should they deal?
MANHACKS_DAMAGE = 25
# How much health should zombies spawn with?
ZOMBIE_HEALTH = 120
# How much damage should they deal?
ZOMBIE_DAMAGE = 30
FOG_COLOR = Color(185, 185, 185)
FLASH_COLOR = Color(255, 0, 0, 150)
FLASH_COLOR_END = Color(255, 0, 0, 255)
SIREN_SOUND = Sound('ambient/alarms/citadel_alert_loop2.wav')
# Sprite used for tinting the player's screen.
SCREEN_SPRITE = Model('sprites/white.vmt')
SCREEN_SPRITE_OFFSET = Vector(10, 0, 0)
# Offset for NPC spawn positions.
NPC_ORIGIN_OFFSET = Vector(0, 0, 32)
NPC_SPAWN_VELOCITY = (-500, -250, 250, 500)
# Dictionary used to keep track of 'env_sprite' entities we'll be using.
_black_screens = {}
# =============================================================================
# >> EVENTS AND LISTENERS
# =============================================================================
def load():
"""Called when the plugin gets loaded."""
# Modify the manhack related convars.
Manhack.initialize_settings()
# Modify the zombie related convars.
Zombie.initialize_settings()
# Are there any players on the server?
if server.num_players > 0:
dark_times.initialize()
def unload():
"""Called when the plugin gets unloaded."""
dark_times.stop(pause_init=False)
dark_times.remove_all_npcs()
# Remove any leftover player entities.
for edict in PlayerGenerator():
PlayerSH(index_from_edict(edict)).remove_black_screen()
@OnLevelEnd
def on_level_end():
"""Called when the map starts changing."""
dark_times.stop()
# Remove old data (spawn points, npc indexes).
dark_times.clean_up_data()
@Event('round_start')
def round_start(event):
"""Called when a new round starts."""
dark_times.stop()
dark_times.initialize()
@OnLevelInit
def on_level_init(map_name):
"""Called when the new map is done loading."""
dark_times.initialize()
@OnEntityDeleted
def on_entity_deleted(base_entity):
"""Called when an entity gets deleted."""
try:
index = base_entity.index
except ValueError:
# Not a networked entity.
return
try:
# Was this one of our 'env_sprite' entities?
player_index = _black_screens.pop(index)
# Remove the instance reference from the player.
PlayerSH(player_index).black_screen = None
except (KeyError, ValueError):
pass
try:
# Was this an NPC we spawned?
dark_times.npc_indexes.remove(index)
except KeyError:
pass
@PreHook(get_virtual_function(server_game_dll, 'SetServerHibernation'))
def set_server_hibernation_pre(stack_data):
"""Called when the last player leaves, or the first player joins."""
# Did the last player just leave (server is being put into hibernation)?
if stack_data[1]:
dark_times.stop()
# Or did the first player just join?
else:
dark_times.initialize()
# =============================================================================
# >> PLAYER STUFF
# =============================================================================
class PlayerSH(Player):
"""Modified Player class."""
def __init__(self, index, caching=True):
"""Initializes the object."""
super().__init__(index, caching)
self.black_screen = None
self.target_name = f'player_{self.userid}'
@property
def viewmodel(self):
"""Returns the Entity instance of the player's viewmodel."""
return Entity.from_inthandle(self.get_property_int('m_hViewModel'))
def darken_view(self, amount):
"""Lowers the brightness of the player's screen."""
if self.black_screen is None:
self.black_screen = create_sprite(
origin=NULL_VECTOR, scale=30.0, model=SCREEN_SPRITE)
self.black_screen.set_parent(self.viewmodel, -1)
self.black_screen.teleport(SCREEN_SPRITE_OFFSET)
# Create a sprite:player reference for later use.
_black_screens[self.black_screen.index] = self.index
# Change the alpha/transparency of the 'env_sprite'.
self.black_screen.set_network_property_int('m_nBrightness', amount)
def remove_black_screen(self):
"""Removes the 'env_sprite' used for tinting the player's screen."""
try:
self.black_screen.remove()
except AttributeError:
return
self.black_screen = None
# =============================================================================
# >> DARK TIMES
# =============================================================================
class DarkTimes:
"""Class used to start and stop the apocalypse.
Attributes:
current_darkness (int): Level of darkness used for darkening players'
screens.
in_progress (bool): Is the apocalypse currently happening?
npc_indexes (set of int): Contains indexes of NPCs spawned during the
thick fog phase.
flash_think (Repeat): Instance of Repeat() used for looping the
`_flash_think()` function.
darken_think (Repeat): Instance of Repeat() used for looping the
`_darken_think()` function.
gather_data_think (Repeat): Instance of Repeat() used for looping the
`_gather_data_think()` function.
old_fog_values (tuple): Tuple that holds values of the previous fog.
_fog (Entity): Entity instance of the 'env_fog_controller' we'll be
using.
_delays (dict of Delay): Dictionary that holds any Delay() instances
we might be using.
_saved_time (float): Remaining time from the previous initialization.
_valid_npc_origins (list of Vector): List containing spawn positions
for NPCs.
"""
def __init__(self):
"""Initializes the object."""
self.current_darkness = 0
self.in_progress = False
self.npc_indexes = set()
self.flash_think = Repeat(self._flash_think)
self.darken_think = Repeat(self._darken_think)
self.gather_data_think = Repeat(self._gather_data_think)
self.spawn_npcs_think = Repeat(self._spawn_npcs_think)
self.old_fog_values = None
self._fog = None
self._delays = {}
self._saved_time = None
self._valid_npc_origins = []
def initialize(self, instant=False):
"""Starts the apocalypse after a randomly chosen delay."""
# Don't go further if the apocalypse is already happening.
if self.in_progress:
return
try:
self._delays['init'].cancel()
except (KeyError, ValueError):
pass
# Are we trying to instantly start the apocalypse?
if instant:
self.begin()
else:
self._delays['init'] = Delay(
# Resume the time to start from the previous initialization if
# there is one, otherwise pick a random time.
self._saved_time if self._saved_time else random.choice(
INTERVALS), self.begin)
def begin(self):
"""Starts the apocalypse."""
self.in_progress = True
self.current_darkness = 0
self._saved_time = None
try:
self._delays['init'].cancel()
except (KeyError, ValueError):
pass
SIREN_SOUND.play()
# Sync the red flashes with the siren sound - every time the players
# hear the siren, their screen will be flashed red.
self.flash_think.start(interval=6.5, limit=4, execute_on_start=True)
# Start lowering the brightness.
self.darken_think.start(interval=0.5, limit=40, execute_on_start=True)
# Look for valid spawn positions for NPCs during the siren phase.
self.gather_origin_data()
# Change to a thick fog similar to the one from Silent Hill.
# (credit: killer89 - https://gamebanana.com/prefabs/1308 )
self._delays['final_flash'] = Delay(
26, self.change_fog, (FOG_COLOR, FOG_COLOR, 0, 620))
# Start changing the fog back to normal.
self._delays['restoration'] = Delay(
26 + FOG_FADE_DELAY, self.restore_fog_smooth, (FOG_FADE_TIME,))
def stop(self, pause_init=True):
"""Stops the apocalypse and restores everything back to normal."""
SIREN_SOUND.stop()
# Stop the looping functions.
self.flash_think.stop()
self.darken_think.stop()
self.gather_data_think.stop()
self.spawn_npcs_think.stop()
if pause_init:
try:
# If we're stopping the apocalypse before it had a chance to
# begin, save the remaining time - so we can resume it later.
self._saved_time = self._delays['init'].time_remaining
except KeyError:
pass
# Cancel all delays.
for delay in self._delays.values():
try:
delay.cancel()
except ValueError:
continue
# Set the brightness back to normal levels.
for edict in PlayerGenerator():
PlayerSH(index_from_edict(edict)).darken_view(0)
self.restore_fog()
self.in_progress = False
self._fog = None
def clean_up_data(self):
"""Removes data that's no longer needed/valid."""
self._valid_npc_origins.clear()
self.npc_indexes.clear()
def remove_all_npcs(self):
"""Removes all currently active NPCs."""
for index in self.npc_indexes.copy():
BaseEntity(index).remove()
def on_completed(self):
"""Called when the fog finally settles back to the default values."""
self.in_progress = False
# Prepare for the next apocalypse.
self.initialize()
def gather_origin_data(self):
"""Starts gathering valid spawn positions for NPCs."""
# Have we gathered a decent amount of spawn positions?
if len(self._valid_npc_origins) >= 32:
return
self.gather_data_think.start(
interval=2, limit=14, execute_on_start=True)
def _gather_data_think(self):
for edict in PlayerGenerator():
player = PlayerSH(index_from_edict(edict))
# Is this player dead?
if player.dead:
continue
new_origin = player.origin + NPC_ORIGIN_OFFSET
# Go through previously added spawn positions.
for origin in self._valid_npc_origins:
# Is the new position too close to this one?
if origin.get_distance(new_origin) <= 256:
# No need to keep looking, stop the loop.
break
else:
# The new position is far enough, add it to the list.
self._valid_npc_origins.append(new_origin)
def _flash_think(self):
UTIL_ScreenFadeAll(FLASH_COLOR, 0.5, 0.25, 1)
def _darken_think(self):
# Increase the darkness.
self.current_darkness += 5
# Reduce the brightness for each player on the server.
for edict in PlayerGenerator():
PlayerSH(index_from_edict(edict)).darken_view(
self.current_darkness)
def _spawn_npcs_think(self):
# Have we hit the NPC limit?
if len(self.npc_indexes) >= NPCS_MAX:
return
try:
origin = random.choice(self._valid_npc_origins)
except IndexError:
return
# Pick an NPC to spawn (55% for a manhack, 45% for a zombie)
npc_class = random.choices((Manhack, Zombie), weights=(55, 45))[0]
npc = npc_class.create(origin)
# Add the NPC's index to the set.
self.npc_indexes.add(npc.index)
def get_fog_instance(self):
"""Returns an Entity instance of an 'env_fog_controller'."""
if self._fog is not None:
return self._fog
old_fog = Entity.find('env_fog_controller')
# Does an 'env_fog_controller' already exist on this map?
if old_fog:
# Store the old values for later use.
self.old_fog_values = (
old_fog.get_property_color('m_fog.colorPrimary'),
old_fog.get_property_color('m_fog.colorSecondary'),
old_fog.fog_start,
old_fog.fog_end
)
# We'll use that one for our fog shenanigans.
self._fog = old_fog
return self._fog
# Guess we need to make a new one.
new_fog = Entity.create('env_fog_controller')
new_fog.fog_enable = True
new_fog.fog_blend = True
new_fog.fog_max_density = 1.0
new_fog.spawn_flags = 1
new_fog.spawn()
new_fog.target_name = 'silent_hill_fog'
# Fix for maps without fog.
for edict in PlayerGenerator():
PlayerSH(index_from_edict(edict)).call_input(
'SetFogController', new_fog.target_name)
self._fog = new_fog
return self._fog
def remove_fog(self):
"""Removes the stored 'env_fog_controller' entity.
Note:
This is only used if we're making our own 'env_fog_controller'.
"""
try:
self._fog.remove()
except AttributeError:
return
self._fog = None
def change_fog(self, color1, color2, start, end, final_flash=True):
"""Changes the fog visuals.
Args:
color1 (Color): Primary color of the fog.
color2 (Color): Secondary color of the fog.
start (float): Distance at which the fog begins.
end (float): Distance at which the fog is at its maximum.
final_flash (bool): Is this the final red flash?
"""
fog = self.get_fog_instance()
fog.set_color(color1)
fog.set_color_secondary(color2)
fog.set_start_dist(start)
fog.set_end_dist(end)
# Is this the final flash?
if final_flash:
# Add a stronger flash to mask the changes in fog and screen
# brightness.
UTIL_ScreenFadeAll(FLASH_COLOR_END, 1, 0.5, 1)
for edict in PlayerGenerator():
PlayerSH(index_from_edict(edict)).darken_view(0)
# Should we start spawning NPCs?
if NPCS_ENABLED:
# Spawn them only during the thick fog phase.
self.spawn_npcs_think.start(
interval=3, limit=FOG_FADE_DELAY / 3)
def restore_fog(self):
"""Restores the fog back to normal."""
if self._fog is None:
return
# Was the map without fog?
if self.old_fog_values is None:
self.remove_fog()
return
self.change_fog(*self.old_fog_values, False)
def restore_fog_smooth(self, duration):
"""Smoothly restores the fog back to normal over the given duration."""
if self._fog is None:
return
should_remove = False
old_values = self.old_fog_values
# Was the map missing an 'env_fog_controller' entity?
if old_values is None:
should_remove = True
# Just increase the 'start' and 'end' values for the fog before
# removing it - making the transition semi-smooth.
old_values = (FOG_COLOR, FOG_COLOR, 512, 14000)
self._fog.set_color_lerp_to(old_values[0])
self._fog.set_color_secondary_lerp_to(old_values[1])
self._fog.set_start_dist_lerp_to(old_values[2])
self._fog.set_end_dist_lerp_to(old_values[3])
# Add 0.001 to the transition duration to avoid flashes caused by the
# fog bouncing back (engine quirk) before being set in place.
self._fog.fog_lerp_time = duration + 0.001
self._fog.start_fog_transition()
# If we created our own fog, we need to remove it.
if should_remove:
self._delays['ending'] = self._fog.delay(duration, self.remove_fog)
else:
# Make sure the fog values stay put after the transition.
self._delays['ending'] = self._fog.delay(
duration, self.change_fog, (*self.old_fog_values, False))
# The cycle is complete - let's do that again!
self._delays['restart'] = Delay(duration, self.on_completed)
dark_times = DarkTimes()
# =============================================================================
# >> UTIL_SCREENFADEALL - https://git.io/JJXoe
# =============================================================================
server_binary = find_binary('server')
if PLATFORM == 'windows':
identifier_screen = b'\x55\x8B\xEC\xD9\x45\x10\x8D\x45\xF4'
else:
identifier_screen = '_Z18UTIL_ScreenFadeAllRK9color32_sffi'
UTIL_ScreenFadeAll = server_binary[identifier_screen].make_function(
Convention.CDECL,
(DataType.POINTER, DataType.FLOAT, DataType.FLOAT, DataType.INT),
DataType.VOID
)
# =============================================================================
# >> UTIL_GETLOCALPLAYER FIX - (thank you Ayuto)
# viewtopic.php?f=20&t=1907#p12247
# =============================================================================
if PLATFORM == 'windows':
identifier_local = b'\xA1\x2A\x2A\x2A\x2A\x8B\x2A\x2A\x83\x2A\x01\x7E\x03\x33\xC0\xC3'
else:
identifier_local = '_Z19UTIL_GetLocalPlayerv'
UTIL_GetLocalPlayer = server_binary[identifier_local].make_function(
Convention.CDECL,
[],
DataType.POINTER
)
@PreHook(UTIL_GetLocalPlayer)
def get_local_player_pre(stack_data):
"""Called when the engine tries to get the local Player object.
This function was designed for single-player and should NOT be called in
multi-player games unless you want the server to crash.
"""
for edict in PlayerGenerator():
try:
return pointer_from_edict(edict)
except ValueError:
pass
return NULL
# =============================================================================
# >> ENV_SPRITE
# =============================================================================
def create_sprite(origin, scale, model):
"""Creates an 'env_sprite' entity.
Args:
origin (Vector): Spawn position of the 'env_sprite'.
scale (float): Size of the sprite (max size: 64.0).
model (Model): Appearance of the sprite.
"""
sprite = Entity.create('env_sprite')
sprite.model = model
sprite.origin = origin
sprite.set_key_value_float('scale', scale)
sprite.set_key_value_bool('disablereceiveshadows', True)
sprite.set_key_value_float('HDRColorScale', 0)
sprite.render_amt = 1
sprite.render_mode = RenderMode.TRANS_COLOR
sprite.set_key_value_string('rendercolor', '0 0 0')
sprite.render_fx = RenderEffects.NONE
sprite.spawn_flags = 1
sprite.spawn()
return sprite
# =============================================================================
# >> NPC_MANHACK
# =============================================================================
class Manhack(Entity):
"""Class used to create and manipulate 'npc_manhack' entities."""
caching = True
effect_name = 'vortigaunt_hand_glow'
settings = {
'health': ConVar('sk_manhack_health'),
'damage': ConVar('sk_manhack_melee_dmg')
}
@staticmethod
def initialize_settings():
"""Changes the convars for 'npc_manhack' health and damage."""
Manhack.settings['health'].set_int(MANHACKS_HEALTH)
Manhack.settings['damage'].set_int(MANHACKS_DAMAGE)
@classmethod
def create(cls, origin):
"""Creates an 'npc_manhack' entity at the specified origin."""
manhack = super().create('npc_manhack')
manhack.origin = origin
# Make the manhack fully transparent.
manhack.render_amt = 1
# Slowly fade in the manhack.
manhack.set_key_value_int('renderfx', 7)
# Set a couple of spawn flags.
# 2: Gag (No IDLE sounds until angry)
# 256: Long Visibility/Shoot
manhack.spawn_flags = 2 + 256
manhack.spawn()
if MANHACKS_SPAWN_EFFECT:
manhack.create_spawn_particle()
# Push the manhack in a random direction.
manhack.set_property_vector(
'm_vForceVelocity', Vector(
random.choice(NPC_SPAWN_VELOCITY),
random.choice(NPC_SPAWN_VELOCITY),
500
))
# Make the manhack hate the players.
manhack.call_input('SetRelationship', 'player D_HT 99')
return manhack
def create_spawn_particle(self):
"""Creates and parents an 'info_particle_system' to the manhack."""
particle = Entity.create('info_particle_system')
particle.origin = self.origin
particle.effect_name = Manhack.effect_name
particle.effect_index = string_tables.ParticleEffectNames.add_string(
Manhack.effect_name)
particle.start()
# Parent the particle to the 'npc_manhack'.
particle.set_parent(self)
# Remove the particle after a second.
particle.delay(1, particle.remove)
# =============================================================================
# >> NPC_ZOMBIE
# =============================================================================
class Zombie(Entity):
"""Class used to create 'npc_zombie' entities."""
caching = True
settings = {
'health': ConVar('sk_zombie_health'),
'damage': ConVar('sk_zombie_dmg_one_slash')
}
@staticmethod
def initialize_settings():
"""Changes the convars for 'npc_manhack' health and damage."""
Zombie.settings['health'].set_int(ZOMBIE_HEALTH)
Zombie.settings['damage'].set_int(ZOMBIE_DAMAGE)
@classmethod
def create(cls, origin):
"""Creates an 'npc_zombie' at the specified origin."""
zombie = super().create('npc_zombie')
zombie.target_name = f'zombie_{zombie.inthandle}'
zombie.origin = origin
zombie.render_amt = 1
zombie.set_key_value_int('renderfx', 7)
zombie.spawn_flags = 2 + 256
zombie.spawn()
zombie.call_input('SetRelationship', 'player D_HT 99')
# Start going towards a random player after a short delay.
# We need to do this so the zombie doesn't just stand still once it
# spawns. This way it won't block the spawn point.
zombie.delay(0.5, zombie.seek_random_player)
return zombie
def seek_random_player(self):
"""Starts heading towards a randomly selected player."""
targets = []
for edict in PlayerGenerator():
targets.append(PlayerSH(index_from_edict(edict)).target_name)
# We need an 'aiscripted_schedule' entity to make the zombie do things.
schedule = Entity.create('aiscripted_schedule')
# Set which entity we'd like to command.
schedule.set_key_value_string('m_iszEntity', self.target_name)
# Set their current state to 'Idle'.
# (0: None, 1: Idle, 2: Alert, 3: Combat)
schedule.set_key_value_int('forcestate', 1)
# Make the zombie set the target as their enemy and start going towards
# them. Here are all the options for this keyvalue:
# 0: <None>
# 1: Walk to Goal Entity
# 2: Run to Goal Entity
# 3: Set enemy to Goal Entity
# 4: Walk Goal Path
# 5: Run Goal Path
# 6: Set enemy to Goal Entity AND Run to Goal Entity
schedule.set_key_value_int('schedule', 6)
# In case the zombie takes damage, stop the schedule.
# (0: General, 1: Damage or death, 2: Death)
schedule.set_key_value_int('interruptability', 1)
# Pick a random player to be the enemy.
schedule.set_key_value_string('goalent', random.choice(targets))
schedule.spawn()
schedule.call_input('StartSchedule')
schedule.delay(5, schedule.remove)
# =============================================================================
# >> SERVER COMMANDS
# =============================================================================
@ServerCommand('force_dark_times')
def force_dark_times_cmd(command):
dark_times.initialize(instant=True)