TerraCraft/game/scenes.py

798 lines
29 KiB
Python
Executable File

#!/bin/python3
"""
________ ______ ______ __
| \ / \ / \ | \
\$$$$$$$$______ ______ ______ ______ | $$$$$$\ ______ ______ | $$$$$$\ _| $$_
| $$ / \ / \ / \ | \ | $$ \$$ / \ | \ | $$_ \$$| $$ \
| $$ | $$$$$$\| $$$$$$\| $$$$$$\ \$$$$$$\| $$ | $$$$$$\ \$$$$$$\| $$ \ \$$$$$$
| $$ | $$ $$| $$ \$$| $$ \$$/ $$| $$ __ | $$ \$$/ $$| $$$$ | $$ __
| $$ | $$$$$$$$| $$ | $$ | $$$$$$$| $$__/ \| $$ | $$$$$$$| $$ | $$| \
| $$ \$$ \| $$ | $$ \$$ $$ \$$ $$| $$ \$$ $$| $$ \$$ $$
\$$ \$$$$$$$ \$$ \$$ \$$$$$$$ \$$$$$$ \$$ \$$$$$$$ \$$ \$$$$
Copyright (C) 2013 Michael Fogleman
Copyright (C) 2018/2019 Stefano Peris <xenonlab.develop@gmail.com>
Github repository: <https://github.com/XenonLab-Studio/TerraCraft>
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 <http://www.gnu.org/licenses/>.
"""
import time
from collections import deque
from pyglet.gl import *
from pyglet.media import Player
from pyglet.window import key, mouse
from pyglet.sprite import Sprite
from pyglet.graphics import OrderedGroup
from .blocks import *
from .utilities import *
from .graphics import BlockGroup
from .genworld import WorldGenerator
from .world import Model
class AudioEngine:
"""A high level audio engine for easily playing SFX and Music."""
def __init__(self, channels=5):
self.sfx_players = deque([Player() for _ in range(channels)], maxlen=channels)
self.music_player = Player()
def set_volume(self, percentage):
"""Set the audio volume, as a percentage of 1 to 100.
:param percentage: int: The volume, as a percentage.
"""
volume = max(min(1, percentage / 100), 0)
for player in self.sfx_players:
player.volume = volume
self.music_player.volume = volume
def play(self, source, position=(0, 0, 0)):
"""Play a sound effect on the next available channel
:param source: A pyglet audio Source
:param position: Optional spacial position for the sound.
"""
player = self.sfx_players[0]
player.position = position
player.queue(source=source)
if not player.playing:
player.play()
else:
player.next_source()
self.sfx_players.rotate()
def play_music(self, source):
"""Play a music track, or switch to a new one.
:param source: A pyglet audio Source
"""
self.music_player.queue(source=source)
if not self.music_player.playing:
self.music_player.play()
else:
self.music_player.next_source()
class Scene:
"""A base class for all Scenes to inherit from.
All Scenes must contain an `update` method. In addition,
you can also define event handlers for any of the events
dispatched by the `Window`. Any Scene methods that match
the Window event names will be automatically set when
changing to the Scene.
"""
scene_manager = None # This is assigned when adding the Scene
audio = AudioEngine() # All Scenes share the same AudioEngine
def update(self, dt):
raise NotImplementedError
class MenuScene(Scene):
def __init__(self, window):
self.window = window
self.batch = pyglet.graphics.Batch()
# Create a
title_image = pyglet.resource.image('TerraCraft.png')
title_image.anchor_x = title_image.width // 2
title_image.anchor_y = title_image.height + 10
position = self.window.width // 2, self.window.height
self.title_graphic = Sprite(img=title_image, x=position[0], y=position[1], batch=self.batch)
self.start_label = pyglet.text.Label('Select save & press Enter to start', font_size=25,
x=self.window.width // 2, y=self.window.height // 2,
anchor_x='center', anchor_y='center', batch=self.batch)
# Create labels for three save slots:
self.save_slot_labels = []
for save_slot in [1, 2, 3]:
self.scene_manager.save.save_slot = save_slot
# indicate if an existing save exists
if self.scene_manager.save.has_save_game():
label_text = f"{save_slot}: load"
else:
label_text = f"{save_slot}: new game"
y_pos = 190 - 50 * save_slot
label = pyglet.text.Label(
label_text, font_size=20, x=40, y=y_pos, batch=self.batch)
self.save_slot_labels.append(label)
# Highlight the default save slot
self.scene_manager.save.save_slot = 1
self._highlight_save_slot()
def update(self, dt):
pass
def _highlight_save_slot(self):
# First reset all labels to white
for label in self.save_slot_labels:
label.color = 255, 255, 255, 255
# Highlight the selected slot
index = self.scene_manager.save.save_slot - 1
self.save_slot_labels[index].color = 50, 50, 50, 255
def on_key_press(self, symbol, modifiers):
"""Event handler for the Window.on_key_press event."""
if symbol == key.ENTER:
self.scene_manager.change_scene('GameScene')
elif symbol == key.ESCAPE:
self.window.set_exclusive_mouse(False)
return pyglet.event.EVENT_HANDLED
if symbol in (key._1, key._2, key._3):
if symbol == key._1:
self.scene_manager.save.save_slot = 1
elif symbol == key._2:
self.scene_manager.save.save_slot = 2
elif symbol == key._3:
self.scene_manager.save.save_slot = 3
self._highlight_save_slot()
def on_mouse_press(self, x, y, button, modifiers):
"""Event handler for the Window.on_resize event."""
self.window.set_exclusive_mouse(True)
def on_resize(self, width, height):
"""Event handler for the Window.on_resize event."""
# Keep the graphics centered on resize
self.title_graphic.position = width//2, height
self.start_label.x = width // 2
self.start_label.y = height // 2
def on_draw(self):
"""Event handler for the Window.on_draw event."""
self.window.clear()
self.batch.draw()
class GameScene(Scene):
def __init__(self, window):
self.window = window
# A Batch is a collection of vertex lists for batched rendering.
self.batch = pyglet.graphics.Batch()
# pyglet Groups manages setting/unsetting OpenGL state.
self.block_group = BlockGroup(
self.window, pyglet.resource.texture('textures.png'), order=0)
self.hud_group = OrderedGroup(order=1)
# Whether or not the window exclusively captures the mouse.
self.exclusive = False
# When flying gravity has no effect and speed is increased.
self.flying = FLYING
# Determine if player is running. If false, then player is walking.
self.running = RUNNING
# Wether or not all gui elements are drawn.
self.toggleGui = TOGGLE_GUI
# Wether or not the fps counter and player coordinates are drawn.
self.toggleLabel = TOGGLE_INFO_LABEL
# Strafing is moving lateral to the direction you are facing,
# e.g. moving to the left or right while continuing to face forward.
#
# First element is -1 when moving forward, 1 when moving back, and 0
# otherwise. The second element is -1 when moving left, 1 when moving
# right, and 0 otherwise.
self.strafe = [0, 0]
# Current (x, y, z) position in the world, specified with floats. Note
# that, perhaps unlike in math class, the y-axis is the vertical axis.
self.position = (SECTOR_SIZE // 2, 6, SECTOR_SIZE // 2)
# First element is rotation of the player in the x-z plane (ground
# plane) measured from the z-axis down. The second is the rotation
# angle from the ground plane up. Rotation is in degrees.
#
# The vertical plane rotation ranges from -90 (looking straight down) to
# 90 (looking straight up). The horizontal rotation range is unbounded.
self.rotation = (0, 0)
# Which sector the player is currently in.
self.sector = None
# True if the location of the camera have changed between an update
self.frustum_updated = False
# Velocity in the y (upward) direction.
self.dy = 0
# A list of blocks the player can place. Hit num keys to cycle.
self.inventory = [DIRT, DIRT_WITH_GRASS, SAND, SNOW, COBBLESTONE,
BRICK_COBBLESTONE, BRICK, TREE, LEAVES, WOODEN_PLANKS]
# The current block the user can place. Hit num keys to cycle.
self.block = self.inventory[0]
# Convenience list of num keys.
self.num_keys = [key._1, key._2, key._3, key._4, key._5,
key._6, key._7, key._8, key._9, key._0]
# Instance of the model that handles the world.
self.model = Model(batch=self.batch, group=self.block_group)
# The crosshairs at the center of the screen.
self.reticle = self.batch.add(4, GL_LINES, self.hud_group, 'v2i', ('c3B', [0]*12))
# The highlight around focused block.
indices = [0, 1, 1, 2, 2, 3, 3, 0, 4, 7, 7, 6, 6, 5, 5, 4, 0, 4, 1, 7, 2, 6, 3, 5]
self.highlight = self.batch.add_indexed(24, GL_LINES, self.block_group, indices,
'v3f/dynamic', ('c3B', [0]*72))
# The label that is displayed in the top left of the canvas.
self.info_label = pyglet.text.Label('', font_name='Arial', font_size=INFO_LABEL_FONTSIZE,
x=10, y=self.window.height - 10, anchor_x='left',
anchor_y='top', color=(0, 0, 0, 255))
# Boolean whether to display loading screen.
self.initialized = False
# Some environmental SFX to preload:
self.jump_sfx = pyglet.resource.media('jump.wav', streaming=False)
self.destroy_sfx = pyglet.resource.media('dirt.wav', streaming=False)
self.on_resize(*self.window.get_size())
def set_exclusive_mouse(self, exclusive):
""" If `exclusive` is True, the game will capture the mouse, if False
the game will ignore the mouse.
"""
self.window.set_exclusive_mouse(exclusive)
self.exclusive = exclusive
def get_sight_vector(self):
""" Returns the current line of sight vector indicating the direction
the player is looking.
"""
x, y = self.rotation
# y ranges from -90 to 90, or -pi/2 to pi/2, so m ranges from 0 to 1 and
# is 1 when looking ahead parallel to the ground and 0 when looking
# straight up or down.
m = math.cos(math.radians(y))
# dy ranges from -1 to 1 and is -1 when looking straight down and 1 when
# looking straight up.
dy = math.sin(math.radians(y))
dx = math.cos(math.radians(x - 90)) * m
dz = math.sin(math.radians(x - 90)) * m
return dx, dy, dz
def get_motion_vector(self):
""" Returns the current motion vector indicating the velocity of the
player.
Returns
-------
vector : tuple of len 3
Tuple containing the velocity in x, y, and z respectively.
"""
if any(self.strafe):
x, y = self.rotation
strafe = math.degrees(math.atan2(*self.strafe))
y_angle = math.radians(y)
x_angle = math.radians(x + strafe)
if self.flying:
m = math.cos(y_angle)
dy = math.sin(y_angle)
if self.strafe[1]:
# Moving left or right.
dy = 0.0
m = 1
if self.strafe[0] > 0:
# Moving backwards.
dy *= -1
# When you are flying up or down, you have less left and right
# motion.
dx = math.cos(x_angle) * m
dz = math.sin(x_angle) * m
else:
dy = 0.0
dx = math.cos(x_angle)
dz = math.sin(x_angle)
elif self.flying and not self.dy == 0:
dx = 0.0
dy = self.dy
dz = 0.0
else:
dy = 0.0
dx = 0.0
dz = 0.0
return dx, dy, dz
def init_player_on_summit(self):
"""Make sure the sector containing the actor is loaded and the player is on top of it.
"""
generator = self.model.generator
x, y, z = self.position
free_height = 0
limit = 100
while free_height < PLAYER_HEIGHT and limit:
pos = x , y, z
sector_position = sectorize(pos)
if sector_position not in self.model.sectors:
sector = generator.generate(sector_position)
self.model.register_sector(sector)
if self.model.empty(pos):
free_height += 1
else:
free_height = 0
y = y + 1
limit -= 1
position = x, y - PLAYER_HEIGHT + 1, z
if self.position != position:
self.position = position
self.frustum_updated = True
def update(self, dt):
""" This method is scheduled to be called repeatedly by the pyglet
clock.
Parameters
----------
dt : float
The change in time since the last call.
"""
if not self.initialized:
self.set_exclusive_mouse(True)
has_save = False
if self.scene_manager.save.has_save_game():
# Returns False if unable to load the save
has_save = self.scene_manager.save.load_world(self.model)
if not has_save:
generator = WorldGenerator()
generator.y = self.position[1]
generator.hills_enabled = HILLS_ON
self.model.generator = generator
self.init_player_on_summit()
self.initialized = True
self.model.process_queue()
if self.frustum_updated:
sector = sectorize(self.position)
self.update_shown_sectors(self.position, self.rotation)
self.sector = sector
self.frustum_updated = False
m = 8
dt = min(dt, 0.2)
for _ in range(m):
self._update(dt / m)
def _update(self, dt):
""" Private implementation of the `update()` method. This is where most
of the motion logic lives, along with gravity and collision detection.
Parameters
----------
dt : float
The change in time since the last call.
"""
# walking
speed = FLYING_SPEED if self.flying else RUNNING_SPEED if self.running else WALKING_SPEED
d = dt * speed # distance covered this tick.
dx, dy, dz = self.get_motion_vector()
# New position in space, before accounting for gravity.
dx, dy, dz = dx * d, dy * d, dz * d
# gravity
if not self.flying:
# Update your vertical speed: if you are falling, speed up until you
# hit terminal velocity; if you are jumping, slow down until you
# start falling.
self.dy -= dt * GRAVITY
self.dy = max(self.dy, -TERMINAL_VELOCITY)
dy += self.dy * dt
# collisions
x, y, z = self.position
x, y, z = self.collide((x + dx, y + dy, z + dz), PLAYER_HEIGHT)
position = (x, y, z)
if self.position != position:
self.position = position
self.frustum_updated = True
def collide(self, position, height):
""" Checks to see if the player at the given `position` and `height`
is colliding with any blocks in the world.
Parameters
----------
position : tuple of len 3
The (x, y, z) position to check for collisions at.
height : int or float
The height of the player.
Returns
-------
position : tuple of len 3
The new position of the player taking into account collisions.
"""
# How much overlap with a dimension of a surrounding block you need to
# have to count as a collision. If 0, touching terrain at all counts as
# a collision. If .49, you sink into the ground, as if walking through
# tall DIRT_WITH_GRASS. If >= .5, you'll fall through the ground.
pad = 0.25
p = list(position)
np = normalize(position)
for face in FACES: # check all surrounding blocks
for i in range(3): # check each dimension independently
if not face[i]:
continue
# How much overlap you have with this dimension.
d = (p[i] - np[i]) * face[i]
if d < pad:
continue
for dy in range(height): # check each height
op = list(np)
op[1] -= dy
op[i] += face[i]
if self.model.empty(tuple(op), must_be_loaded=True):
continue
p[i] -= (d - pad) * face[i]
if face == (0, -1, 0) or face == (0, 1, 0):
# You are colliding with the ground or ceiling, so stop
# falling / rising.
self.dy = 0
break
generator = self.model.generator
if generator is None:
# colliding with the virtual floor
# to avoid to fall infinitely.
p[1] = max(-1.25, p[1])
else:
if generator.enclosure:
# Force the player inside the enclosure
s = generator.enclosure_size
if p[0] < -s:
p[0] = -s
elif p[0] > s:
p[0] = s
if p[2] < -s:
p[2] = -s
elif p[2] > s:
p[2] = s
return tuple(p)
def update_shown_sectors(self, position, rotation):
"""Update shown sectors according to the actual frustum.
A sector is a contiguous x, y sub-region of world. Sectors are
used to speed up world rendering.
"""
sector = sectorize(position)
if self.sector == sector:
# The following computation is based on the actual sector
# So if there is no changes on the sector, it have to display
# The exact same thing
return
sectors_to_show = []
pad = int(FOG_END) // SECTOR_SIZE
for dx in range(-pad, pad + 1):
for dy in range(-pad, pad + 1):
for dz in range(-pad, pad + 1):
# Manathan distance
dist = abs(dx) + abs(dy) + abs(dz)
if dist > pad + pad // 2:
# Skip sectors outside of the sphere of radius pad+1
continue
x, y, z = sector
sectors_to_show.append((dist, x + dx, y + dy, z + dz))
# Sort by distance to the player in order to
# displayed closest sectors first
sectors_to_show = sorted(sectors_to_show)
sectors_to_show = [s[1:] for s in sectors_to_show]
self.model.show_only_sectors(sectors_to_show)
def on_mouse_press(self, x, y, button, modifiers):
"""Event handler for the Window.on_mouse_press event.
Called when a mouse button is pressed. See pyglet docs for button
amd modifier mappings.
Parameters
----------
x, y : int
The coordinates of the mouse click. Always center of the screen if
the mouse is captured.
button : int
Number representing mouse button that was clicked. 1 = left button,
4 = right button.
modifiers : int
Number representing any modifying keys that were pressed when the
mouse button was clicked.
"""
if self.exclusive:
vector = self.get_sight_vector()
block, previous = self.model.hit_test(self.position, vector)
if button == mouse.RIGHT or (button == mouse.LEFT and modifiers & key.MOD_CTRL):
# ON OSX, control + left click = right click.
if previous:
self.model.add_block(previous, self.block)
elif button == pyglet.window.mouse.LEFT and block:
texture = self.model.get_block(block)
if texture != BEDSTONE:
self.model.remove_block(block)
self.audio.play(self.destroy_sfx)
else:
self.set_exclusive_mouse(True)
def on_mouse_motion(self, x, y, dx, dy):
"""Event handler for the Window.on_mouse_motion event.
Called when the player moves the mouse.
Parameters
----------
x, y : int
The coordinates of the mouse click. Always center of the screen if
the mouse is captured.
dx, dy : float
The movement of the mouse.
"""
if self.exclusive:
x, y = self.rotation
x, y = x + dx * LOOK_SPEED_X, y + dy * LOOK_SPEED_Y
y = max(-90, min(90, y))
rotation = (x, y)
if self.rotation != rotation:
self.rotation = rotation
self.frustum_updated = True
def on_key_press(self, symbol, modifiers):
"""Event handler for the Window.on_key_press event.
Called when the player presses a key. See pyglet docs for key
mappings.
Parameters
----------
symbol : int
Number representing the key that was pressed.
modifiers : int
Number representing any modifying keys that were pressed.
"""
if symbol == key.W:
self.strafe[0] -= 1
elif symbol == key.S:
self.strafe[0] += 1
elif symbol == key.A:
self.strafe[1] -= 1
elif symbol == key.D:
self.strafe[1] += 1
elif symbol == key.SPACE:
if self.flying:
# Reduces vertical flying speed
# 0.1 positive value that allows vertical flight up.
self.dy = 0.1 * JUMP_SPEED
elif self.dy == 0:
self.dy = JUMP_SPEED
self.audio.play(self.jump_sfx)
elif symbol == key.LCTRL:
self.running = True
elif symbol == key.LSHIFT:
if self.flying:
# Reduces vertical flying speed
# -0.1 negative value that allows vertical flight down.
self.dy = -0.1 * JUMP_SPEED
elif self.dy == 0:
self.dy = JUMP_SPEED
elif symbol == key.ESCAPE:
self.set_exclusive_mouse(False)
return pyglet.event.EVENT_HANDLED
elif symbol == key.TAB:
self.flying = not self.flying
elif symbol == key.F1:
self.scene_manager.change_scene("HelpScene")
elif symbol == key.F2:
self.toggleGui = not self.toggleGui
elif symbol == key.F3:
self.toggleLabel = not self.toggleLabel
elif symbol == key.F5:
self.scene_manager.save.save_world(self.model)
elif symbol == key.F12:
pyglet.image.get_buffer_manager().get_color_buffer().save('screenshot.png')
elif symbol in self.num_keys:
index = (symbol - self.num_keys[0]) % len(self.inventory)
self.block = self.inventory[index]
elif symbol == key.ENTER:
self.scene_manager.change_scene('MenuScene')
def on_key_release(self, symbol, modifiers):
"""Event handler for the Window.on_key_release event.
Called when the player releases a key. See pyglet docs for key
mappings.
Parameters
----------
symbol : int
Number representing the key that was pressed.
modifiers : int
Number representing any modifying keys that were pressed.
"""
if symbol == key.W:
self.strafe[0] += 1
elif symbol == key.S:
self.strafe[0] -= 1
elif symbol == key.A:
self.strafe[1] += 1
elif symbol == key.D:
self.strafe[1] -= 1
elif symbol == key.SPACE:
self.dy = 0
elif symbol == key.LCTRL:
self.running = False
elif symbol == key.LSHIFT:
self.dy = 0
elif symbol == key.P:
breakpoint()
def on_resize(self, width, height):
"""Event handler for the Window.on_resize event.
Called when the window is resized to a new `width` and `height`.
"""
# Reset the info label and reticle positions.
self.info_label.y = height - 10
x, y = width // 2, height // 2
n = 10
self.reticle.vertices[:] = (x - n, y, x + n, y, x, y - n, x, y + n)
def on_draw(self):
"""Event handler for the Window.on_draw event.
Called by pyglet to draw the canvas.
"""
self.window.clear()
# Set the current position/rotation before drawing
self.block_group.position = self.position
self.block_group.rotation = self.rotation
# Draw everything in the batch
self.batch.draw()
# Optionally draw some things
if self.toggleGui:
self.draw_focused_block()
if self.toggleLabel:
self.draw_label()
def get_focus_block(self):
vector = self.get_sight_vector()
block = self.model.hit_test(self.position, vector)[0]
return block
def draw_focused_block(self):
""" Draw black edges around the block that is currently under the
crosshairs.
"""
block = self.get_focus_block()
if block:
x, y, z = block
self.highlight.vertices[:] = cube_vertices(x, y, z, 0.51)
else:
# Make invisible by setting all vertices to 0
self.highlight.vertices[:] = [0] * 72
def draw_label(self):
""" Draw the label in the top left of the screen.
"""
x, y, z = self.position
elements = []
elements.append("FPS = [%02d]" % pyglet.clock.get_fps())
elements.append("COORDS = [%.2f, %.2f, %.2f]" % (x, y, z))
elements.append("SECTORS = %d [+%d]" % (len(self.model.sectors), len(self.model.requested)))
elements.append("BLOCKS = %d" % self.model.count_blocks())
self.info_label.text = ' : '.join(elements)
self.info_label.draw()
class HelpScene(Scene):
def __init__(self, window):
self.window = window
self.batch = pyglet.graphics.Batch()
self.labels = []
self.text_strings = [" GAME OPTIONS",
"* Left click mouse to destroy block",
"* Right click mouse to create block",
"* Press keys 1 through 0 to choose block type",
"* Press F2 key to hide block selection",
"* Press F3 key to hide debug stats"]
self.return_label = pyglet.text.Label("Press any key to return to game", font_size=25,
x=self.window.width // 2, y=20, anchor_x='center',
color=(0, 50, 50, 255), batch=self.batch)
self.spacing = 60
y_position = self.window.height - self.spacing
for string in self.text_strings:
self.labels.append(pyglet.text.Label(string, font_size=22, x=40, y=y_position,
color=(0, 50, 50, 255), batch=self.batch))
y_position -= self.spacing
self.on_resize(*self.window.get_size())
def on_resize(self, width, height):
y_position = height - self.spacing
for label in self.labels:
label.y = y_position
y_position -= self.spacing
def update(self, dt):
pass
def on_key_press(self, symbol, modifiers):
self.scene_manager.change_scene("GameScene")
return pyglet.event.EVENT_HANDLED
def on_draw(self):
self.window.clear()
self.batch.draw()