原文:RealPython
使用 Flask 的基于令牌的认证
原文:https://realpython.com/token-based-authentication-with-flask/
本教程采用测试优先的方法,使用 JSON Web 令牌(jwt)在 Flask 应用程序中实现基于令牌的认证。
更新:
- 08/04/2017 :为 PyBites 挑战赛重构路线处理程序。
目标
本教程结束时,您将能够…
- 讨论使用 jwt 与会话和 cookies 进行身份验证的优势
- 用 JWTs 实现用户认证
- 必要时将用户令牌列入黑名单
- 编写测试来创建和验证 jwt 和用户认证
- 实践测试驱动的开发
免费奖励: 点击此处获得免费的 Flask + Python 视频教程,向您展示如何一步一步地构建 Flask web 应用程序。
简介
JSON Web 令牌(或 JWTs)提供了一种从客户端向服务器传输信息的方式,这是一种安全的无状态的方式。
在服务器上,jwt 是通过使用秘密密钥对用户信息进行签名而生成的,然后安全地存储在客户机上。这种形式的身份验证与现代的单页面应用程序配合得很好。有关这方面的更多信息,以及使用 JWTs 与会话和基于 cookie 的身份验证的优缺点,请查看以下文章:
- 饼干 vs 代币:权威指南
- 令牌认证与 cookie
- 在 Flask 中会话是如何工作的?
**注意:**请记住,由于 JWT 是由签名的,而不是加密的,它不应该包含像用户密码这样的敏感信息。
开始使用
理论够了,开始实现一些代码吧!
项目设置
首先克隆项目样板文件,然后创建一个新的分支:
$ git clone https://github.com/realpython/flask-jwt-auth.git
$ cd flask-jwt-auth
$ git checkout tags/1.0.0 -b jwt-auth
创建并激活 virtualenv 并安装依赖项:
$ python3.6 -m venv env
$ source env/bin/activate
(env)$ pip install -r requirements.txt
这是可选的,但是创建一个新的 Github 存储库并更新 remote 是个好主意:
(env)$ git remote set-url origin <newurl>
数据库设置
让我们设置 Postgres。
注意:如果你在苹果电脑上,看看的 Postgres 应用。
一旦本地 Postgres 服务器运行,从psql
创建两个新的数据库,它们与您的项目名称同名:
(env)$ psql # create database flask_jwt_auth; CREATE DATABASE # create database flask_jwt_auth_test; CREATE DATABASE # \q
注意:根据您的 Postgres 版本,上述创建数据库的命令可能会有一些变化。检查 Postgres 文档中的正确命令。
在应用数据库迁移之前,我们需要更新位于 project/server/config.py 中的配置文件。简单更新一下database_name
:
database_name = 'flask_jwt_auth'
在终端中设置环境变量:
(env)$ export APP_SETTINGS="project.server.config.DevelopmentConfig"
更新project/tests/test _ _ config . py中的以下测试:
class TestDevelopmentConfig(TestCase):
def create_app(self):
app.config.from_object('project.server.config.DevelopmentConfig')
return app
def test_app_is_development(self):
self.assertTrue(app.config['DEBUG'] is True)
self.assertFalse(current_app is None)
self.assertTrue(
app.config['SQLALCHEMY_DATABASE_URI'] == 'postgresql://postgres:@localhost/flask_jwt_auth'
)
class TestTestingConfig(TestCase):
def create_app(self):
app.config.from_object('project.server.config.TestingConfig')
return app
def test_app_is_testing(self):
self.assertTrue(app.config['DEBUG'])
self.assertTrue(
app.config['SQLALCHEMY_DATABASE_URI'] == 'postgresql://postgres:@localhost/flask_jwt_auth_test'
)
运行它们以确保它们仍然通过:
(env)$ python manage.py test
您应该看到:
test_app_is_development (test__config.TestDevelopmentConfig) ... ok
test_app_is_production (test__config.TestProductionConfig) ... ok
test_app_is_testing (test__config.TestTestingConfig) ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.007s
OK
迁移
在“服务器”目录中添加一个 models.py 文件:
# project/server/models.py
import datetime
from project.server import app, db, bcrypt
class User(db.Model):
""" User Model for storing user related details """
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
email = db.Column(db.String(255), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
registered_on = db.Column(db.DateTime, nullable=False)
admin = db.Column(db.Boolean, nullable=False, default=False)
def __init__(self, email, password, admin=False):
self.email = email
self.password = bcrypt.generate_password_hash(
password, app.config.get('BCRYPT_LOG_ROUNDS')
).decode()
self.registered_on = datetime.datetime.now()
self.admin = admin
在上面的代码片段中,我们定义了一个基本的用户模型,它使用 Flask-Bcrypt 扩展来散列密码。
安装 psycopg2 连接到 Postgres:
(env)$ pip install psycopg2==2.6.2
(env)$ pip freeze > requirements.txt
在 manage.py 内更改-
from project.server import app, db
到
from project.server import app, db, models
应用迁移:
(env)$ python manage.py create_db
(env)$ python manage.py db init
(env)$ python manage.py db migrate
健全性检查
成功了吗?
(env)$ psql # \c flask_jwt_auth You are now connected to database "flask_jwt_auth" as user "michael.herman". # \d List of relations Schema | Name | Type | Owner --------+-----------------+----------+----------
public | alembic_version | table | postgres public | users | table | postgres public | users_id_seq | sequence | postgres (3 rows)
JWT 设置
身份验证工作流的工作方式如下:
- 客户端提供电子邮件和密码,发送给服务器
- 然后,服务器验证电子邮件和密码是否正确,并使用一个身份验证令牌进行响应
- 客户端存储令牌,并将其与所有后续请求一起发送给 API
- 服务器解码令牌并验证它
这个循环重复进行,直到令牌过期或被撤销。在后一种情况下,服务器会发出一个新的令牌。
令牌本身分为三个部分:
- 页眉
- 有效载荷
- 签名
我们将更深入地研究有效负载,但是如果您有兴趣,您可以从 JSON Web Tokens 的文章中阅读关于每个部分的更多内容。
要在我们的应用程序中使用 JSON Web 令牌,请安装 PyJWT 包:
(env)$ pip install pyjwt==1.4.2
(env)$ pip freeze > requirements.txt
编码令牌
将下面的方法添加到项目/服务器/模型. py 中的User()
类中:
def encode_auth_token(self, user_id):
"""
Generates the Auth Token
:return: string
"""
try:
payload = {
'exp': datetime.datetime.utcnow() + datetime.timedelta(days=0, seconds=5),
'iat': datetime.datetime.utcnow(),
'sub': user_id
}
return jwt.encode(
payload,
app.config.get('SECRET_KEY'),
algorithm='HS256'
)
except Exception as e:
return e
不要忘记添加导入:
import jwt
因此,给定一个用户 id,这个方法从有效负载和在 config.py 文件中设置的密钥创建并返回一个令牌。负载是我们添加关于令牌的元数据和关于用户的信息的地方。这些信息通常被称为 JWT 声称的。我们利用以下“声明”:
exp
:令牌到期日期iat
:令牌生成的时间sub
:令牌的主题(它标识的用户)
秘密密钥必须是随机的,并且只能在服务器端访问。使用 Python 解释器生成密钥:
>>> import os
>>> os.urandom(24)
b"\xf9'\xe4p(\xa9\x12\x1a!\x94\x8d\x1c\x99l\xc7\xb7e\xc7c\x86\x02MJ\xa0"
将密钥设置为环境变量:
(env)$ export SECRET_KEY="\xf9'\xe4p(\xa9\x12\x1a!\x94\x8d\x1c\x99l\xc7\xb7e\xc7c\x86\x02MJ\xa0"
将此键添加到项目/服务器/配置文件中BaseConfig()
类内的SECRET_KEY
:
SECRET_KEY = os.getenv('SECRET_KEY', 'my_precious')
更新project/tests/test _ _ config . py中的测试,以确保变量设置正确:
def test_app_is_development(self):
self.assertFalse(app.config['SECRET_KEY'] is 'my_precious')
self.assertTrue(app.config['DEBUG'] is True)
self.assertFalse(current_app is None)
self.assertTrue(
app.config['SQLALCHEMY_DATABASE_URI'] == 'postgresql://postgres:@localhost/flask_jwt_auth'
)
class TestTestingConfig(TestCase):
def create_app(self):
app.config.from_object('project.server.config.TestingConfig')
return app
def test_app_is_testing(self):
self.assertFalse(app.config['SECRET_KEY'] is 'my_precious')
self.assertTrue(app.config['DEBUG'])
self.assertTrue(
app.config['SQLALCHEMY_DATABASE_URI'] == 'postgresql://postgres:@localhost/flask_jwt_auth_test'
)
在继续之前,让我们为用户模型编写一个快速的单元测试。将以下代码添加到“项目/测试”中名为 test_user_model.py 的新文件中:
# project/tests/test_user_model.py
import unittest
from project.server import db
from project.server.models import User
from project.tests.base import BaseTestCase
class TestUserModel(BaseTestCase):
def test_encode_auth_token(self):
user = User(
email='test@test.com',
password='test'
)
db.session.add(user)
db.session.commit()
auth_token = user.encode_auth_token(user.id)
self.assertTrue(isinstance(auth_token, bytes))
if __name__ == '__main__':
unittest.main()
进行测试。他们都应该通过。
解码令牌
类似地,要解码一个令牌,将下面的方法添加到User()
类中:
@staticmethod
def decode_auth_token(auth_token):
"""
Decodes the auth token
:param auth_token:
:return: integer|string
"""
try:
payload = jwt.decode(auth_token, app.config.get('SECRET_KEY'))
return payload['sub']
except jwt.ExpiredSignatureError:
return 'Signature expired. Please log in again.'
except jwt.InvalidTokenError:
return 'Invalid token. Please log in again.'
我们需要对每个 API 请求的 auth 令牌进行解码,并验证其签名,以确保用户的真实性。为了验证auth_token
,我们使用了与编码令牌相同的SECRET_KEY
。
如果auth_token
有效,我们从有效载荷的sub
索引中获取用户 id。如果无效,可能有两种例外情况:
- 过期签名:当令牌过期后被使用时,它抛出一个
ExpiredSignatureError
异常。这意味着有效载荷的exp
字段中指定的时间已经过期。 - 无效令牌:当提供的令牌不正确或格式不正确时,就会引发一个
InvalidTokenError
异常。
**注意:**我们使用了一个静态方法,因为它与类的实例无关。
向 test_user_model.py 添加一个测试:
def test_decode_auth_token(self):
user = User(
email='test@test.com',
password='test'
)
db.session.add(user)
db.session.commit()
auth_token = user.encode_auth_token(user.id)
self.assertTrue(isinstance(auth_token, bytes))
self.assertTrue(User.decode_auth_token(auth_token) == 1)
确保在继续之前通过测试。
**注意:**我们稍后将通过将无效令牌列入黑名单来处理它们。
路线设置
现在,我们可以使用测试优先的方法来配置授权路由:
/auth/register
/auth/login
/auth/logout
/auth/user
首先在“项目/服务器”中创建一个名为“auth”的新文件夹。然后,在“auth”内添加两个文件, init。py 和视图。最后,将以下代码添加到 views.py :
# project/server/auth/views.py
from flask import Blueprint, request, make_response, jsonify
from flask.views import MethodView
from project.server import bcrypt, db
from project.server.models import User
auth_blueprint = Blueprint('auth', __name__)
要在应用程序中注册新的蓝图,请将以下内容添加到项目/服务器/init 的底部。py :
from project.server.auth.views import auth_blueprint
app.register_blueprint(auth_blueprint)
现在,在“project/tests”中添加一个名为 test_auth.py 的新文件来保存我们对这个蓝图的所有测试:
# project/tests/test_auth.py
import unittest
from project.server import db
from project.server.models import User
from project.tests.base import BaseTestCase
class TestAuthBlueprint(BaseTestCase):
pass
if __name__ == '__main__':
unittest.main()
注册路线
从一个测试开始:
def test_registration(self):
""" Test for user registration """
with self.client:
response = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['message'] == 'Successfully registered.')
self.assertTrue(data['auth_token'])
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 201)
确保添加导入:
import json
进行测试。您应该会看到以下错误:
raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
现在,让我们编写通过测试的代码。将以下内容添加到project/server/auth/views . py中:
class RegisterAPI(MethodView):
"""
User Registration Resource
"""
def post(self):
# get the post data
post_data = request.get_json()
# check if user already exists
user = User.query.filter_by(email=post_data.get('email')).first()
if not user:
try:
user = User(
email=post_data.get('email'),
password=post_data.get('password')
)
# insert the user
db.session.add(user)
db.session.commit()
# generate the auth token
auth_token = user.encode_auth_token(user.id)
responseObject = {
'status': 'success',
'message': 'Successfully registered.',
'auth_token': auth_token.decode()
}
return make_response(jsonify(responseObject)), 201
except Exception as e:
responseObject = {
'status': 'fail',
'message': 'Some error occurred. Please try again.'
}
return make_response(jsonify(responseObject)), 401
else:
responseObject = {
'status': 'fail',
'message': 'User already exists. Please Log in.',
}
return make_response(jsonify(responseObject)), 202
# define the API resources
registration_view = RegisterAPI.as_view('register_api')
# add Rules for API Endpoints
auth_blueprint.add_url_rule(
'/auth/register',
view_func=registration_view,
methods=['POST']
)
这里,我们注册了一个新用户,并为进一步的请求生成了一个新的 auth token,我们将它发送回客户端。
运行测试以确保它们全部通过:
Ran 6 tests in 0.132s
OK
接下来,让我们再添加一个测试,以确保在用户已经存在的情况下注册失败:
def test_registered_with_already_registered_user(self):
""" Test registration with already registered email"""
user = User(
email='joe@gmail.com',
password='test'
)
db.session.add(user)
db.session.commit()
with self.client:
response = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(
data['message'] == 'User already exists. Please Log in.')
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 202)
在进入下一条路线之前,再次进行测试。一切都会过去。
登录路线
再次,从一个测试开始。为了验证登录 API,让我们测试两种情况:
- 注册用户登录
- 非注册用户登录
注册用户登录
def test_registered_user_login(self):
""" Test for login of registered-user login """
with self.client:
# user registration
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json',
)
data_register = json.loads(resp_register.data.decode())
self.assertTrue(data_register['status'] == 'success')
self.assertTrue(
data_register['message'] == 'Successfully registered.'
)
self.assertTrue(data_register['auth_token'])
self.assertTrue(resp_register.content_type == 'application/json')
self.assertEqual(resp_register.status_code, 201)
# registered user login
response = self.client.post(
'/auth/login',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['message'] == 'Successfully logged in.')
self.assertTrue(data['auth_token'])
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 200)
在这个测试用例中,注册用户试图登录,正如所料,我们的应用程序应该允许这样做。
进行测试。他们应该失败。现在编写代码:
class LoginAPI(MethodView):
"""
User Login Resource
"""
def post(self):
# get the post data
post_data = request.get_json()
try:
# fetch the user data
user = User.query.filter_by(
email=post_data.get('email')
).first()
auth_token = user.encode_auth_token(user.id)
if auth_token:
responseObject = {
'status': 'success',
'message': 'Successfully logged in.',
'auth_token': auth_token.decode()
}
return make_response(jsonify(responseObject)), 200
except Exception as e:
print(e)
responseObject = {
'status': 'fail',
'message': 'Try again'
}
return make_response(jsonify(responseObject)), 500
不要忘记将类转换成视图函数:
# define the API resources
registration_view = RegisterAPI.as_view('register_api')
login_view = LoginAPI.as_view('login_api')
# add Rules for API Endpoints
auth_blueprint.add_url_rule(
'/auth/register',
view_func=registration_view,
methods=['POST']
)
auth_blueprint.add_url_rule(
'/auth/login',
view_func=login_view,
methods=['POST']
)
再次运行测试。他们通过了吗?他们应该。在所有测试通过之前,不要继续前进。
非注册用户登录
添加测试:
def test_non_registered_user_login(self):
""" Test for login of non-registered user """
with self.client:
response = self.client.post(
'/auth/login',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(data['message'] == 'User does not exist.')
self.assertTrue(response.content_type == 'application/json')
self.assertEqual(response.status_code, 404)
在这种情况下,一个未注册的用户试图登录,正如所料,我们的应用程序不应该允许这样做。
运行测试,然后更新代码:
class LoginAPI(MethodView):
"""
User Login Resource
"""
def post(self):
# get the post data
post_data = request.get_json()
try:
# fetch the user data
user = User.query.filter_by(
email=post_data.get('email')
).first()
if user and bcrypt.check_password_hash(
user.password, post_data.get('password')
):
auth_token = user.encode_auth_token(user.id)
if auth_token:
responseObject = {
'status': 'success',
'message': 'Successfully logged in.',
'auth_token': auth_token.decode()
}
return make_response(jsonify(responseObject)), 200
else:
responseObject = {
'status': 'fail',
'message': 'User does not exist.'
}
return make_response(jsonify(responseObject)), 404
except Exception as e:
print(e)
responseObject = {
'status': 'fail',
'message': 'Try again'
}
return make_response(jsonify(responseObject)), 500
我们改变了什么?测试通过了吗?邮件正确但密码不正确怎么办?会发生什么?为此写一个测试!
用户状态路线
为了获得当前登录用户的用户详细信息,auth 令牌必须与请求一起在报头中发送。
从一个测试开始:
def test_user_status(self):
""" Test for user status """
with self.client:
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
response = self.client.get(
'/auth/status',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_register.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['data'] is not None)
self.assertTrue(data['data']['email'] == 'joe@gmail.com')
self.assertTrue(data['data']['admin'] is 'true' or 'false')
self.assertEqual(response.status_code, 200)
测试应该会失败。现在,在处理程序类中,我们应该:
- 提取身份验证令牌并检查其有效性
- 从有效负载中获取用户 id 并获得用户详细信息(当然,如果令牌有效的话)
class UserAPI(MethodView):
"""
User Resource
"""
def get(self):
# get the auth token
auth_header = request.headers.get('Authorization')
if auth_header:
auth_token = auth_header.split(" ")[1]
else:
auth_token = ''
if auth_token:
resp = User.decode_auth_token(auth_token)
if not isinstance(resp, str):
user = User.query.filter_by(id=resp).first()
responseObject = {
'status': 'success',
'data': {
'user_id': user.id,
'email': user.email,
'admin': user.admin,
'registered_on': user.registered_on
}
}
return make_response(jsonify(responseObject)), 200
responseObject = {
'status': 'fail',
'message': resp
}
return make_response(jsonify(responseObject)), 401
else:
responseObject = {
'status': 'fail',
'message': 'Provide a valid auth token.'
}
return make_response(jsonify(responseObject)), 401
因此,如果令牌有效且未过期,我们将从令牌的有效负载中获取用户 id,然后使用它从数据库中获取用户数据。
**注意:**我们仍然需要检查令牌是否被列入黑名单。我们很快就会谈到这一点。
确保添加:
user_view = UserAPI.as_view('user_api')
并且:
auth_blueprint.add_url_rule(
'/auth/status',
view_func=user_view,
methods=['GET']
)
测试应该通过:
Ran 10 tests in 0.240s
OK
还有一条路要走!
注销路由测试
测试有效注销:
def test_valid_logout(self):
""" Test for logout before token expires """
with self.client:
# user registration
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json',
)
data_register = json.loads(resp_register.data.decode())
self.assertTrue(data_register['status'] == 'success')
self.assertTrue(
data_register['message'] == 'Successfully registered.')
self.assertTrue(data_register['auth_token'])
self.assertTrue(resp_register.content_type == 'application/json')
self.assertEqual(resp_register.status_code, 201)
# user login
resp_login = self.client.post(
'/auth/login',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data_login = json.loads(resp_login.data.decode())
self.assertTrue(data_login['status'] == 'success')
self.assertTrue(data_login['message'] == 'Successfully logged in.')
self.assertTrue(data_login['auth_token'])
self.assertTrue(resp_login.content_type == 'application/json')
self.assertEqual(resp_login.status_code, 200)
# valid token logout
response = self.client.post(
'/auth/logout',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_login.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'success')
self.assertTrue(data['message'] == 'Successfully logged out.')
self.assertEqual(response.status_code, 200)
在第一个测试中,我们注册了一个新用户,让他们登录,然后尝试在令牌过期之前让他们注销。
测试无效注销:
def test_invalid_logout(self):
""" Testing logout after the token expires """
with self.client:
# user registration
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json',
)
data_register = json.loads(resp_register.data.decode())
self.assertTrue(data_register['status'] == 'success')
self.assertTrue(
data_register['message'] == 'Successfully registered.')
self.assertTrue(data_register['auth_token'])
self.assertTrue(resp_register.content_type == 'application/json')
self.assertEqual(resp_register.status_code, 201)
# user login
resp_login = self.client.post(
'/auth/login',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data_login = json.loads(resp_login.data.decode())
self.assertTrue(data_login['status'] == 'success')
self.assertTrue(data_login['message'] == 'Successfully logged in.')
self.assertTrue(data_login['auth_token'])
self.assertTrue(resp_login.content_type == 'application/json')
self.assertEqual(resp_login.status_code, 200)
# invalid token logout
time.sleep(6)
response = self.client.post(
'/auth/logout',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_login.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(
data['message'] == 'Signature expired. Please log in again.')
self.assertEqual(response.status_code, 401)
像上一个测试一样,我们注册一个用户,让他们登录,然后尝试让他们注销。在这种情况下,令牌无效,因为它已经过期。
添加导入:
import time
现在,代码必须:
- 验证身份验证令牌
- 将令牌列入黑名单(当然,如果有效的话)
在编写路由处理程序之前,让我们为黑名单令牌创建一个新模型…
黑名单
将以下代码添加到项目/服务器/模型. py 中:
class BlacklistToken(db.Model):
"""
Token Model for storing JWT tokens
"""
__tablename__ = 'blacklist_tokens'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
token = db.Column(db.String(500), unique=True, nullable=False)
blacklisted_on = db.Column(db.DateTime, nullable=False)
def __init__(self, token):
self.token = token
self.blacklisted_on = datetime.datetime.now()
def __repr__(self):
return '<id: token: {}'.format(self.token)
然后创建并应用迁移。完成后,您的数据库应该包含以下表格:
Schema | Name | Type | Owner --------+-------------------------+----------+----------
public | alembic_version | table | postgres public | blacklist_tokens | table | postgres public | blacklist_tokens_id_seq | sequence | postgres public | users | table | postgres public | users_id_seq | sequence | postgres (5 rows)
这样,我们可以添加注销处理程序…
注销路由处理程序
更新视图:
class LogoutAPI(MethodView):
"""
Logout Resource
"""
def post(self):
# get auth token
auth_header = request.headers.get('Authorization')
if auth_header:
auth_token = auth_header.split(" ")[1]
else:
auth_token = ''
if auth_token:
resp = User.decode_auth_token(auth_token)
if not isinstance(resp, str):
# mark the token as blacklisted
blacklist_token = BlacklistToken(token=auth_token)
try:
# insert the token
db.session.add(blacklist_token)
db.session.commit()
responseObject = {
'status': 'success',
'message': 'Successfully logged out.'
}
return make_response(jsonify(responseObject)), 200
except Exception as e:
responseObject = {
'status': 'fail',
'message': e
}
return make_response(jsonify(responseObject)), 200
else:
responseObject = {
'status': 'fail',
'message': resp
}
return make_response(jsonify(responseObject)), 401
else:
responseObject = {
'status': 'fail',
'message': 'Provide a valid auth token.'
}
return make_response(jsonify(responseObject)), 403
# define the API resources
registration_view = RegisterAPI.as_view('register_api')
login_view = LoginAPI.as_view('login_api')
user_view = UserAPI.as_view('user_api')
logout_view = LogoutAPI.as_view('logout_api')
# add Rules for API Endpoints
auth_blueprint.add_url_rule(
'/auth/register',
view_func=registration_view,
methods=['POST']
)
auth_blueprint.add_url_rule(
'/auth/login',
view_func=login_view,
methods=['POST']
)
auth_blueprint.add_url_rule(
'/auth/status',
view_func=user_view,
methods=['GET']
)
auth_blueprint.add_url_rule(
'/auth/logout',
view_func=logout_view,
methods=['POST']
)
更新导入:
from project.server.models import User, BlacklistToken
当用户注销时,令牌不再有效,因此我们将其添加到黑名单中。
**注意:**通常,较大的应用程序有办法不时更新列入黑名单的令牌,以便系统不会用完有效令牌。
运行测试:
Ran 12 tests in 6.418s
OK
重构
最后,我们需要确保令牌没有被列入黑名单,就在令牌被解码之后- decode_auth_token()
-在注销和用户状态路由中。
首先,让我们为注销路由编写一个测试:
def test_valid_blacklisted_token_logout(self):
""" Test for logout after a valid token gets blacklisted """
with self.client:
# user registration
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json',
)
data_register = json.loads(resp_register.data.decode())
self.assertTrue(data_register['status'] == 'success')
self.assertTrue(
data_register['message'] == 'Successfully registered.')
self.assertTrue(data_register['auth_token'])
self.assertTrue(resp_register.content_type == 'application/json')
self.assertEqual(resp_register.status_code, 201)
# user login
resp_login = self.client.post(
'/auth/login',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
data_login = json.loads(resp_login.data.decode())
self.assertTrue(data_login['status'] == 'success')
self.assertTrue(data_login['message'] == 'Successfully logged in.')
self.assertTrue(data_login['auth_token'])
self.assertTrue(resp_login.content_type == 'application/json')
self.assertEqual(resp_login.status_code, 200)
# blacklist a valid token
blacklist_token = BlacklistToken(
token=json.loads(resp_login.data.decode())['auth_token'])
db.session.add(blacklist_token)
db.session.commit()
# blacklisted valid token logout
response = self.client.post(
'/auth/logout',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_login.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(data['message'] == 'Token blacklisted. Please log in again.')
self.assertEqual(response.status_code, 401)
在这个测试中,我们在注销路由命中之前将令牌列入黑名单,这使得我们的有效令牌不可用。
更新导入:
from project.server.models import User, BlacklistToken
测试应该会失败,并出现以下异常:
psycopg2.IntegrityError: duplicate key value violates unique constraint "blacklist_tokens_token_key"
DETAIL: Key (token)=(eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE0ODUyMDgyOTUsImlhdCI6MTQ4NTIwODI5MCwic3ViIjoxfQ.D9annoyh-VwpI5RY3blaSBX4pzK5UJi1H9dmKg2DeLQ) already exists.
现在更新decode_auth_token
函数,以便在解码后立即处理已经列入黑名单的令牌,并使用适当的消息进行响应。
@staticmethod
def decode_auth_token(auth_token):
"""
Validates the auth token
:param auth_token:
:return: integer|string
"""
try:
payload = jwt.decode(auth_token, app.config.get('SECRET_KEY'))
is_blacklisted_token = BlacklistToken.check_blacklist(auth_token)
if is_blacklisted_token:
return 'Token blacklisted. Please log in again.'
else:
return payload['sub']
except jwt.ExpiredSignatureError:
return 'Signature expired. Please log in again.'
except jwt.InvalidTokenError:
return 'Invalid token. Please log in again.'
最后,将check_blacklist()
函数添加到BlacklistToken
类中的项目/服务器/模型. py 中:
@staticmethod
def check_blacklist(auth_token):
# check whether auth token has been blacklisted
res = BlacklistToken.query.filter_by(token=str(auth_token)).first()
if res:
return True
else:
return False
在运行测试之前,更新test_decode_auth_token
将 bytes 对象转换成一个字符串:
def test_decode_auth_token(self):
user = User(
email='test@test.com',
password='test'
)
db.session.add(user)
db.session.commit()
auth_token = user.encode_auth_token(user.id)
self.assertTrue(isinstance(auth_token, bytes))
self.assertTrue(User.decode_auth_token(
auth_token.decode("utf-8") ) == 1)
运行测试:
Ran 13 tests in 9.557s
OK
以类似的方式,为用户状态路由再添加一个测试。
def test_valid_blacklisted_token_user(self):
""" Test for user status with a blacklisted valid token """
with self.client:
resp_register = self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json'
)
# blacklist a valid token
blacklist_token = BlacklistToken(
token=json.loads(resp_register.data.decode())['auth_token'])
db.session.add(blacklist_token)
db.session.commit()
response = self.client.get(
'/auth/status',
headers=dict(
Authorization='Bearer ' + json.loads(
resp_register.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(data['message'] == 'Token blacklisted. Please log in again.')
self.assertEqual(response.status_code, 401)
与上一个测试类似,我们在用户状态路由命中之前将令牌列入黑名单。
最后一次运行测试:
Ran 14 tests in 10.206s
OK
代码气味
最后看一下 test_auth.py 。注意到重复的代码了吗?例如:
self.client.post(
'/auth/register',
data=json.dumps(dict(
email='joe@gmail.com',
password='123456'
)),
content_type='application/json',
)
这种情况出现了八次。要修复此问题,请在文件顶部添加以下助手:
def register_user(self, email, password):
return self.client.post(
'/auth/register',
data=json.dumps(dict(
email=email,
password=password
)),
content_type='application/json',
)
现在,在任何需要注册用户的地方,您都可以呼叫助手:
register_user(self, 'joe@gmail.com', '123456')
登录一个用户怎么样?自己重构它。还能重构什么?下面评论。
重构
对于 PyBites 挑战,让我们重构一些代码来纠正添加到 GitHub repo 中的一个问题。首先向 test_auth.py 添加以下测试:
def test_user_status_malformed_bearer_token(self):
""" Test for user status with malformed bearer token"""
with self.client:
resp_register = register_user(self, 'joe@gmail.com', '123456')
response = self.client.get(
'/auth/status',
headers=dict(
Authorization='Bearer' + json.loads(
resp_register.data.decode()
)['auth_token']
)
)
data = json.loads(response.data.decode())
self.assertTrue(data['status'] == 'fail')
self.assertTrue(data['message'] == 'Bearer token malformed.')
self.assertEqual(response.status_code, 401)
本质上,如果Authorization
头的格式不正确,就会抛出一个错误——例如,Bearer
和令牌值之间没有空格。运行测试以确保它们失败,然后更新project/server/auth/views . py中的UserAPI
类:
class UserAPI(MethodView):
"""
User Resource
"""
def get(self):
# get the auth token
auth_header = request.headers.get('Authorization')
if auth_header:
try:
auth_token = auth_header.split(" ")[1]
except IndexError:
responseObject = {
'status': 'fail',
'message': 'Bearer token malformed.'
}
return make_response(jsonify(responseObject)), 401
else:
auth_token = ''
if auth_token:
resp = User.decode_auth_token(auth_token)
if not isinstance(resp, str):
user = User.query.filter_by(id=resp).first()
responseObject = {
'status': 'success',
'data': {
'user_id': user.id,
'email': user.email,
'admin': user.admin,
'registered_on': user.registered_on
}
}
return make_response(jsonify(responseObject)), 200
responseObject = {
'status': 'fail',
'message': resp
}
return make_response(jsonify(responseObject)), 401
else:
responseObject = {
'status': 'fail',
'message': 'Provide a valid auth token.'
}
return make_response(jsonify(responseObject)), 401
最后一次测试。
结论
在本教程中,我们经历了使用 JSON Web 令牌向 Flask 应用程序添加身份验证的过程。回到本教程开头的目标。你能把每一个都付诸行动吗?你学到了什么?
下一步是什么?客户端怎么样?查看使用 Angular 的基于令牌的认证,将 Angular 添加到组合中。
要了解如何使用 Flask 从头构建一个完整的 web 应用程序,请查看我们的视频系列:
免费奖励: 点击此处获得免费的 Flask + Python 视频教程,向您展示如何一步一步地构建 Flask web 应用程序。
欢迎在下面的评论中分享你的评论、问题或建议。完整的代码可以在 flask-jwt-auth 存储库中找到。
干杯!********
顶级 Python 游戏引擎
和很多人一样,也许你刚开始学编码的时候想写视频游戏。但是那些游戏和你玩过的游戏一样吗?可能你刚开始的时候没有 Python,没有 Python 游戏可供你学习,也没有游戏引擎可言。没有真正的指导或框架来帮助你,你在其他游戏中体验到的高级图形和声音可能仍然遥不可及。
现在,有了 Python,以及大量优秀的 Python 游戏引擎。这种强大的组合使得制作优秀的电脑游戏比过去容易得多。在本教程中,您将探索其中的几个游戏引擎,了解开始制作您自己的 Python 视频游戏所需的东西!
本文结束时,你将:
- 了解几款流行的 Python 游戏引擎的优劣
- 看看这些游戏引擎的运行
- 理解他们如何比较和独立游戏引擎
- 了解其他可用的 Python 游戏引擎
为了从本教程中获得最大收益,您应该精通 Python 编程,包括面向对象编程。理解基本的游戏概念是有帮助的,但不是必需的。
准备好开始了吗?单击下面的链接下载您将创建的所有游戏的源代码:
获取源代码: 点击此处获取您将使用试用 Python 游戏引擎的源代码。
Python 游戏引擎概述
Python 的游戏引擎通常采用 Python 库的形式,可以通过多种方式安装。大多数在 PyPI 上有,可以和 pip
一起安装。但是,有一些只在 GitHub、GitLab 或其他代码共享位置上可用,它们可能需要其他安装步骤。本文将涵盖所有讨论过的引擎的安装方法。
Python 是一种通用编程语言,除了编写计算机游戏之外,它还用于各种任务。相比之下,有许多不同的单机游戏引擎是专门为编写游戏而定制的。其中包括:
这些独立游戏引擎在几个关键方面不同于 Python 游戏引擎:
- **语言支持:**像 C++、C#和 JavaScript 这样的语言在独立游戏引擎中编写的游戏中很受欢迎,因为引擎本身通常是用这些语言编写的。很少有独立引擎支持 Python。
- **专有脚本支持:**另外,很多单机游戏引擎都维护和支持自己的脚本语言,可能不像 Python。比如 Unity 原生使用 C#,Unreal 用 C++效果最好。
- **平台支持:**很多现代单机游戏引擎都可以不费吹灰之力制作出适用于多种平台的游戏,包括移动和专用游戏系统。相比之下,将 Python 游戏移植到各种平台,尤其是移动平台,可能是一项艰巨的任务。
- **许可选项:**根据所使用的引擎,使用独立游戏引擎编写的游戏可能会有不同的许可选项和限制。
那么为什么要用 Python 来写游戏呢?一句话,Python。使用单机游戏引擎往往需要你学习一门新的编程或脚本语言。Python 游戏引擎利用您现有的 Python 知识,缩短学习曲线,让您快速前进。
有许多游戏引擎可用于 Python 环境。您将在此了解的所有引擎都具有以下标准:
- 它们是相对受欢迎的引擎,或者它们涵盖了游戏中通常不被涉及的方面。
- 它们目前被维护着。
- 他们有很好的文件可用。
对于每种引擎,您将了解到:
- 安装方法
- 基本概念,以及引擎做出的假设
- 主要特性和功能
- 两个游戏实现,为了便于比较
在适当的地方,你应该在一个虚拟环境中安装这些游戏引擎。本教程中游戏的完整源代码可以从下面的链接下载,并将在整篇文章中引用:
获取源代码: 点击此处获取您将使用试用 Python 游戏引擎的源代码。
下载完源代码后,您就可以开始了。
Pygame
当人们想到 Python 游戏引擎时,许多人的第一个想法是 Pygame 。事实上,在 Real Python 网站上已经有了关于 Pygame 的很棒的初级读本。
作为停滞不前的 PySDL 库的替代品,Pygame 包装并扩展了 SDL 库,它代表简单直接媒体层。SDL 提供对系统底层多媒体硬件组件的跨平台访问,如声音、视频、鼠标、键盘和操纵杆。SDL 和 Pygame 的跨平台特性意味着你可以为每一个支持它们的平台编写游戏和丰富的多媒体 Python 程序!
Pygame 安装
PyPI 上有 Pygame,所以在创建并激活一个虚拟环境后,您可以使用适当的pip
命令来安装它:
(venv) $ python -m pip install pygame
完成后,您可以通过运行库附带的示例来验证安装:
(venv) $ python -m pygame.examples.aliens
现在你已经安装了 Pygame,你可以马上开始使用它。如果您在安装过程中遇到问题,那么入门指南概述了一些已知问题和所有平台的可能解决方案。
基本概念
Pygame 被组织成几个不同的模块,这些模块提供了对计算机图形、声音和输入硬件的抽象访问。Pygame 还定义了许多类,这些类封装了与硬件无关的概念。例如,在Surface
对象上绘图,其矩形界限由其Rect
对象定义。
每个游戏都利用一个游戏循环来控制游戏的进行。这个循环随着游戏的进行不断迭代。Pygame 提供了实现游戏循环的方法和函数,但是它没有自动提供。游戏作者应该实现游戏循环的功能。
游戏循环的每次迭代被称为一个帧。每一帧,游戏执行四个重要动作:
-
处理用户输入。使用事件模型处理 Pygame 中的用户输入。鼠标和键盘输入会生成事件,这些事件可以被读取和处理,也可以被忽略。Pygame 本身不提供任何事件处理程序。
-
更新游戏对象的状态。游戏对象可以使用任何 Pygame 数据结构或特殊的 Pygame 类来表示。诸如精灵、图像、字体和颜色等对象可以在 Python 中创建和扩展,以提供尽可能多的状态信息。
-
更新显示和音频输出。 Pygame 提供了对显示器和声音硬件的抽象访问。
display
、mixer
和music
模块允许游戏作者在游戏设计和实现中具有灵活性。 -
保持游戏速度。 Pygame 的
time
模块允许游戏作者控制游戏速度。通过确保每一帧在指定的时间限制内完成,游戏作者可以确保游戏在不同的硬件上类似地运行。
你可以在一个基本的例子中看到这些概念的结合。
基本应用
这个基本的 Pygame 程序在屏幕上绘制一些形状和一些文本:
此示例的代码可以在下面的可下载资料中找到:
1"""
2Basic "Hello, World!" program in Pygame
3
4This program is designed to demonstrate the basic capabilities
5of Pygame. It will:
6- Create a game window
7- Fill the background with white
8- Draw some basic shapes in different colors
9- Draw some text in a specified size and color
10- Allow you to close the window
11"""
12
13# Import and initialize the pygame library
14import pygame
15
16pygame.init()
17
18# Set the width and height of the output window, in pixels
19WIDTH = 800
20HEIGHT = 600
21
22# Set up the drawing window
23screen = pygame.display.set_mode([WIDTH, HEIGHT])
24
25# Run until the user asks to quit
26running = True
27while running:
28
29 # Did the user click the window close button?
30 for event in pygame.event.get():
31 if event.type == pygame.QUIT:
32 running = False
33
34 # Fill the background with white
35 screen.fill((255, 255, 255))
36
37 # Draw a blue circle with a radius of 50 in the center of the screen
38 pygame.draw.circle(screen, (0, 0, 255), (WIDTH // 2, HEIGHT // 2), 50)
39
40 # Draw a red-outlined square in the top-left corner of the screen
41 red_square = pygame.Rect((50, 50), (100, 100))
42 pygame.draw.rect(screen, (200, 0, 0), red_square, 1)
43
44 # Draw an orange caption along the bottom in 60-point font
45 text_font = pygame.font.SysFont("any_font", 60)
46 text_block = text_font.render(
47 "Hello, World! From Pygame", False, (200, 100, 0)
48 )
49 screen.blit(text_block, (50, HEIGHT - 50))
50
51 # Flip the display
52 pygame.display.flip()
53
54# Done! Time to quit.
55pygame.quit()
尽管它的期望很低,但即使是这个基本的 Pygame 程序也需要一个游戏循环和事件处理程序。游戏循环从线 27 开始,由running
变量控制。将该变量设置为False
将结束程序。
事件处理从第 30 行的开始,伴随着事件循环**。使用pygame.event.get()
从队列中检索事件,并在每次循环迭代中一次处理一个事件。在这种情况下,唯一被处理的事件是pygame.QUIT
事件,它是在用户关闭游戏窗口时生成的。当这个事件被处理时,你设置running = False
,这将最终结束游戏循环和程序。**
Pygame 提供了各种绘制基本形状的方法,比如圆形和矩形。在该示例中,在第 38 条的线上画了一个蓝色圆圈,在第 41 和 42 条的线上画了一个红色方块。请注意,绘制矩形需要您首先创建一个Rect
对象。
在屏幕上绘制文本稍微复杂一些。首先,在第 45 行上,选择一种字体并创建一个font
对象。在第 46 到 48 行上使用该字体,调用.render()
方法。这将创建一个包含以指定字体和颜色呈现的文本的Surface
对象。最后,使用行 49 上的.blit()
方法将Surface
复制到屏幕上。
游戏循环的结束发生在线 52 处,此时先前绘制的所有内容都显示在显示器上。没有这一行,什么都不会显示。
要运行此代码,请使用以下命令:
(venv) $ python pygame/pygame_basic.py
您应该会看到一个窗口,上面显示的图像。恭喜你!您刚刚运行了您的第一个 Pygame 程序!
高级应用程序
当然,Pygame 是为用 Python 写游戏而设计的。为了探究一个实际的 Pygame 游戏的功能和需求,您将通过以下细节来检查一个用 Pygame 编写的游戏:
- 玩家是屏幕上的一个精灵,通过移动鼠标来控制。
- 每隔一段时间,硬币就会一个接一个地出现在屏幕上。
- 当玩家移动每枚硬币时,硬币消失,玩家获得 10 分。
- 随着游戏的进行,硬币会更快地加入。
- 当屏幕上出现十个以上的硬币时,游戏结束。
完成后,游戏看起来会像这样:
这个游戏的完整代码可以在下面的下载资料中找到:
1"""
2Complete Game in Pygame
3
4This game demonstrates some of the more advanced features of
5Pygame, including:
6- Using sprites to render complex graphics
7- Handling user mouse input
8- Basic sound output
9"""
10
11# Import and initialize the pygame library
12import pygame
13
14# To randomize coin placement
15from random import randint
16
17# To find your assets
18from pathlib import Path
19
20# For type hinting
21from typing import Tuple
22
23# Set the width and height of the output window, in pixels
24WIDTH = 800
25HEIGHT = 600
26
27# How quickly do you generate coins? Time is in milliseconds
28coin_countdown = 2500
29coin_interval = 100
30
31# How many coins can be on the screen before you end?
32COIN_COUNT = 10
33
34# Define the Player sprite
35class Player(pygame.sprite.Sprite):
36 def __init__(self):
37 """Initialize the player sprite"""
38 super(Player, self).__init__()
39
40 # Get the image to draw for the player
41 player_image = str(
42 Path.cwd() / "pygame" / "images" / "alien_green_stand.png"
43 )
44 # Load the image, preserve alpha channel for transparency
45 self.surf = pygame.image.load(player_image).convert_alpha()
46 # Save the rect so you can move it
47 self.rect = self.surf.get_rect()
48
49 def update(self, pos: Tuple):
50 """Update the position of the player
51
52 Arguments:
53 pos {Tuple} -- the (X,Y) position to move the player
54 """
55 self.rect.center = pos
56
57# Define the Coin sprite
58class Coin(pygame.sprite.Sprite):
59 def __init__(self):
60 """Initialize the coin sprite"""
61 super(Coin, self).__init__()
62
63 # Get the image to draw for the coin
64 coin_image = str(Path.cwd() / "pygame" / "images" / "coin_gold.png")
65
66 # Load the image, preserve alpha channel for transparency
67 self.surf = pygame.image.load(coin_image).convert_alpha()
68
69 # The starting position is randomly generated
70 self.rect = self.surf.get_rect(
71 center=(
72 randint(10, WIDTH - 10),
73 randint(10, HEIGHT - 10),
74 )
75 )
76
77# Initialize the Pygame engine
78pygame.init()
79
80# Set up the drawing window
81screen = pygame.display.set_mode(size=[WIDTH, HEIGHT])
82
83# Hide the mouse cursor
84pygame.mouse.set_visible(False)
85
86# Set up the clock for a decent frame rate
87clock = pygame.time.Clock()
88
89# Create a custom event for adding a new coin
90ADDCOIN = pygame.USEREVENT + 1
91pygame.time.set_timer(ADDCOIN, coin_countdown)
92
93# Set up the coin_list
94coin_list = pygame.sprite.Group()
95
96# Initialize the score
97score = 0
98
99# Set up the coin pickup sound
100coin_pickup_sound = pygame.mixer.Sound(
101 str(Path.cwd() / "pygame" / "sounds" / "coin_pickup.wav")
102)
103
104# Create a player sprite and set its initial position
105player = Player()
106player.update(pygame.mouse.get_pos())
107
108# Run until you get to an end condition
109running = True
110while running:
111
112 # Did the user click the window close button?
113 for event in pygame.event.get():
114 if event.type == pygame.QUIT:
115 running = False
116
117 # Should you add a new coin?
118 elif event.type == ADDCOIN:
119 # Create a new coin and add it to the coin_list
120 new_coin = Coin()
121 coin_list.add(new_coin)
122
123 # Speed things up if fewer than three coins are on-screen
124 if len(coin_list) < 3:
125 coin_countdown -= coin_interval
126 # Need to have some interval
127 if coin_countdown < 100:
128 coin_countdown = 100
129
130 # Stop the previous timer by setting the interval to 0
131 pygame.time.set_timer(ADDCOIN, 0)
132
133 # Start a new timer
134 pygame.time.set_timer(ADDCOIN, coin_countdown)
135
136 # Update the player position
137 player.update(pygame.mouse.get_pos())
138
139 # Check if the player has collided with a coin, removing the coin if so
140 coins_collected = pygame.sprite.spritecollide(
141 sprite=player, group=coin_list, dokill=True
142 )
143 for coin in coins_collected:
144 # Each coin is worth 10 points
145 score += 10
146 # Play the coin collected sound
147 coin_pickup_sound.play()
148
149 # Are there too many coins on the screen?
150 if len(coin_list) >= COIN_COUNT:
151 # This counts as an end condition, so you end your game loop
152 running = False
153
154 # To render the screen, first fill the background with pink
155 screen.fill((255, 170, 164))
156
157 # Draw the coins next
158 for coin in coin_list:
159 screen.blit(coin.surf, coin.rect)
160
161 # Then draw the player
162 screen.blit(player.surf, player.rect)
163
164 # Finally, draw the score at the bottom left
165 score_font = pygame.font.SysFont("any_font", 36)
166 score_block = score_font.render(f"Score: {score}", False, (0, 0, 0))
167 screen.blit(score_block, (50, HEIGHT - 50))
168
169 # Flip the display to make everything appear
170 pygame.display.flip()
171
172 # Ensure you maintain a 30 frames per second rate
173 clock.tick(30)
174
175# Done! Print the final score
176print(f"Game over! Final score: {score}")
177
178# Make the mouse visible again
179pygame.mouse.set_visible(True)
180
181# Quit the game
182pygame.quit()
Pygame 中的精灵提供了一些基本的功能,但是它们被设计成子类而不是独立使用。Pygame 精灵默认没有关联的图片,也不能自己定位。
为了正确地抽取和管理玩家和屏幕上的硬币,在第 35 到 55 行的上创建了一个Player
类,在第 58 到 75 行的上创建了一个Coin
类。当每个 sprite 对象被创建时,它首先定位并加载它将显示的图像,保存在self.surf
中。属性在屏幕上定位和移动精灵。
定期向屏幕添加硬币是通过计时器完成的。在 Pygame 中,每当定时器到期时都会触发事件,游戏创建者可以将自己的事件定义为整数常量。在线 90 上定义ADDCOIN
事件,定时器在线 91 上coin_countdown
毫秒后触发事件。
由于ADDCOIN
是一个事件,它需要在一个事件循环中处理,这发生在的第 118 到 134 行。该事件创建一个新的Coin
对象,并将其添加到现有的coin_list
中。检查屏幕上的硬币数量。如果少于三个,则coin_countdown
减少。最后,前一个计时器停止,新的计时器开始计时。
当玩家移动时,它们会与硬币碰撞,一边碰撞一边收集硬币。这将自动从coin_list
中移除每个收集的硬币。这也会更新乐谱并播放声音。
玩家移动发生在线 137 。在行 140 到 142 检查与屏幕上硬币的碰撞。dokill=True
参数自动从coin_list
中取出硬币。最后,第 143 到 147 行更新分数并为收集到的每枚硬币播放声音。
当用户关闭窗口,或者当屏幕上出现十个以上的硬币时,游戏结束。在行 150 到 152 上检查十个以上的硬币。
因为 Pygame 精灵没有内置的图像知识,他们也不知道如何在屏幕上画自己。游戏作者需要清空屏幕,按照正确的顺序画出所有的精灵,画出屏幕上的分数,然后.flip()
显示器让一切出现。这一切都发生在第 155 到 170 行。
Pygame 是一个非常强大和完善的库,但是它也有缺点。Pygame 让游戏作者工作来得到他们的结果。由游戏作者来实现基本的精灵行为,并实现游戏循环和基本事件处理程序等关键的游戏要求。接下来,您将看到其他游戏引擎如何提供类似的结果,同时减少您必须做的工作量。
Pygame Zero
Pygame 在许多方面做得很好,但在其他方面却显而易见。对于游戏写作初学者来说,更好的选择可以在 Pygame Zero 找到。Pygame Zero 专为教育而设计,由一套简单的原则指导,旨在为年轻和刚入门的程序员提供完美的服务:
- **使其可访问:**一切都是为初级程序员设计的。
- **保守一点:**支持通用平台,避免实验特性。
- 工作就是了:确保一切正常,不要大惊小怪。
- **最小化运行成本:**如果某些东西可能会失败,那就尽早失败。
- **错误明显:**没有什么比不知道为什么会出错更糟糕的了。
- 做好文档:一个框架只有和它的文档一样好。
- 最小化突破性的改变:升级不需要重写你的游戏。
Pygame Zero 的文档对于初级程序员来说非常容易理解,它包括一个完整的逐步教程。此外,Pygame Zero 团队认识到许多初学编程的人是从 Scratch 开始编码的,所以他们给提供了一个教程,演示如何将 Scratch 程序迁移到 Pygame Zero。
Pygame 零安装
Pygame Zero 在 PyPI 上可用,您可以像在 Windows、macOS 或 Linux 上安装任何其他 Python 库一样安装它:
(venv) $ python -m pip install pgzero
Pygame Zero,顾名思义,是建立在 Pygame 之上的,所以这一步也是将 Pygame 作为依赖库安装。Pygame Zero 默认安装在 Raspberry Pi 平台上,在 Raspbian Jessie 或更高版本上。
基本概念
Pygame Zero 自动化了许多程序员在 Pygame 中必须手动完成的事情。默认情况下,Pygame Zero 为游戏创建者提供:
- 一个游戏循环,所以没必要写
- 处理绘图、更新和输入处理的事件模型
- 统一的图像、文本和声音处理
- 一个可用的精灵类和用户精灵的动画方法
由于这些规定,一个基本的 Pygame Zero 程序可能会非常短:
1"""
2Basic "Hello, World!" program in Pygame Zero
3
4This program is designed to demonstrate the basic capabilities
5of Pygame Zero. It will:
6- Create a game window
7- Fill the background with white
8- Draw some basic shapes in different colors
9- Draw some text in a specified size and color
10"""
11
12# Import pgzrun allows the program to run in Python IDLE
13import pgzrun
14
15# Set the width and height of your output window, in pixels
16WIDTH = 800
17HEIGHT = 600
18
19def draw():
20 """Draw is called once per frame to render everything on the screen"""
21
22 # Clear the screen first
23 screen.clear()
24
25 # Set the background color to white
26 screen.fill("white")
27
28 # Draw a blue circle with a radius of 50 in the center of the screen
29 screen.draw.filled_circle(
30 (WIDTH // 2, HEIGHT // 2), 50, "blue"
31 )
32
33 # Draw a red-outlined square in the top-left corner of the screen
34 red_square = Rect((50, 50), (100, 100))
35 screen.draw.rect(red_square, (200, 0, 0))
36
37 # Draw an orange caption along the bottom in 60-point font
38 screen.draw.text(
39 "Hello, World! From Pygame Zero!",
40 (100, HEIGHT - 50),
41 fontsize=60,
42 color="orange",
43 )
44
45# Run the program
46pgzrun.go()
Pygame Zero 识别出第 16 行和第 17 行上的常量WIDTH
和HEIGHT
指的是窗口的大小,并自动使用这些尺寸来创建窗口。另外,Pygame Zero 提供了一个内置的游戏循环,并且每帧调用一次行 19 到 43 上定义的draw()
函数来渲染屏幕。
因为 Pygame Zero 基于 Pygame,所以继承了一些形状绘制代码。你可以看到在第 29 行上画圆和在第 34 到 35 行上画正方形的相似之处:
然而,文本绘制现在是对第 38 到 43 行的单个函数调用,而不是三个独立的函数。
Pygame Zero 还提供了基本的窗口处理代码,因此您可以通过单击适当的关闭按钮来关闭窗口,而不需要事件处理程序。
您可以在可下载的资料中找到演示 Pygame Zero 的一些基本功能的代码:
获取源代码: 点击此处获取您将使用试用 Python 游戏引擎的源代码。
运行 Pygame Zero 程序是从命令行使用以下命令完成的:
(venv) $ python pygame_zero/pygame_zero_basic.py
运行此命令将启动您的 Pygame Zero 游戏。您应该会看到一个窗口,显示基本形状和您的 Pygame Zero 问候语。
精灵和图像
精灵在 Pygame Zero 中被称为演员,他们有一些需要解释的特征:
- Pygame Zero 提供了
Actor
类。每个Actor
至少有一个图像和一个位置。 - Pygame Zero 程序中使用的所有图像必须位于名为img/`的子文件夹中,并且只能使用小写字母、数字和下划线命名。
- 仅使用图像的基本名称引用图像。例如,如果你的图像被称为
alien.png
,你在你的程序中引用它为"alien"
。
由于 Pygame Zero 的这些内置特性,在屏幕上绘制精灵只需要很少的代码:
1alien = Actor("alien")
2alien.pos = 100, 56
3
4WIDTH = 500
5HEIGHT = alien.height + 20
6
7def draw():
8 screen.clear()
9 alien.draw()
现在,您将逐行分解这个小示例:
- 第 1 行创建新的
Actor
对象,给它一个要绘制的图像的名称。 - 线 2 设置
Actor
的初始 x 和 y 位置。 - 第 4 行和第 5 行设置 Pygame 零窗口的大小。注意
HEIGHT
是基于 sprite 的.height
属性的。该值来自用于创建精灵的图像的高度。 - 第 9 行通过调用
Actor
对象上的.draw()
来绘制精灵。这将在屏幕上由.pos
提供的位置绘制精灵图像。
接下来,您将在更高级的游戏中使用这些技术。
高级应用程序
为了演示游戏引擎之间的区别,您将再次访问您在 Pygame 部分看到的相同的高级游戏,现在使用 Pygame Zero 编写。提醒一下,这个游戏的关键细节是:
- 玩家是屏幕上的一个精灵,通过移动鼠标来控制。
- 每隔一段时间,硬币就会一个接一个地出现在屏幕上。
- 当玩家移动每枚硬币时,硬币消失,玩家获得 10 分。
- 随着游戏的进行,硬币会更快地加入。
- 当屏幕上出现十个以上的硬币时,游戏结束。
这个游戏的外观和行为应该与之前演示的 Pygame 版本一致,只有窗口标题栏暴露了 Pygame 零原点:
您可以在下面的下载资料中找到该示例的完整代码:
1"""
2Complete game in Pygame Zero
3
4This game demonstrates some of the more advanced features of
5Pygame Zero, including:
6- Using sprites to render complex graphics
7- Handling user input
8- Sound output
9
10"""
11
12# Import pgzrun allows the program to run in Python IDLE
13import pgzrun
14
15# For type-hinting support
16from typing import Tuple
17
18# To randomize coin placement
19from random import randint
20
21# Set the width and height of your output window, in pixels
22WIDTH = 800
23HEIGHT = 600
24
25# Set up the player
26player = Actor("alien_green_stand")
27player_position = WIDTH // 2, HEIGHT // 2
28player.center = player_position
29
30# Set up the coins to collect
31COIN_COUNT = 10
32coin_list = list()
33
34# Set up a timer to create new coins
35coin_countdown = 2.5
36coin_interval = 0.1
37
38# Score is initially zero
39score = 0
40
41def add_coin():
42 """Adds a new coin to playfield, then
43 schedules the next coin to be added
44 """
45 global coin_countdown
46
47 # Create a new coin Actor at a random location
48 new_coin = Actor(
49 "coin_gold", (randint(10, WIDTH - 10), randint(10, HEIGHT - 10))
50 )
51
52 # Add it to the global coin list
53 coin_list.append(new_coin)
54
55 # Decrease the time between coin appearances if there are
56 # fewer than three coins on the screen.
57 if len(coin_list) < 3:
58 coin_countdown -= coin_interval
59
60 # Make sure you don't go too quickly
61 if coin_countdown < 0.1:
62 coin_countdown = 0.1
63
64 # Schedule the next coin addition
65 clock.schedule(add_coin, coin_countdown)
66
67def on_mouse_move(pos: Tuple):
68 """Called whenever the mouse changes position
69
70 Arguments:
71 pos {Tuple} -- The current position of the mouse
72 """
73 global player_position
74
75 # Set the player to the mouse position
76 player_position = pos
77
78 # Ensure the player doesn't move off the screen
79 if player_position[0] < 0:
80 player_position[0] = 0
81 if player_position[0] > WIDTH:
82 player_position[0] = WIDTH
83
84 if player_position[1] < 0:
85 player_position[1] = 0
86 if player_position[1] > HEIGHT:
87 player_position[1] = HEIGHT
88
89def update(delta_time: float):
90 """Called every frame to update game objects
91
92 Arguments:
93 delta_time {float} -- Time since the last frame
94 """
95 global score
96
97 # Update the player position
98 player.center = player_position
99
100 # Check if the player has collided with a coin
101 # First, set up a list of coins to remove
102 coin_remove_list = []
103
104 # Check each coin in the list for a collision
105 for coin in coin_list:
106 if player.colliderect(coin):
107 sounds.coin_pickup.play()
108 coin_remove_list.append(coin)
109 score += 10
110
111 # Remove any coins with which you collided
112 for coin in coin_remove_list:
113 coin_list.remove(coin)
114
115 # The game is over when there are too many coins on the screen
116 if len(coin_list) >= COIN_COUNT:
117 # Stop making new coins
118 clock.unschedule(add_coin)
119
120 # Print the final score and exit the game
121 print(f"Game over! Final score: {score}")
122 exit()
123
124def draw():
125 """Render everything on the screen once per frame"""
126
127 # Clear the screen first
128 screen.clear()
129
130 # Set the background color to pink
131 screen.fill("pink")
132
133 # Draw the remaining coins
134 for coin in coin_list:
135 coin.draw()
136
137 # Draw the player
138 player.draw()
139
140 # Draw the current score at the bottom
141 screen.draw.text(
142 f"Score: {score}",
143 (50, HEIGHT - 50),
144 fontsize=48,
145 color="black",
146 )
147
148# Schedule the first coin to appear
149clock.schedule(add_coin, coin_countdown)
150
151# Run the program
152pgzrun.go()
创建玩家Actor
是在的第 26 到 28 行完成的。初始位置是屏幕的中心。
clock.schedule()
方法处理定期创建硬币。该方法需要调用一个函数,并在调用该函数之前确定延迟的秒数。
第 41 到 65 行定义了将要被调度的add_coin()
功能。它在第 48 到 50 行的随机位置创建一个新硬币Actor
,并将其添加到可见硬币的全局列表中。
随着游戏的进行,硬币应该会越来越快地出现,但不能太快。间隔管理在线 57 至 62 完成。因为clock.schedule()
只会触发一次,所以你在线路 65 上安排了另一次呼叫。
鼠标移动在第 67 到 87 行的事件处理程序中处理。鼠标位置被捕获并存储在线 76** 的一个全局变量中。第 79 行到第 87 行确保该位置不会离开屏幕。**
将玩家位置存储在一个global
变量中是一种便利,它简化了代码,并允许您专注于 Pygame Zero 的功能。在更完整的游戏中,你的设计选择可能会有所不同。
第 89 到 122 行定义的update()
函数被 Pygame Zero 每帧调用一次。你用它来移动Actor
物体并更新你所有游戏物体的状态。玩家Actor
的位置被更新以在线 98 上跟踪鼠标。
与硬币的碰撞在线 102 到 113 上处理。如果玩家撞上了一枚硬币,那么硬币会被加到coin_remove_list
上,分数会增加,并且会发出声音。当所有的碰撞都被处理后,你取出添加到行 112 到 113 的coin_remove_list
中的硬币。
处理完硬币碰撞后,检查屏幕上的行 116 处是否还有过多硬币。如果是这样,游戏就结束了,所以你停止创造新的硬币,打印最后的分数,在第 118 到 122 行结束游戏。
当然,这一切的更新都需要体现在屏幕上。第 124 到 146 行上的draw()
函数在每帧update()
之后被调用一次。在清空屏幕并在行 128 和 131** 处填充背景色后,玩家Actor
和所有硬币被绘制在行 134 至 138 处。当前分数是在第 141 至 146 行上绘制的最后一项。**
Pygame Zero 实现使用了 152 行代码来交付与 182 行 Pygame 代码相同的游戏。虽然这些行数是可比的,但 Pygame Zero 版本无疑比 Pygame 版本更干净、更模块化,并且可能更容易理解和编码。
当然,写游戏总会多一种方式。
街机
Arcade 是一个现代的 Python 框架,用于制作具有引人注目的图形和声音的游戏。通过面向对象的设计, Arcade 为游戏作者提供了一套现代的工具来打造出色的 Python 游戏体验。
Arcade 由美国爱荷华州辛普森学院的 Paul Craven 教授设计,建立在 T2 的 pyglet 窗口和多媒体图书馆之上。它提供了一系列改进、现代化和增强功能,与 Pygame 和 Pygame Zero 相比毫不逊色:
- 支持现代 OpenGL 图形
- 支持 Python 3 类型提示
- 支持基于帧的动画精灵
- 整合了一致的命令、函数和参数名称
- 鼓励游戏逻辑与显示代码的分离
- 需要较少的样板代码
- 提供维护良好的最新文档,包括几个教程和完整的 Python 游戏示例
- 内置了自顶向下和平台游戏的物理引擎
Arcade 处于不断的开发中,在社区中得到很好的支持,并且有一个对问题、错误报告和潜在修复非常敏感的作者。
街机安装
要安装 Arcade 及其依赖项,使用相应的 pip
命令:
(venv) $ python -m pip install arcade
基于您的平台的完整安装说明可用于 Windows 、 macOS 和 Linux 。如果你愿意,你甚至可以直接从源安装arcade
。
基本概念
Arcade 中的一切都发生在一个由用户定义大小的窗口中。坐标系假设原点(0, 0)
位于屏幕的左下角,随着你向上移动,y 坐标增加。这和其他很多游戏引擎不同,把(0, 0)
放在左上角,增加了 y 坐标下移。
本质上,Arcade 是一个面向对象的库。虽然程序化地编写 Arcade 应用程序是可能的,但是当您创建完全面向对象的代码时,它的真正威力才会显现出来。
Arcade 和 Pygame Zero 一样,提供了内置的游戏循环和定义良好的事件模型,所以你最终得到的是非常干净易读的游戏代码。也像 Pygame Zero 一样,Arcade 提供了一个强大的 sprite 类来帮助渲染、定位和碰撞检测。此外,街机精灵可以通过提供多个图像来制作动画。
下面列出的基本 Arcade 应用程序的代码在教程的源代码中作为arcade_basic.py
提供:
1"""
2Basic "Hello, World!" program in Arcade
3
4This program is designed to demonstrate the basic capabilities
5of Arcade. It will:
6- Create a game window
7- Fill the background with white
8- Draw some basic shapes in different colors
9- Draw some text in a specified size and color
10"""
11
12# Import arcade allows the program to run in Python IDLE
13import arcade
14
15# Set the width and height of your output window, in pixels
16WIDTH = 800
17HEIGHT = 600
18
19# Classes
20class ArcadeBasic(arcade.Window):
21 """Main game window"""
22
23 def __init__(self, width: int, height: int, title: str):
24 """Initialize the window to a specific size
25
26 Arguments:
27 width {int} -- Width of the window
28 height {int} -- Height of the window
29 title {str} -- Title for the window
30 """
31
32 # Call the parent class constructor
33 super().__init__(width, height, title)
34
35 # Set the background window
36 arcade.set_background_color(color=arcade.color.WHITE)
37
38 def on_draw(self):
39 """Called once per frame to render everything on the screen"""
40
41 # Start rendering
42 arcade.start_render()
43
44 # Draw a blue circle with a radius of 50 in the center of the screen
45 arcade.draw_circle_filled(
46 center_x=WIDTH // 2,
47 center_y=HEIGHT // 2,
48 radius=50,
49 color=arcade.color.BLUE,
50 num_segments=50,
51 )
52
53 # Draw a red-outlined square in the top-left corner of the screen
54 arcade.draw_lrtb_rectangle_outline(
55 left=50,
56 top=HEIGHT - 50,
57 bottom=HEIGHT - 100,
58 right=100,
59 color=arcade.color.RED,
60 border_width=3,
61 )
62
63 # Draw an orange caption along the bottom in 60-point font
64 arcade.draw_text(
65 text="Hello, World! From Arcade!",
66 start_x=100,
67 start_y=50,
68 font_size=28,
69 color=arcade.color.ORANGE,
70 )
71
72# Run the program
73if __name__ == "__main__":
74 arcade_game = ArcadeBasic(WIDTH, HEIGHT, "Arcade Basic Game")
75 arcade.run()
要运行此代码,请使用以下命令:
(venv) $ python arcade/arcade_basic.py
该程序在屏幕上绘制一些形状和一些文本,如前面显示的基本示例所示:
如上所述,街机程序可以写成完全面向对象的代码。这个arcade.Window
类被设计成你的游戏的子类,如第 20 行所示。在线 33 上调用super().__init()
确保游戏窗口被正确设置。
Arcade 每帧调用一次行 38 到 70 上定义的.on_draw()
事件处理程序,将所有内容渲染到屏幕上。这个方法从调用.start_render()
开始,它告诉 Arcade 准备窗口进行绘制。这相当于 Pygame 绘制步骤结束时需要的pygame.flip()
调用。
Arcade 中的每个基本形状绘制方法都以draw_*
开始,并且需要一条线来完成。Arcade 内置了对众多形状的绘图支持。
Arcade 在arcade.color
包中装载了数百种命名的颜色,但你也可以使用 RGB 或 RGBA 元组自由选择自己的颜色。
高级应用程序
为了展示 Arcade 与其他游戏引擎的不同,您将看到以前的相同游戏,现在在 Arcade 中实现。提醒一下,以下是游戏的关键细节:
- 玩家是屏幕上的一个精灵,通过移动鼠标来控制。
- 每隔一段时间,硬币就会一个接一个地出现在屏幕上。
- 当玩家移动每枚硬币时,硬币消失,玩家获得 10 分。
- 随着游戏的进行,硬币会更快地加入。
- 当屏幕上出现十个以上的硬币时,游戏结束。
同样,游戏的行为应该与前面的示例相同:
下面列出的完整街机游戏代码在可下载资料中以arcade_game.py
的形式提供:
1"""
2Complete game in Arcade
3
4This game demonstrates some of the more advanced features of
5Arcade, including:
6- Using sprites to render complex graphics
7- Handling user input
8- Sound output
9"""
10
11# Import arcade allows the program to run in Python IDLE
12import arcade
13
14# To randomize coin placement
15from random import randint
16
17# To locate your assets
18from pathlib import Path
19
20# Set the width and height of your game window, in pixels
21WIDTH = 800
22HEIGHT = 600
23
24# Set the game window title
25TITLE = "Arcade Sample Game"
26
27# Location of your assets
28ASSETS_PATH = Path.cwd() / "assets"
29
30# How many coins must be on the screen before the game is over?
31COIN_COUNT = 10
32
33# How much is each coin worth?
34COIN_VALUE = 10
35
36# Classes
37class ArcadeGame(arcade.Window):
38 """The Arcade Game class"""
39
40 def __init__(self, width: float, height: float, title: str):
41 """Create the main game window
42
43 Arguments:
44 width {float} -- Width of the game window
45 height {float} -- Height of the game window
46 title {str} -- Title for the game window
47 """
48
49 # Call the super class init method
50 super().__init__(width, height, title)
51
52 # Set up a timer to create new coins
53 self.coin_countdown = 2.5
54 self.coin_interval = 0.1
55
56 # Score is initially zero
57 self.score = 0
58
59 # Set up empty sprite lists
60 self.coins = arcade.SpriteList()
61
62 # Don't show the mouse cursor
63 self.set_mouse_visible(False)
64
65 def setup(self):
66 """Get the game ready to play"""
67
68 # Set the background color
69 arcade.set_background_color(color=arcade.color.PINK)
70
71 # Set up the player
72 sprite_image = ASSETS_PATH / "images" / "alien_green_stand.png"
73 self.player = arcade.Sprite(
74 filename=sprite_image, center_x=WIDTH // 2, center_y=HEIGHT // 2
75 )
76
77 # Spawn a new coin
78 arcade.schedule(
79 function_pointer=self.add_coin, interval=self.coin_countdown
80 )
81
82 # Load your coin collision sound
83 self.coin_pickup_sound = arcade.load_sound(
84 ASSETS_PATH / "sounds" / "coin_pickup.wav"
85 )
86
87 def add_coin(self, dt: float):
88 """Add a new coin to the screen, reschedule the timer if necessary
89
90 Arguments:
91 dt {float} -- Time since last call (unused)
92 """
93
94 # Create a new coin
95 coin_image = ASSETS_PATH / "images" / "coin_gold.png"
96 new_coin = arcade.Sprite(
97 filename=coin_image,
98 center_x=randint(20, WIDTH - 20),
99 center_y=randint(20, HEIGHT - 20),
100 )
101
102 # Add the coin to the current list of coins
103 self.coins.append(new_coin)
104
105 # Decrease the time between coin appearances, but only if there are
106 # fewer than three coins on the screen.
107 if len(self.coins) < 3:
108 self.coin_countdown -= self.coin_interval
109
110 # Make sure you don't go too quickly
111 if self.coin_countdown < 0.1:
112 self.coin_countdown = 0.1
113
114 # Stop the previously scheduled call
115 arcade.unschedule(function_pointer=self.add_coin)
116
117 # Schedule the next coin addition
118 arcade.schedule(
119 function_pointer=self.add_coin, interval=self.coin_countdown
120 )
121
122 def on_mouse_motion(self, x: float, y: float, dx: float, dy: float):
123 """Processed when the mouse moves
124
125 Arguments:
126 x {float} -- X Position of the mouse
127 y {float} -- Y Position of the mouse
128 dx {float} -- Change in x position since last move
129 dy {float} -- Change in y position since last move
130 """
131
132 # Ensure the player doesn't move off-screen
133 self.player.center_x = arcade.clamp(x, 0, WIDTH)
134 self.player.center_y = arcade.clamp(y, 0, HEIGHT)
135
136 def on_update(self, delta_time: float):
137 """Update all the game objects
138
139 Arguments:
140 delta_time {float} -- How many seconds since the last frame?
141 """
142
143 # Check if you've picked up a coin
144 coins_hit = arcade.check_for_collision_with_list(
145 sprite=self.player, sprite_list=self.coins
146 )
147
148 for coin in coins_hit:
149 # Add the coin score to your score
150 self.score += COIN_VALUE
151
152 # Play the coin sound
153 arcade.play_sound(self.coin_pickup_sound)
154
155 # Remove the coin
156 coin.remove_from_sprite_lists()
157
158 # Are there more coins than allowed on the screen?
159 if len(self.coins) > COIN_COUNT:
160 # Stop adding coins
161 arcade.unschedule(function_pointer=self.add_coin)
162
163 # Show the mouse cursor
164 self.set_mouse_visible(True)
165
166 # Print the final score and exit the game
167 print(f"Game over! Final score: {self.score}")
168 exit()
169
170 def on_draw(self):
171 """Draw everything"""
172
173 # Start the rendering pass
174 arcade.start_render()
175
176 # Draw the coins
177 self.coins.draw()
178
179 # Draw the player
180 self.player.draw()
181
182 # Draw the score in the lower-left corner
183 arcade.draw_text(
184 text=f"Score: {self.score}",
185 start_x=50,
186 start_y=50,
187 font_size=32,
188 color=arcade.color.BLACK,
189 )
190
191if __name__ == "__main__":
192 arcade_game = ArcadeGame(WIDTH, HEIGHT, TITLE)
193 arcade_game.setup()
194 arcade.run()
Arcade 的面向对象特性允许您通过将游戏的初始化与每个不同级别的初始化分开来快速实现不同的级别。游戏在第 40 到 63 行的.__init__()
方法中初始化,而关卡在第 65 到 85 行使用单独的.setup()
方法设置和重启。这是一个很好的模式,即使是像这样只有一个关卡的游戏。
精灵是通过创建一个类arcade.Sprite
的对象,并提供一个图像的路径来定义的。Arcade 支持 pathlib 路径,这使得在第 72 到 75 行上创建玩家精灵变得更加容易。
创建新硬币是在第 78 行到第 80 行的上处理的,它们每隔一段时间调用arcade.schedule()
来调用self.add_coin()
方法。
在第 87 到 120 行定义的.add_coin()
方法在一个随机的位置创建一个新的硬币精灵,并将其添加到一个列表中,以简化绘图以及以后的碰撞处理。
要使用鼠标移动玩家,您需要在第 122 行到第 134 行的上实现.on_mouse_motion()
方法。arcade.clamp()
方法确保玩家的中心坐标不会离开屏幕。
检查玩家和硬币之间的碰撞是在行 144 到 156 的.on_update()
方法中处理的。方法返回列表中所有与指定精灵冲突的精灵的列表。代码遍历列表,增加分数并播放声音效果,然后将每个硬币移出游戏。
.on_update()
方法还检查在行 159 到 168 上是否有太多的硬币。如果是,游戏结束。
这个 Arcade 实现和 Pygame Zero 代码一样易读和结构良好,尽管它用了超过 27%的代码,写了 194 行。较长的代码可能是值得的,因为 Arcade 提供了更多这里没有展示的功能,例如:
- 动画精灵
- 几个内置的物理引擎
- 支持第三方游戏地图
- 更新的粒子和着色器系统
来自 Python Zero 的新游戏作者会发现 Arcade 在结构上类似,但提供了更强大和更广泛的功能。
adventure lib〔t0〕
当然,并不是每个游戏都需要一个彩色的玩家在屏幕上移动,躲避障碍,杀死坏人。像 Zork 这样的经典电脑游戏展示了好故事的力量,同时还提供了很好的游戏体验。制作这些基于文本的游戏,也被称为互动小说,在任何语言中都很难。对 Python 程序员来说幸运的是,有 adventurelib:
adventurelib 提供了编写基于文本的冒险游戏的基本功能,目的是让青少年也能轻松完成。(来源)
然而,这不仅仅是针对青少年的!adventurelib 非常适合那些想编写基于文本的游戏而不需要编写自然语言解析器的人。
adventurelib 由 Pygame Zero 背后的人创建,它处理更高级的计算机科学主题,例如状态管理、业务逻辑、命名和引用以及集合操作等等。这使它成为教育工作者、家长和导师帮助年轻人通过游戏学习计算机科学的伟大的下一步。这对拓展你自己的游戏编码技能也很有帮助。
adventurelib 安装
PyPI 上有 adventurelib,可以使用适当的pip
命令进行安装:
(venv) $ python -m pip install adventurelib
adventurelib 是一个单独的文件,所以也可以从 GitHub repo 中下载,保存在和你的游戏相同的文件夹中,直接使用。
基本概念
为了学习 adventurelib 的基础知识,您将看到一个有三个房间的小游戏和一把打开下面最后一个房间的门的钥匙。该示例游戏的代码在adventurelib_basic.py
的可下载资料中提供:
1"""
2Basic "Hello, World!" program in adventurelib
3
4This program is designed to demonstrate the basic capabilities
5of adventurelib. It will:
6- Create a basic three-room world
7- Add a single inventory item
8- Require that inventory item to move to the final room
9"""
10
11# Import the library contents
12import adventurelib as adv
13
14# Define your rooms
15bedroom = adv.Room(
16 """
17You are in your bedroom. The bed is unmade, but otherwise
18it's clean. Your dresser is in the corner, and a desk is
19under the window.
20"""
21)
22
23living_room = adv.Room(
24 """
25The living room stands bright and empty. The TV is off,
26and the sun shines brightly through the curtains.
27"""
28)
29
30front_porch = adv.Room(
31 """
32The creaky boards of your front porch welcome you as an
33old friend. Your front door mat reads 'Welcome'.
34"""
35)
36
37# Define the connections between the rooms
38bedroom.south = living_room
39living_room.east = front_porch
40
41# Define a constraint to move from the bedroom to the living room
42# If the door between the living room and front porch door is locked,
43# you can't exit
44living_room.locked = {"east": True}
45
46# None of the other rooms have any locked doors
47bedroom.locked = dict()
48front_porch.locked = dict()
49
50# Set the starting room as the current room
51current_room = bedroom
52
53# Define functions to use items
54def unlock_living_room(current_room):
55
56 if current_room == living_room:
57 print("You unlock the door.")
58 current_room.locked["east"] = False
59 else:
60 print("There is nothing to unlock here.")
61
62# Create your items
63key = adv.Item("a front door key", "key")
64key.use_item = unlock_living_room
65
66# Create empty Bags for room contents
67bedroom.contents = adv.Bag()
68living_room.contents = adv.Bag()
69front_porch.contents = adv.Bag()
70
71# Put the key in the bedroom
72bedroom.contents.add(key)
73
74# Set up your current empty inventory
75inventory = adv.Bag()
76
77# Define your movement commands
78@adv.when("go DIRECTION")
79@adv.when("north", direction="north")
80@adv.when("south", direction="south")
81@adv.when("east", direction="east")
82@adv.when("west", direction="west")
83@adv.when("n", direction="north")
84@adv.when("s", direction="south")
85@adv.when("e", direction="east")
86@adv.when("w", direction="west")
87def go(direction: str):
88 """Processes your moving direction
89
90 Arguments:
91 direction {str} -- which direction does the player want to move
92 """
93
94 # What is your current room?
95 global current_room
96
97 # Is there an exit in that direction?
98 next_room = current_room.exit(direction)
99 if next_room:
100 # Is the door locked?
101 if direction in current_room.locked and current_room.locked[direction]:
102 print(f"You can't go {direction} --- the door is locked.")
103 else:
104 current_room = next_room
105 print(f"You go {direction}.")
106 look()
107
108 # No exit that way
109 else:
110 print(f"You can't go {direction}.")
111
112# How do you look at the room?
113@adv.when("look")
114def look():
115 """Looks at the current room"""
116
117 # Describe the room
118 adv.say(current_room)
119
120 # List the contents
121 for item in current_room.contents:
122 print(f"There is {item} here.")
123
124 # List the exits
125 print(f"The following exits are present: {current_room.exits()}")
126
127# How do you look at items?
128@adv.when("look at ITEM")
129@adv.when("inspect ITEM")
130def look_at(item: str):
131
132 # Check if the item is in your inventory or not
133 obj = inventory.find(item)
134 if not obj:
135 print(f"You don't have {item}.")
136 else:
137 print(f"It's an {obj}.")
138
139# How do you pick up items?
140@adv.when("take ITEM")
141@adv.when("get ITEM")
142@adv.when("pickup ITEM")
143def get(item: str):
144 """Get the item if it exists
145
146 Arguments:
147 item {str} -- The name of the item to get
148 """
149 global current_room
150
151 obj = current_room.contents.take(item)
152 if not obj:
153 print(f"There is no {item} here.")
154 else:
155 print(f"You now have {item}.")
156 inventory.add(obj)
157
158# How do you use an item?
159@adv.when("unlock door", item="key")
160@adv.when("use ITEM")
161def use(item: str):
162 """Use an item, consumes it if used
163
164 Arguments:
165 item {str} -- Which item to use
166 """
167
168 # First, do you have the item?
169 obj = inventory.take(item)
170 if not obj:
171 print(f"You don't have {item}")
172
173 # Try to use the item
174 else:
175 obj.use_item(current_room)
176
177if __name__ == "__main__":
178 # Look at the starting room
179 look()
180
181 adv.start()
要运行此代码,请使用以下命令:
(venv) $ python adventurelib/adventurelib_basic.py
基于文本的游戏严重依赖于解析用户输入来驱动游戏前进。adventurelib 将玩家键入的文本定义为一个命令,并提供@when()
装饰器来定义命令。
命令的一个很好的例子是在行 113 到 125 上定义的look
命令。@when("look")
装饰器将文本look
添加到有效命令列表中,并将其连接到look()
函数。每当玩家键入look
,adventurelib 就会调用look()
函数。
玩家输入的命令不区分大小写。玩家可以键入look
、LOOK
、Look
,甚至lOOk
,adventurelib 会找到正确的命令。
多个命令可以使用相同的功能,如第 78 到 110 行的go()
功能所示。这个功能由九个独立的命令装饰,允许玩家以几种不同的方式在游戏世界中移动。在下面的游戏示例中,命令south
、east
和north
都被使用,但是每一个都导致相同的函数被调用:
有时候玩家输入的命令是针对某个特定的物品的。例如,玩家可能想看某个特定的东西或者朝某个特定的方向走。游戏设计者可以通过在@when()
装饰器中指定大写单词来捕获额外的命令上下文。这些被视为变量名,玩家在它们的位置键入的文本就是值。
这可以在第 128 到 137 行的look_at()
功能中看到。这个函数定义了一个名为item
的字符串参数。在定义look at
和inspect
命令的@when()
装饰器中,单词ITEM
充当命令后面任何文本的占位符。然后,该文本作为item
参数传递给look_at()
函数。比如玩家输入look at book
,那么参数item
就会得到值"book"
。
基于文本的游戏的优势依赖于其文本的描述性。虽然您可以并且应该使用print()
函数,但是为了响应用户命令而打印多行文本会给跨多行文本和确定换行符带来困难。adventurelib 通过say()
函数减轻了这一负担,该函数可以很好地处理三重引用的多行字符串。
您可以在look()
功能中的线 118 上看到say()
功能正在运行。每当玩家输入look
时,say()
功能就会向控制台输出当前房间的描述。
当然,你的命令需要出现的地方。adventurelib 提供了Room
类来定义游戏世界的不同区域。通过提供房间的描述来创建房间,并且可以使用.north
、.south
、.east
和.west
属性将它们连接到其他房间。您还可以定义应用于整个Room
类或单个对象的自定义属性。
这个游戏中的三个房间是在15 到 35 线创建的。Room()
构造函数接受字符串形式的描述,或者在本例中,接受多行字符串形式的描述。一旦你创建了房间,然后你在第 38 到 39 行上连接它们。将bedroom.south
设置为living_room
意味着living_room.north
将成为bedroom
。adventurelib 足够智能,可以自动建立这种连接。
您还可以在线 44 上创建一个约束,以指示起居室和前廊之间的一扇锁着的门。打开这扇门需要玩家找到一个物品。
基于文本的游戏通常以必须收集的物品为特色,以打开游戏的新领域或解决某些谜题。物品也可以代表玩家可以与之互动的非玩家角色。adventurelib 提供了Item
类来通过名字和别名定义可收集的物品和非玩家角色。例如,别名key
是指前门钥匙:
在第 63 行,你定义了用于打开客厅和前廊之间的门的key
。Item()
构造函数接受一个或多个字符串。第一个是项目的默认名称或全名,在打印项目名称时使用。所有其他名称都被用作别名,因此玩家不必键入项目的全名。
这个key
不仅仅有名字和别名。它还有一个预期用途,在行 64 中定义。key.use_item
是指当玩家试图通过输入"use key"
来使用该物品时将被调用的功能。该功能在行 159 至 175 定义的use()
命令处理器中调用。
物品的集合,例如玩家的物品清单或房间地面上的物品,可以存储在一个Bag
对象中。您可以向包中添加物品,从包中取出物品,以及检查包中的物品。Bag
对象在 Python 中是可迭代的,所以你也可以使用in
来测试包里是否有东西,并在for
循环中遍历包里的内容。
四个不同的Bag
对象被定义在行 67 到 75 上。三个房间中的每一个都有一个Bag
来存放房间中的物品,玩家也有一个Bag
来存放他们拾取的inventory
物品。key
项目被放置在bedroom
的起始位置。
物品通过行 140 到 156 定义的get()
功能添加到玩家的物品清单中。当玩家输入get key
时,你试图在行 151 上take()
房间contents
包中的物品。如果key
被归还,它也会从房间的contents
中移除。然后将key
添加到玩家的inventory
中的行 156 处。
高级应用程序
当然,adventurelib 还有更多内容。为了展示它的其他功能,您将使用下面的背景故事来创建一个更复杂的文本冒险:
- 你住在一个安静的小村庄里。
- 最近,你的邻居开始抱怨丢失的牲畜。
- 作为夜间巡逻队的一员,你注意到一个破损的栅栏和一条离开它的痕迹。
- 你决定去调查,只带了一把练习用的木剑。
这个游戏有几个方面需要描述和定义:
- 你安静的小村庄
- 这条小路远离田野
- 附近的一个村庄,在那里你可以买到更好的武器
- 一条通往能为你的武器附魔的巫师的小路
- 一个洞穴,里面有一个拿走你牲畜的巨人
有几个项目需要收集,如武器和食物,以及与角色互动。你还需要一个基本的战斗系统来让你与巨人战斗并赢得比赛。
这个游戏的所有代码都列在下面,可以在下载的资料中找到:
获取源代码: 点击此处获取您将使用试用 Python 游戏引擎的源代码。
为了让事情有条理,你把你的游戏分成不同的文件:
adventurelib_game_rooms.py
定义房间和区域。adventurelib_game_items.py
定义项目及其属性。adventurelib_game_characters.py
定义您可以与之互动的角色。- 把所有东西放在一起,添加命令,然后开始游戏。
1"""
2Complete game written in adventurelib
3
4This program is designed to demonstrate the capabilities
5of adventurelib. It will:
6- Create a large world in which to wander
7- Contain several inventory items
8- Set contexts for moving from one area to another
9- Require some puzzle-solving skills
10"""
11
12# Import the library contents
13# from adventurelib import *
14import adventurelib as adv
15
16# Import your rooms, which imports your items and characters
17import adventurelib_game_rooms
18
19import adventurelib_game_items
20
21# For your battle sequence
22from random import randint
23
24# To allow you to exit the game
25import sys
26
27# Set the first room
28current_room = adventurelib_game_rooms.home
29current_room.visited = False
30
31# How many HP do you have?
32hit_points = 20
33
34# How many HP does the giant have?
35giant_hit_points = 50
36
37# Your current inventory
38inventory = adv.Bag()
39
40# Some basic item commands
41@adv.when("inventory")
42@adv.when("inv")
43@adv.when("i")
44def list_inventory():
45 if inventory:
46 print("You have the following items:")
47 for item in inventory:
48 print(f" - {item.description}")
49 else:
50 print("You have nothing in your inventory.")
51
52@adv.when("look at ITEM")
53def look_at(item: str):
54 """Prints a short description of an item if it is either:
55 1\. in the current room, or
56 2\. in our inventory
57
58 Arguments:
59 item {str} -- the item to look at
60 """
61
62 global inventory, current_room
63
64 # Check if the item is in the room
65 obj = current_room.items.find(item)
66 if not obj:
67 # Check if the item is in your inventory
68 obj = inventory.find(item)
69 if not obj:
70 print(f"I can't find {item} anywhere.")
71 else:
72 print(f"You have {item}.")
73 else:
74 print(f"You see {item}.")
75
76@adv.when("describe ITEM")
77def describe(item: str):
78 """Prints a description of an item if it is either:
79 1\. in the current room, or
80 2\. in your inventory
81
82 Arguments:
83 item {str} -- the item to look at
84 """
85
86 global inventory, current_room
87
88 # Check if the item is in the room
89 obj = current_room.items.find(item)
90 if not obj:
91 # Check if the item is in your inventory
92 obj = inventory.find(item)
93 if not obj:
94 print(f"I can't find {item} anywhere.")
95 else:
96 print(f"You have {obj.description}.")
97 else:
98 print(f"You see {obj.description}.")
99
100@adv.when("take ITEM")
101@adv.when("get ITEM")
102@adv.when("pickup ITEM")
103@adv.when("pick up ITEM")
104@adv.when("grab ITEM")
105def take_item(item: str):
106 global current_room
107
108 obj = current_room.items.take(item)
109 if not obj:
110 print(f"I don't see {item} here.")
111 else:
112 print(f"You now have {obj.description}.")
113 inventory.add(obj)
114
115@adv.when("eat ITEM")
116def eat(item: str):
117 global inventory
118
119 # Make sure you have the thing first
120 obj = inventory.find(item)
121
122 # Do you have this thing?
123 if not obj:
124 print(f"You don't have {item}.")
125
126 # Is it edible?
127 elif obj.edible:
128 print(f"You savor every bite of {obj.description}.")
129 inventory.take(item)
130
131 else:
132 print(f"How do you propose we eat {obj.description}?")
133
134@adv.when("wear ITEM")
135@adv.when("put on ITEM")
136def wear(item: str):
137 global inventory
138
139 # Make sure you have the thing first
140 obj = inventory.find(item)
141
142 # Do you have this thing?
143 if not obj:
144 print(f"You don't have {item}.")
145
146 # Is it wearable?
147 elif obj.wearable:
148 print(f"The {obj.description} makes a wonderful fashion statement!")
149
150 else:
151 print(
152 f"""This is no time for avant garde fashion choices!
153 Wear a {obj.description}? Really?"""
154 )
155
156# Some character-specific commands
157@adv.when("talk to CHARACTER")
158def talk_to(character: str):
159 global current_room
160
161 char = current_room.characters.find(character)
162
163 # Is the character there?
164 if not char:
165 print(f"Sorry, I can't find {character}.")
166
167 # It's a character who is there
168 else:
169 # Set the context, and start the encounter
170 adv.set_context(char.context)
171 adv.say(char.greeting)
172
173@adv.when("yes", context="elder")
174def yes_elder():
175 global current_room
176
177 adv.say(
178 """
179 It is not often one of our number leaves, and rarer still if they leave
180 to defend our Home. Go with our blessing, and our hope for a successful
181 journey and speedy return. To help, we bestow three gifts.
182
183 The first is one of knowledge. There is a blacksmith in one of the
184 neighboring villages. You may find help there.
185
186 Second, seek a wizard who lives as a hermit, who may be persuaded to
187 give aid. Be wary, though! The wizard does not give away his aid for
188 free. As he tests you, remember always where you started your journey.
189
190 Lastly, we don't know what dangers you may face. We are peaceful people,
191 but do not wish you to go into the world undefended. Take this meager
192 offering, and use it well!
193 """
194 )
195 inventory.add(adventurelib_game_items.wooden_sword)
196 current_room.locked_exits["south"] = False
197
198@adv.when("thank you", context="elder")
199@adv.when("thanks", context="elder")
200def thank_elder():
201 adv.say("It is we who should thank you. Go with our love and hopes!")
202
203@adv.when("yes", context="blacksmith")
204def yes_blacksmith():
205 global current_room
206
207 adv.say(
208 """
209 I can see you've not a lot of money. Usually, everything here
210 if pretty expensive, but I just might have something...
211
212 There's this steel sword here, if you want it. Don't worry --- it
213 doesn't cost anything! It was dropped off for repair a few weeks
214 ago, but the person never came back for it. It's clean, sharp,
215 well-oiled, and will do a lot more damage than that
216 fancy sword-shaped club you've got. I need it gone to clear some room.
217
218 If you want, we could trade even up --- the wooden sword for the
219 steel one. I can use yours for fire-starter. Deal?
220 """
221 )
222 adv.set_context("blacksmith.trade")
223
224@adv.when("yes", context="blacksmith.trade")
225def trade_swords_yes():
226 print("Great!")
227 inventory.take("wooden sword")
228 inventory.add(adventurelib_game_items.steel_sword)
229
230@adv.when("no", context="blacksmith.trade")
231def trade_swords_no():
232 print("Well, that's all I have within your budget. Good luck!")
233 adv.set_context(None)
234
235@adv.when("yes", context="wizard")
236def yes_wizard():
237 global current_room
238
239 adv.say(
240 """
241 I can make your weapon more powerful than it is, but only if
242 you can answer my riddle:
243
244 What has one head...
245 One foot...
246 But four legs?
247 """
248 )
249
250 adv.set_context("wizard.riddle")
251
252@adv.when("bed", context="wizard.riddle")
253@adv.when("a bed", context="wizard.riddle")
254def answer_riddle():
255 adv.say("You are smarter than you believe yourself to be! Behold!")
256
257 obj = inventory.find("sword")
258 obj.bonus = 2
259 obj.description += ", which glows with eldritch light"
260
261 adv.set_context(None)
262 current_room.locked_exits["west"] = False
263
264@adv.when("fight CHARACTER", context="giant")
265def fight_giant(character: str):
266
267 global giant_hit_points, hit_points
268
269 sword = inventory.find("sword")
270
271 # The player gets a swing
272 player_attack = randint(1, sword.damage + 1) + sword.bonus
273 print(f"You swing your {sword}, doing {player_attack} damage!")
274 giant_hit_points -= player_attack
275
276 # Is the giant dead?
277 if giant_hit_points <= 0:
278 end_game(victory=True)
279
280 print_giant_condition()
281 print()
282
283 # Then the giant tries
284 giant_attack = randint(0, 5)
285 if giant_attack == 0:
286 print("The giant's arm whistles harmlessly over your head!")
287 else:
288 print(
289 f"""
290 The giant swings his mighty fist,
291 and does {giant_attack} damage!
292 """
293 )
294 hit_points -= giant_attack
295
296 # Is the player dead?
297 if hit_points <= 0:
298 end_game(victory=False)
299
300 print_player_condition()
301 print()
302
303def print_giant_condition():
304
305 if giant_hit_points < 10:
306 print("The giant staggers, his eyes unfocused.")
307 elif giant_hit_points < 20:
308 print("The giant's steps become more unsteady.")
309 elif giant_hit_points < 30:
310 print("The giant sweats and wipes the blood from his brow.")
311 elif giant_hit_points < 40:
312 print("The giant snorts and grits his teeth against the pain.")
313 else:
314 print("The giant smiles and readies himself for the attack.")
315
316def print_player_condition():
317
318 if hit_points < 4:
319 print("Your eyes lose focus on the giant as you sway unsteadily.")
320 elif hit_points < 8:
321 print(
322 """
323 Your footing becomes less steady
324 as you swing your sword sloppily.
325 """
326 )
327 elif hit_points < 12:
328 print(
329 """
330 Blood mixes with sweat on your face
331 as you wipe it from your eyes.
332 """
333 )
334 elif hit_points < 16:
335 print("You bite down as the pain begins to make itself felt.")
336 else:
337 print("You charge into the fray valiantly!")
338
339def end_game(victory: bool):
340 if victory:
341 adv.say(
342 """
343 The giant falls to his knees as the last of his strength flees
344 his body. He takes one final swing at you, which you dodge easily.
345 His momentum carries him forward, and he lands face down in the dirt.
346 His final breath escapes his lips as he succumbs to your attack.
347
348 You are victorious! Your name will be sung for generations!
349 """
350 )
351
352 else:
353 adv.say(
354 """
355 The giant's mighty fist connects with your head, and the last
356 sound you hear are the bones in your neck crunching. You spin
357 and tumble down, your sword clattering to the floor
358 as the giant laughs.
359 Your eyes see the giant step towards you, his mighty foot
360 raised to crash down on you.
361 Oblivion takes over before you experience anything else...
362
363 You have been defeated! The giant is free to ravage your town!
364 """
365 )
366
367 sys.exit()
368
369@adv.when("flee", context="giant")
370def flee():
371 adv.say(
372 """
373 As you turn to run, the giant reaches out and catches your tunic.
374 He lifts you off the ground, grabbing your dangling sword-arm
375 as he does so. A quick twist, and your sword tumbles to the ground.
376 Still holding you, he reaches his hand to your throat and squeezes,
377 cutting off your air supply.
378
379 The last sight you see before blackness takes you are
380 the rotten teeth of the evil grin as the giant laughs
381 at your puny attempt to stop him...
382
383 You have been defeated! The giant is free to ravage your town!
384 """
385 )
386
387 sys.exit()
388
389@adv.when("goodbye")
390@adv.when("bye")
391@adv.when("adios")
392@adv.when("later")
393def goodbye():
394
395 # Are you fighting the giant?
396 if adv.get_context() == "giant":
397 # Not so fast!
398 print("The giant steps in front of you, blocking your exit!")
399
400 else:
401 # Close the current context
402 adv.set_context(None)
403 print("Fare thee well, traveler!")
404
405# Define some basic commands
406@adv.when("look")
407def look():
408 """Print the description of the current room.
409 If you've already visited it, print a short description.
410 """
411 global current_room
412
413 if not current_room.visited:
414 adv.say(current_room)
415 current_room.visited = True
416 else:
417 print(current_room.short_desc)
418
419 # Are there any items here?
420 for item in current_room.items:
421 print(f"There is {item.description} here.")
422
423@adv.when("describe")
424def describe_room():
425 """Print the full description of the room."""
426 adv.say(current_room)
427
428 # Are there any items here?
429 for item in current_room.items:
430 print(f"There is {item.description} here.")
431
432# Define your movement commands
433@adv.when("go DIRECTION")
434@adv.when("north", direction="north")
435@adv.when("south", direction="south")
436@adv.when("east", direction="east")
437@adv.when("west", direction="west")
438@adv.when("n", direction="north")
439@adv.when("s", direction="south")
440@adv.when("e", direction="east")
441@adv.when("w", direction="west")
442def go(direction: str):
443 """Processes your moving direction
444
445 Arguments:
446 direction {str} -- which direction does the player want to move
447 """
448
449 # What is your current room?
450 global current_room
451
452 # Is there an exit in that direction?
453 next_room = current_room.exit(direction)
454 if next_room:
455 # Is the door locked?
456 if (
457 direction in current_room.locked_exits
458 and current_room.locked_exits[direction]
459 ):
460 print(f"You can't go {direction} --- the door is locked.")
461 else:
462 # Clear the context if necessary
463 current_context = adv.get_context()
464 if current_context == "giant":
465 adv.say(
466 """Your way is currently blocked.
467 Or have you forgotten the giant you are fighting?"""
468 )
469 else:
470 if current_context:
471 print("Fare thee well, traveler!")
472 adv.set_context(None)
473
474 current_room = next_room
475 print(f"You go {direction}.")
476 look()
477
478 # No exit that way
479 else:
480 print(f"You can't go {direction}.")
481
482# Define a prompt
483def prompt():
484 global current_room
485
486 # Get possible exits
487 exits_string = get_exits(current_room)
488
489 # Are you in battle?
490 if adv.get_context() == "giant":
491 prompt_string = f"HP: {hit_points} > "
492 else:
493 prompt_string = f"({current_room.title}) > "
494
495 return f"""({exits_string}) {prompt_string}"""
496
497def no_command_matches(command: str):
498 if adv.get_context() == "wizard.riddle":
499 adv.say("That is not the correct answer. Begone!")
500 adv.set_context(None)
501 current_room.locked_exits["west"] = False
502 else:
503 print(f"What do you mean by '{command}'?")
504
505def get_exits(room):
506 exits = room.exits()
507
508 exits_string = ""
509 for exit in exits:
510 exits_string += f"{exit[0].upper()}|"
511
512 return exits_string[:-1]
513
514# Start the game
515if __name__ == "__main__":
516 # No context is normal
517 adv.set_context(None)
518
519 # Set the prompt
520 adv.prompt = prompt
521
522 # What happens with unknown commands
523 adv.no_command_matches = no_command_matches
524
525 # Look at your starting room
526 look()
527
528 # Start the game
529 adv.start()
1"""
2Rooms for the adventurelib game
3"""
4
5# Import the library contents
6import adventurelib as adv
7
8# Import your items as well
9import adventurelib_game_items
10
11# And your characters
12import adventurelib_game_characters
13
14# Create a subclass of Rooms to track some custom properties
15class GameArea(adv.Room):
16 def __init__(self, description: str):
17
18 super().__init__(description)
19
20 # All areas can have locked exits
21 self.locked_exits = {
22 "north": False,
23 "south": False,
24 "east": False,
25 "west": False,
26 }
27 # All areas can have items in them
28 self.items = adv.Bag()
29
30 # All areas can have characters in them
31 self.characters = adv.Bag()
32
33 # All areas may have been visited already
34 # If so, you can print a shorter description
35 self.visited = False
36
37 # Which means each area needs a shorter description
38 self.short_desc = ""
39
40 # Each area also has a very short title for the prompt
41 self.title = ""
42
43# Your home
44home = GameArea(
45 """
46You wake as the sun streams in through the single
47window into your small room. You lie on your feather bed which
48hugs the north wall, while the remains of last night's
49fire smolders in the center of the room.
50
51Remembering last night's discussion with the council, you
52throw back your blanket and rise from your comfortable
53bed. Cold water awaits you as you splash away the night's
54sleep, grab an apple to eat, and prepare for the day.
55"""
56)
57home.title = "Home"
58home.short_desc = "This is your home."
59
60# Hamlet
61hamlet = GameArea(
62 """
63From the center of your small hamlet, you can see every other
64home. It doesn't really even have an official name --- folks
65around here just call it Home.
66
67The council awaits you as you approach. Elder Barron beckons you
68as you exit your home.
69"""
70)
71hamlet.title = "Hamlet"
72hamlet.short_desc = "You are in the hamlet."
73
74# Fork in road
75fork = GameArea(
76 """
77As you leave your hamlet, you think about how unprepared you
78really are. Your lack of experience and pitiful equipment
79are certainly no match for whatever has been stealing
80the villages livestock.
81
82As you travel, you come across a fork in the path. The path of
83the livestock thief continues east. However, you know
84the village of Dunhaven lies to the west, where you may
85get some additional help.
86"""
87)
88fork.title = "Fork in road"
89fork.short_desc = "You are at a fork in the road."
90
91# Village of Dunhaven
92village = GameArea(
93 """
94A short trek up the well-worn path brings you the village
95of Dunhaven. Larger than your humble Home, Dunhaven sits at
96the end of a supply route from the capitol. As such, it has
97amenities and capabilities not found in the smaller farming
98communities.
99
100As you approach, you hear the clang-clang of hammer on anvil,
101and inhale the unmistakable smell of the coal-fed fire of a
102blacksmith shop to your south.
103"""
104)
105village.title = "Village of Dunhaven"
106village.short_desc = "You are in the village of Dunhaven."
107
108# Blacksmith shop
109blacksmith_shop = GameArea(
110 """
111As you approach the blacksmith, the sounds of the hammer become
112clearer and clearer. Passing the front door, you head towards
113the sound of the blacksmith, and find her busy at the furnace.
114"""
115)
116blacksmith_shop.title = "Blacksmith Shop"
117blacksmith_shop.short_desc = "You are in the blacksmith shop."
118
119# Side path away from fork
120side_path = GameArea(
121 """
122The path leads away from the fork to Dunhaven. Fresh tracks of
123something big, dragging something behind it, lead away to the south.
124"""
125)
126side_path.title = "Side path"
127side_path.short_desc = "You are standing on a side path."
128
129# Wizard's Hut
130wizard_hut = GameArea(
131 """
132The path opens into a shaded glen. A small stream wanders down the
133hills to the east and past an unassuming hut. In front of the hut,
134the local wizard Trent sits smoking a long clay pipe.
135"""
136)
137wizard_hut.title = "Wizard's Hut"
138wizard_hut.short_desc = "You are at the wizard's hut."
139
140# Cave mouth
141cave_mouth = GameArea(
142 """
143The path from Trent's hut follows the stream for a while before
144turning south away from the water. The trees begin closing overhead,
145blocking the sun and lending a chill to the air as you continue.
146
147The path finally terminates at the opening of a large cave. The
148tracks you have been following mix and mingle with others, both
149coming and going, but all the same. Whatever has been stealing
150your neighbor's livestock lives here, and comes and goes frequently.
151"""
152)
153cave_mouth.title = "Cave Mouth"
154cave_mouth.short_desc = "You are at the mouth of large cave."
155
156# Cave of the Giant
157giant_cave = GameArea(
158 """
159You take a few tentative steps into the cave. It feels much warmer
160and more humid than the cold sunless forest air outside. A steady
161drip of water from the rocks is the only sound for a while.
162
163You begin to make out a faint light ahead. You hug the wall and
164press on, as the light becomes brighter. You finally enter a
165chamber at least 20 meters across, with a fire blazing in the center.
166Cages line one wall, some empty, but others containing cows and
167sheep stolen from you neighbors. Opposite them are piles of the bones
168of the creatures unlucky enough to have already been devoured.
169
170As you look around, you become aware of another presence in the room.
171"""
172)
173giant_cave.title = "Cave of the Giant"
174giant_cave.short_desc = "You are in the giant's cave."
175
176# Set up the paths between areas
177home.south = hamlet
178hamlet.south = fork
179fork.west = village
180fork.east = side_path
181village.south = blacksmith_shop
182side_path.south = wizard_hut
183wizard_hut.west = cave_mouth
184cave_mouth.south = giant_cave
185
186# Lock some exits, since you can't leave until something else happens
187hamlet.locked_exits["south"] = True
188wizard_hut.locked_exits["west"] = True
189
190# Place items in different areas
191# These are just for flavor
192home.items.add(adventurelib_game_items.apple)
193fork.items.add(adventurelib_game_items.cloak)
194cave_mouth.items.add(adventurelib_game_items.slug)
195
196# Place characters where they should be
197hamlet.characters.add(adventurelib_game_characters.elder_barron)
198blacksmith_shop.characters.add(adventurelib_game_characters.blacksmith)
199wizard_hut.characters.add(adventurelib_game_characters.wizard_trent)
200giant_cave.characters.add(adventurelib_game_characters.giant)
1"""
2Items for the adventurelib Game
3"""
4
5# Import the adventurelib library
6import adventurelib as adv
7
8# All items have some basic properties
9adv.Item.color = "undistinguished"
10adv.Item.description = "a generic thing"
11adv.Item.edible = False
12adv.Item.wearable = False
13
14# Create your "flavor" items
15apple = adv.Item("small red apple", "apple")
16apple.color = "red"
17apple.description = "a small ripe red apple"
18apple.edible = True
19apple.wearable = False
20
21cloak = adv.Item("wool cloak", "cloak")
22cloak.color = "grey tweed"
23cloak.description = (
24 "a grey tweed cloak, heavy enough to keep the wind and rain at bay"
25)
26cloak.edible = False
27cloak.wearable = True
28
29slug = adv.Item("slimy brown slug", "slug")
30slug.color = "slimy brown"
31slug.description = "a fat, slimy, brown slug"
32slug.edible = True
33slug.wearable = False
34
35# Create the real items you need
36wooden_sword = adv.Item("wooden sword", "sword")
37wooden_sword.color = "brown"
38wooden_sword.description = (
39 "a small wooden practice sword, not even sharp enough to cut milk"
40)
41wooden_sword.edible = False
42wooden_sword.wearable = False
43wooden_sword.damage = 4
44wooden_sword.bonus = 0
45
46steel_sword = adv.Item("steel sword", "sword")
47steel_sword.color = "steely grey"
48steel_sword.description = (
49 "a finely made steel sword, honed to a razor edge, ready for blood"
50)
51steel_sword.edible = False
52steel_sword.wearable = False
53steel_sword.damage = 10
54steel_sword.bonus = 0
1"""
2Characters for the adventurelib Game
3"""
4
5# Import the adventurelib library
6import adventurelib as adv
7
8# All characters have some properties
9adv.Item.greeting = ""
10adv.Item.context = ""
11
12# Your characters
13elder_barron = adv.Item("Elder Barron", "elder", "barron")
14elder_barron.description = """Elder Barron, a tall distinguished member
15of the community. His steely grey hair and stiff beard inspire confidence."""
16elder_barron.greeting = (
17 "I have some information for you. Would you like to hear it?"
18)
19elder_barron.context = "elder"
20
21blacksmith = adv.Item("Alanna Smith", "Alanna", "blacksmith", "smith")
22blacksmith.description = """Alanna the blacksmith stands just 1.5m tall,
23and her strength lies in her arms and heart"""
24blacksmith.greeting = (
25 "Oh, hi! I've got some stuff for sale. Do you want to see it?"
26)
27blacksmith.context = "blacksmith"
28
29wizard_trent = adv.Item("Trent the Wizard", "Trent", "wizard")
30wizard_trent.description = """Trent's wizardly studies have apparently
31aged him past his years, but they have also preserved his life longer than
32expected."""
33wizard_trent.greeting = (
34 "It's been a long time since I've had a visitor? Do you seek wisdom?"
35)
36wizard_trent.context = "wizard"
37
38giant = adv.Item("hungry giant", "giant")
39giant.description = """Almost four meters of hulking brutish strength
40stands before you, his breath rank with rotten meat, his mangy hair
41tangled and matted"""
42giant.greeting = "Argh! Who dares invade my home? Prepare to defend yourself!"
43giant.context = "giant"
你可以用下面的命令开始这个游戏:
(venv) $ python adventurelib/adventurelib_game.py
在定义了背景故事之后,你绘制了不同的游戏区域和玩家在它们之间移动的路径:
每个区域都有与其相关联的各种属性,包括:
- 该区域中的项目和角色
- 一些出口被锁上了
- 标题、简短描述和较长描述
- 玩家是否到过这个区域的指示
为了确保每个区域都有自己的属性实例,您在第 15 到 41 行的adventurelib_game_rooms.py
中创建了一个名为GameArea
的Room
的子类。每个房间中的物品保存在一个名为items
的Bag
对象中,而角色存储在characters
中,在的第 28 行和第 31 行中定义。现在您可以创建GameArea
对象,描述它们,并用独特的项目和角色填充它们,这些都在第 9 行和第 12 行中导入。
一些游戏道具是完成游戏所必需的,而其他的只是为了增加趣味。风味项目被识别并放置在行 192 到 194 上,随后是行 197 到 200 上的字符。
你所有的游戏物品都在adventurelib_game_items.py
中被定义为Item()
类型的对象。游戏物品有定义它们的属性,但是因为你使用了Item
基类,一些基本的通用属性被添加到第 9 到 12 行的类中。创建项目时会用到这些属性。例如,apple
对象创建于第 15 到 19 行**,并在创建时定义每个通用属性。**
但是,某些项目具有该项目独有的特定属性。例如,wooden_sword
和steel_sword
物品需要属性来追踪它们造成的伤害和它们携带的魔法奖励。在43 至 44 线和 53 至 54 线追加。
与角色互动有助于推动游戏故事向前发展,并经常给玩家一个探索的理由。adventurelib 中的角色被创建为Item
对象,并且在adventurelib_game_characters.py
中定义了该游戏的角色。
每个角色,就像每个物品一样,都有与之相关的通用属性,比如长描述和玩家第一次遇到它时使用的问候语。这些属性在的第 9 行和第 10 行声明,并且在创建角色时为每个角色定义。
当然,如果你有角色,那么玩家与他们交谈和互动是有意义的。知道什么时候你在和一个角色互动,什么时候你和一个角色在同一个游戏区域通常是个好主意。
这是通过使用一个叫做上下文的 adventurelib 概念来完成的。上下文允许您针对不同的情况打开不同的命令。它们还允许某些命令有不同的行为,并跟踪玩家可能采取的行动的附加信息。
当游戏开始时,没有背景设定。随着玩家的前进,他们首先遇到了老巴伦。当玩家输入"talk to elder"
时,上下文被设置为elder.context
,在本例中是elder
。
老巴伦的问候以一个是或否的问题结束。如果玩家输入"yes"
,那么adventurelib_game.py
中行 173 上的命令处理程序被触发,定义为@when("yes", context="elder")
,如下图所示:
稍后,当玩家与铁匠交谈时,第二层的上下文被添加,以反映他们正在进行一场可能的武器交易。第 203 到 233 行定义了与铁匠的讨论,包括提供武器交易。在第 222 行上定义了一个新的上下文,这允许以多种方式优雅地使用同一个"yes"
命令。
您还可以在命令处理程序中检查上下文。例如,玩家不能简单地通过结束对话来离开与巨人的战斗。在行 389 到 403 定义的"goodbye"
命令处理程序检查玩家是否在"giant"
上下文中,这是当他们开始与巨人战斗时进入的。如果是这样,他们不允许停止谈话——这是一场殊死搏斗!
你也可以问玩家一些需要具体答案的问题。当玩家与巫师特伦特交谈时,他们被要求解答一个谜语。不正确的答案将结束互动。虽然正确答案由第 252 到 262 行上的命令处理程序处理,但几乎无限的错误答案中有一个与任何处理程序都不匹配。
没有匹配的命令由行 497 到 503 上的no_command_matches()
函数处理。通过检查第行 498 上的wizard.riddle
上下文,您可以利用这一点来处理向导谜语的错误答案。任何不正确的答案将导致向导结束对话。通过将adventurelib.no_command_matches
设置为您的新函数,您可以在行 523 上将它连接到 adventurelib。
您可以通过编写一个返回新提示的函数来自定义显示给播放器的提示。您的新提示定义在第 483 到 495 行的上,并连接到第 520 行的上的 adventurelib。
当然,你还可以添加更多。创建一个完整的文本冒险游戏是具有挑战性的,adventurelib 确保主要的挑战在于用文字画一幅画。
Ren’Py
纯文本冒险的现代后代是视觉小说,它突出了游戏的讲故事方面,限制了玩家的互动,同时添加了视觉和声音来增强体验。视觉小说是游戏世界的图画小说——现代的、创新的、极具吸引力的创作和消费。
Ren’Py 是一款基于 Pygame 的工具,专为创作视觉小说而设计。Ren’Py 的名字来自日语,意为浪漫爱情,它为制作引人入胜的视觉小说提供了工具和框架。
公平地说,Ren’Py 严格来说并不是一个你可以使用的 Python 库。Ren’Py 游戏是使用 Ren’Py Launcher 创建的,它带有完整的 Ren’Py SDK。这个启动器也有一个游戏编辑器,虽然你可以在你选择的编辑器中编辑你的游戏。Ren’Py 还拥有自己的游戏创作脚本语言。然而,Ren’Py 基于 Pygame,并且可以使用 Python 进行扩展,这保证了它在这里的出现。
Ren’Py 装置
如前所述,Ren’Py 不仅需要 SDK,还需要 Ren’Py 启动器。这些都打包在一个单元里,你需要下载。
知道下载哪个包以及如何安装取决于您的平台。Ren’Py 为 Windows、macOS 和 Linux 用户提供安装程序和说明:
**Windows 用户应该下载提供的可执行文件,然后运行它来安装 SDK 和 Ren’Py Launcher。
Linux 用户应该将提供的 tarball 下载到一个方便的位置,然后使用bunzip2
展开它。
macOS 用户应该下载提供的 DMG 文件,双击该文件将其打开,并将内容复制到一个方便的位置。
软件包安装完成后,您可以导航到包含 SDK 的文件夹,然后运行 Ren’Py 启动器。Windows 用户要用renpy.exe
,macOS 和 Linux 用户要运行renpy.sh
。这将首次启动 Ren’Py 启动器:
在这里,您将开始新的 Ren’Py 项目,处理现有项目,并设置 Ren’Py 的整体首选项。
基本概念
Ren’Py 游戏在 Ren’Py 启动器中作为新项目启动。创建一个将会为一个游戏建立正确的文件和文件夹结构。项目建立后,您可以使用自己的编辑器编写游戏,尽管运行游戏需要 Ren’Py 启动器:
任我行游戏包含在名为脚本的文件中。不要把 Ren’Py 脚本当成 shell 脚本。它们更类似于戏剧或电视节目的脚本。瑞文脚本的扩展名为.rpy
,是用瑞文语言编写的。您的游戏可以包含任意多的脚本,这些脚本都存储在项目文件夹的game/
子文件夹中。
创建新的 Ren’Py 项目时,会创建以下脚本供您使用和更新:
gui.rpy
,它定义了游戏中使用的所有 UI 元素的外观options.rpy
,它定义了可改变的选项来定制你的游戏screens.rpy
,定义了用于对话、菜单和其他游戏输出的样式- 这是你开始编写游戏的地方
要运行本教程下载资料中的示例游戏,您将使用以下过程:
- 启动 Ren’Py 发射器。
- 点击首选项,然后点击项目目录。
- 将项目目录更改为您下载的存储库中的
renpy
文件夹。 - 点击返回返回到主启动页面。
您会在左侧的项目列表中看到basic_game
和giant_quest_game
。选择您希望运行的项目,然后点击启动项目。
对于这个例子,您将只修改basic_game
的script.rpy
文件。这个游戏的完整代码可以在下载的资料中找到,也可以在下面找到:
1# The script of the game goes in this file.
2
3# Declare characters used by this game. The color argument colorizes the
4# name of the character.
5
6define kevin = Character("Kevin", color="#c8ffc8")
7define mom = Character("Mom", color="#c8ffff")
8define me = Character("Me", color="#c8c8ff")
9
10# The game starts here.
11
12label start:
13
14 # Some basic narration to start the game
15
16 "You hear your alarm going off, and your mother calling to you."
17
18 mom "It's time to wake up. If I can hear your alarm,
19 you can hear it to - let's go!"
20
21 "Reluctantly you open your eyes."
22
23 # Show a background.
24
25 scene bedroom day
26
27 # This shows the basic narration
28
29 "You awaken in your bedroom after a good night's rest.
30 Laying there sleepily, your eyes wander to the clock on your phone."
31
32 me "Yoinks! I'm gonna be late!"
33
34 "You leap out of bed and quickly put on some clothes.
35 Grabbing your book bag, you sprint for the door to the living room."
36
37 scene hallway day
38
39 "Your brother is waiting for you in the hall."
40
41 show kevin normal
42
43 kevin "Let's go, loser! We're gonna be late!"
44
45 mom "Got everything, honey?"
46
47 menu:
48 "Yes, I've got everything.":
49 jump follow_kevin
50
51 "Wait, I forgot my phone!":
52 jump check_room
53
54label check_room:
55
56 me "Wait! My phone!"
57
58 kevin "Whatever. See you outside!"
59
60 "You sprint back to your room to get your phone."
61
62 scene bedroom day
63
64 "You grab the phone from the nightstand and sprint back to the hall."
65
66 scene hallway day
67
68 "True to his word, Kevin is already outside."
69
70 jump outside
71
72label follow_kevin:
73
74 kevin "Then let's go!"
75
76 "You follow Kevin out to the street."
77
78label outside:
79
80 scene street
81
82 show kevin normal
83
84 kevin "About time you got here. Let's Go!"
85
86 # This ends the game
87 return
标签定义你的故事的切入点,通常用于开始新的场景,并提供贯穿整个故事的替代路径。所有 Ren’Py 游戏都从第label start:
行开始运行,这可以出现在你选择的任何脚本中。你可以在script.rpy
的第 12 行看到这个。
您还可以使用标签来定义背景图像,设置场景之间的过渡,以及控制角色的外观。在该示例中,第二个场景从第行第 54 开始,第label check_room:
行开始。
一行中用双引号括起来的文本称为 say 语句。一行中的单个字符串被视为叙述。两个字符串被视为对话,首先识别一个字符,然后提供他们正在说的台词。
在游戏开始时,旁白出现在第 16 行的处,设定场景。对话在第 18 行提供,当你妈妈叫你的时候。
你可以通过在故事中简单地命名来定义角色。但是,您也可以在脚本的顶部定义字符。你可以在的第 6 到第 8 行中看到这一点,这里定义了你、你的兄弟凯文和你的妈妈。define
语句将三个变量初始化为Characters
,给它们一个显示名称,后跟一个用于显示名称的文本颜色。
当然,这是一部视觉小说,所以伦比有办法处理图像是有道理的。像 Pygame Zero 一样,Ren’Py 要求游戏中使用的所有图像和声音都保存在特定的文件夹中。图像在gaimg/
文件夹中,声音在game/audio/
文件夹中。在游戏脚本中,你通过文件名来引用它们,没有任何文件扩展名。
第 25 行展示了这一点,当你睁开眼睛,第一次看到你的卧室。scene
关键字清除屏幕,然后显示bedroom day.png
图像。Ren’Py 支持 JPG、WEBP 和 PNG 图像格式。
您也可以使用show
关键字和相同的图像命名约定在屏幕上显示字符。第 41 行显示了你弟弟凯文的照片,存储为kevin normal.png
。
当然,如果你不能做出决定来影响结果,这就不是一场游戏。在 Ren’Py 中,玩家从游戏过程中出现的菜单中做出选择。游戏通过跳转到预定义的标签、改变角色图像、播放声音或采取其他必要的动作来做出反应。
这个例子中的一个基本选择显示在第 47 到 52 行中,这时你意识到你忘记带手机了。在一个更完整的故事中,这个选择可能会在以后产生后果。
当然,你可以用 Ren’Py 做更多的事情。您可以控制场景之间的转换,让角色以特定的方式进入和离开场景,并为您的游戏添加声音和音乐。Ren’Py 还支持编写更复杂的 Python 代码,包括使用 Python 数据类型和直接调用 Python 函数。现在让我们在一个更高级的应用程序中仔细看看这些功能。
高级应用程序
为了展示 Ren’Py 的深度,您将实现与 adventurelib 相同的游戏。提醒一下,这是游戏的基本设计:
- 你住在一个安静的小村庄里。
- 最近,你的邻居开始抱怨丢失的牲畜。
- 作为夜间巡逻队的一员,你注意到一个破损的栅栏和一条离开它的痕迹。
- 你决定去调查,只带了一把练习用的木剑。
这个游戏有几个需要定义和提供图像的区域。例如,您将需要图像和定义,用于您的安静小村庄、远离田野的小径、附近可以购买更好武器的村庄、通向可以为您的武器附魔的巫师的小路,以及包含一直在掠夺您牲畜的巨人的洞穴。
也有一些字符来定义和提供图像。你需要一个能给你更好武器的铁匠,一个能给你武器附魔的巫师,还有一个你需要打败的巨人。
对于本例,您将创建四个单独的脚本:
script.rpy
,这是游戏开始的地方town.rpy
,其中包含了附近村庄的故事path.rpy
,其中包含了村庄之间的道路giant.rpy
,其中包含了巨人战斗的逻辑
你可以单独练习创建向导遭遇战。
这个游戏的完整代码可以在renpy_sample/giant_quest/
的下载资料中找到,也可以在下面找到:
1#
2# Complete game in Ren'Py
3#
4# This game demonstrates some of the more advanced features of
5# Ren'Py, including:
6# - Multiple sprites
7# - Handling user input
8# - Selecting alternate outcomes
9# - Tracking score and inventory
10#
11
12## Declare characters used by this game. The color argument colorizes the
13## name of the character.
14define player = Character("Me", color="#c8ffff")
15define smith = Character("Miranda, village blacksmith", color="#99ff9c")
16define wizard = Character("Endeavor, cryptic wizard", color="#f4d3ff")
17define giant = Character("Maull, terrifying giant", color="#ff8c8c")
18
19## Images used in the game
20# Backgrounds
21image starting path = "BG10a_1280.jpg"
22image crossroads = "BG19a01_1280.jpg"
23
24# Items
25image wooden sword = "SwordWood.png"
26image steel sword = "Sword.png"
27image enchanted sword = "SwordT2.png"
28
29## Default settings
30# What is the current weapon?
31default current_weapon = "wooden sword"
32
33# What is the weapon damage?
34# These change when the weapon is upgraded or enchanted
35default base_damage = 4
36default multiplier = 1
37default additional = 0
38
39# Did they cross the bridge to town?
40default cross_bridge = False
41
42# You need this for the giant battle later
43
44init python:
45 from random import randint
46
47# The game starts here.
48
49label start:
50
51 # Show the initial background.
52
53 scene starting path
54 with fade
55
56 # Begin narration
57
58 "Growing up in a small hamlet was boring, but reliable and safe.
59 At least, it was until the neighbors began complaining of missing
60 livestock. That's when the evening patrols began."
61
62 "While on patrol just before dawn, your group noticed broken fence
63 around a cattle paddock. Beyond the broken fence,
64 a crude trail had been blazed to a road leading away from town."
65
66 # Show the current weapon
67 show expression current_weapon at left
68 with moveinleft
69
70 "After reporting back to the town council, it was decided that you
71 should follow the tracks to discover the fate of the livestock.
72 You picked up your only weapon, a simple wooden practice sword,
73 and set off."
74
75 scene crossroads
76 with fade
77
78 show expression current_weapon at left
79
80 "Following the path, you come to a bridge across the river."
81
82 "Crossing the bridge will take you to the county seat,
83 where you may hear some news or get supplies.
84 The tracks, however, continue straight on the path."
85
86 menu optional_name:
87 "Which direction will you travel?"
88
89 "Cross the bridge":
90 $ cross_bridge = True
91 jump town
92 "Continue on the path":
93 jump path
94
95 "Your quest is ended!"
96
97 return
1##
2## Code for the interactions in town
3##
4
5## Backgrounds
6image distant town = "4_road_a.jpg"
7image within town = "3_blacksmith_a.jpg"
8
9# Characters
10image blacksmith greeting = "blacksmith1.png"
11image blacksmith confused = "blacksmith2.png"
12image blacksmith happy = "blacksmith3.png"
13image blacksmith shocked = "blacksmith4.png"
14
15label town:
16
17 scene distant town
18 with fade
19
20 show expression current_weapon at left
21
22 "Crossing the bridge, you stride away from the river along a
23 well worn path. The way is pleasant, and you find yourself humming
24 a tune as you break into a small clearing."
25
26 "From here, you can make out the county seat of Fetheron.
27 You feel confident you can find help for your quest here."
28
29 scene within town
30 with fade
31
32 show expression current_weapon at left
33
34 "As you enter town, you immediately begin seeking the local blacksmith.
35 After asking one of the townsfolk, you find the smithy on the far
36 south end of town. You approach the smithy,
37 smelling the smoke of the furnace long before you hear
38 the pounding of hammer on steel."
39
40 player "Hello! Is the smith in?"
41
42 smith "Who wants to know?"
43
44 show blacksmith greeting
45
46 "The blacksmith appears from her bellows.
47 She greets you with a warm smile."
48
49 smith "Oh, hello! You're from the next town over, right?"
50
51 menu:
52 "Yes, from the other side of the river.":
53 show blacksmith happy
54
55 smith "I thought I recognized you. Nice to see you!"
56
57 "Look, I don't have time for pleasantries, can we get to business?":
58 show blacksmith shocked
59
60 smith "Hey, just trying to make conversation"
61
62 smith "So, what can I do for you?"
63
64 player "I need a better weapon than this wooden thing."
65
66 show blacksmith confused
67
68 smith "Are you going to be doing something dangerous?"
69
70 player "Have you heard about the missing livestock in town?"
71
72 smith "Of course. Everyone has. What do you know about it?"
73
74 player "Well, I'm tracking whatever took them from our town."
75
76 smith "Oh, I see. So you want something better to fight with!"
77
78 player "Exactly! Can you help?"
79
80 smith "I've got just the thing. Been working on it for a while,
81 but didn't know what to do with it. Now I know."
82
83 "Miranda walks back past the furnace to a small rack.
84 On it, a gleaming steel sword rests.
85 She picks it up and walks back to you."
86
87 smith "Will this do?"
88
89 menu:
90 "It's perfect!":
91 show blacksmith happy
92
93 smith "Wonderful! Give me the wooden one -
94 I can use it in the furnace!"
95
96 $ current_weapon = "steel sword"
97 $ base_damage = 6
98 $ multiplier = 2
99
100 "Is that piece of junk it?":
101 show blacksmith confused
102
103 smith "I worked on this for weeks.
104 If you don't want it, then don't take it."
105
106 # Show the current weapon
107 show expression current_weapon at left
108
109 smith "Anything else?"
110
111 player "Nope, that's all."
112
113 smith "Alright. Good luck!"
114
115 scene distant town
116 with fade
117
118 show expression current_weapon at left
119
120 "You make your way back through town.
121 Glancing back at the town, you wonder if
122 you can keep them safe too."
123
124 jump path
1##
2## Code for the interactions in town
3##
4
5## Backgrounds
6image path = "1_forest_a.jpg"
7image wizard hut = "BG600a_1280.jpg"
8
9# Characters
10image wizard greeting = "wizard1.png"
11image wizard happy = "wizard2.png"
12image wizard confused = "wizard3.png"
13image wizard shocked = "wizard4.png"
14
15label path:
16
17 scene path
18 with fade
19
20 show expression current_weapon at left
21
22 "You pick up the tracks as you follow the path through the woods."
23
24 jump giant_battle
1##
2## Code for the giant battle
3##
4
5## Backgrounds
6image forest = "forest_hill_night.jpg"
7
8# Characters
9image giant greeting = "giant1.png"
10image giant unhappy = "giant2.png"
11image giant angry = "giant3.png"
12image giant hurt = "giant4.png"
13
14# Text of the giant encounter
15label giant_battle:
16
17 scene forest
18 with fade
19
20 show expression current_weapon at left
21
22 "As you follow the tracks down the path, night falls.
23 You hear sounds in the distance:
24 cows, goats, sheep. You've found the livestock!"
25
26 show giant greeting
27
28 "As you approach the clearing and see your villages livestock,
29 a giant appears."
30
31 giant "Who are you?"
32
33 player "I've come to get our livestock back."
34
35 giant "You and which army, little ... whatever you are?"
36
37 show giant unhappy
38
39 "The giant bears down on you - the battle is joined!"
40
41python:
42
43 def show_giant_condition(giant_hp):
44 if giant_hp < 10:
45 renpy.say(None, "The giant staggers, his eyes unfocused.")
46 elif giant_hp < 20:
47 renpy.say(None, "The giant's steps become more unsteady.")
48 elif giant_hp < 30:
49 renpy.say(
50 None, "The giant sweats and wipes the blood from his brow."
51 )
52 elif giant_hp < 40:
53 renpy.say(
54 None,
55 "The giant snorts and grits his teeth against the pain.",
56 )
57 else:
58 renpy.say(
59 None,
60 "The giant smiles and readies himself for the attack.",
61 )
62
63 def show_player_condition(player_hp):
64 if player_hp < 4:
65 renpy.say(
66 None,
67 "Your eyes lose focus on the giant as you sway unsteadily.",
68 )
69 elif player_hp < 8:
70 renpy.say(
71 None,
72 "Your footing becomes less steady as you swing your sword sloppily.",
73 )
74 elif player_hp < 12:
75 renpy.say(
76 None,
77 "Blood mixes with sweat on your face as you wipe it from your eyes.",
78 )
79 elif player_hp < 16:
80 renpy.say(
81 None,
82 "You bite down as the pain begins to make itself felt.",
83 )
84 else:
85 renpy.say(None, "You charge into the fray valiantly!")
86
87 def fight_giant():
88
89 # Default values
90 giant_hp = 50
91 player_hp = 20
92 giant_damage = 4
93
94 battle_over = False
95 player_wins = False
96
97 # Keep swinging until something happens
98 while not battle_over:
99
100 renpy.say(
101 None,
102 "You have {0} hit points. Do you want to fight or flee?".format(
103 player_hp
104 ),
105 interact=False,
106 )
107 battle_over = renpy.display_menu(
108 [("Fight!", False), ("Flee!", True)]
109 )
110
111 if battle_over:
112 player_wins = False
113 break
114
115 # The player gets a swing
116 player_attack = (
117 randint(1, base_damage + 1) * multiplier + additional
118 )
119 renpy.say(
120 None,
121 "You swing your {0}, doing {1} damage!".format(
122 current_weapon, player_attack
123 ),
124 )
125 giant_hp -= player_attack
126
127 # Is the giant dead?
128 if giant_hp <= 0:
129 battle_over = True
130 player_wins = True
131 break
132
133 show_giant_condition(giant_hp)
134
135 # Then the giant tries
136 giant_attack = randint(0, giant_damage)
137 if giant_attack == 0:
138 renpy.say(
139 None,
140 "The giant's arm whistles harmlessly over your head!",
141 )
142 else:
143 renpy.say(
144 None,
145 "The giant swings his mighty fist, and does {0} damage!".format(
146 giant_attack
147 ),
148 )
149 player_hp -= giant_attack
150
151 # Is the player dead?
152 if player_hp <= 0:
153 battle_over = True
154 player_wins = False
155
156 show_player_condition(player_hp)
157
158 # Return who died
159 return player_wins
160
161 # fight_giant returns True if the player wins.
162 if fight_giant():
163 renpy.jump("player_wins")
164 else:
165 renpy.jump("giant_wins")
166
167label player_wins:
168
169 "The giant's eyes glaze over as he falls heavily to the ground.
170 The earth shakes as his bulk lands face down,
171 and his death rattle fills the air."
172
173 hide giant
174
175 "You are victorious! The land is safe from the giant!"
176
177 return
178
179label giant_wins:
180
181 "The giant takes one last swing, knocking you down.
182 Your vision clouds, and you see the ground rising to meet you.
183 As you slowly lose consciousness, your last vision is
184 the smiling figure of the giant as he advances on you."
185
186 "You have lost!"
187
188 return
和前面的例子一样,在脚本从script.rpy
的第 14 到 17 行的开始之前,您定义了Character()
对象。
您还可以定义背景或人物image
对象以备后用。第 21 行到第 27 行定义了几个你稍后会用到的图像,既可以用作背景,也可以作为项目显示。使用此语法,您可以为图像指定更短、更具描述性的内部名称。稍后,您将看到它们是如何显示的。
你还需要追踪装备武器的能力。这是在第 31 到 37 行的中完成的,使用的是default
变量值,你将在稍后的大型战役中用到。
为了表明哪种武器被激活,你把图像显示为一个表达式。人物表情是显示在游戏窗口角落的小图像,用来显示各种各样的信息。在这个游戏中,你先在第 67 行和第 68 行的处使用一个表达式来显示武器。
show
命令记录了许多修饰符。with moveinleft
修改器使current_weapon
图像从左边滑动到屏幕上。此外,重要的是要记住,每次scene
改变,整个屏幕都被清除,要求你再次显示当前的武器。你可以在第 75 到 78 行看到。
当你在town.rpy
进入城镇时,你遇到铁匠,他向你打招呼:
铁匠为你提供升级武器的机会。如果你选择这样做,那么你就更新了current_weapon
和武器属性的值。这在第 93 到 98 行完成。
以$
字符开头的行被 Ren’Py 解释为 Python 语句,允许您根据需要编写任意的 Python 代码。更新current_weapon
和武器统计是使用第 96 到 98 行的三个 Python 语句完成的,这些语句改变了您在script.rpy
顶部定义的default
变量的值。
您还可以使用一个python:
部分定义一大块 Python 代码,如从行 41 开始的giant.rpy
所示。
第 43 到 61 行包含了一个助手功能,根据巨人剩余的生命值来显示巨人的状况。它使用renpy.say()
方法将叙述输出回主窗口。在第 63 到 85 行中可以看到一个类似的显示玩家状态的助手功能。
战斗由 87 到 159 线上的fight_giant()
控制。游戏循环在线 98** 上实现,并由battle_over
变量控制。玩家选择战斗或逃跑是使用renpy.display_menu()
方法显示的。**
如果玩家战斗,那么在线 116 到 118 上造成随机数量的伤害,并且调整巨人的生命值。如果巨人还活着,那么他们会在的第 136 到 149 行以类似的方式攻击。注意巨人有机会失手,而玩家总是命中。战斗持续到玩家或巨人的生命值为零或者玩家逃跑:
需要注意的是,这段代码与您在 adventurelib 战斗中使用的代码非常相似。这展示了如何将完整的 Python 代码放到你的 Ren’Py 游戏中,而不需要将其翻译成 Ren’Py 脚本。
除了你在这里尝试过的,还有很多值得尝试的。查阅 Ren’Py 文档了解更多完整细节。
其他著名的 Python 游戏引擎
这五个引擎只是众多不同 Python 游戏引擎中的一小部分。还有其他几十种方法,这里有一些值得注意:
-
芥末 2D 由 Pygame Zero 的团队开发。这是一个建立在 moderngl 基础上的现代框架,可以自动渲染,为动画效果提供协同程序,内置粒子效果,并使用事件驱动模型玩游戏。
-
cocos2d 是一个为跨平台游戏编码而设计的框架。可悲的是,cocos2d-python 从 2017 年开始就没有更新过。
-
熊猫 3D 是一个用于创建 3D 游戏和 3D 渲染的开源框架。Panda 3D 可跨平台移植,支持多种资产类型,开箱即可与众多第三方库连接,并提供内置的管道分析。
-
Ursina 建立在熊猫 3D 的基础上,提供了一个专用的游戏开发引擎,简化了熊猫 3D 的许多方面。在撰写本文时,Ursina 得到了很好的支持和很好的文档,正在积极开发中。
-
purchased ybear 被宣传为教育图书馆。它拥有一个场景管理系统,基于帧的动画精灵,可以暂停,进入门槛低。文档很少,但是帮助只是 GitHub 讨论的一部分。
每天都有新的 Python 游戏引擎诞生。如果你找到了一个适合你的需求,但这里没有提到的,请在评论中赞美它!
游戏资产的来源
通常,创建游戏资产是游戏作者面临的最大问题。大型视频游戏公司雇佣艺术家、动画师和音乐家团队来设计游戏的外观和声音。有编码背景的单人游戏开发者可能会发现游戏开发的这一方面令人望而生畏。幸运的是,游戏资产有许多不同的来源。以下是在本教程中为游戏定位资产的一些重要因素:
-
OpenGameArt.org 为 2D 和 3D 游戏提供了各种各样的游戏艺术、音乐、背景、图标和其他资源。艺术家和音乐家列出了可供下载的资产,您可以下载并在游戏中使用这些资产。大多数资产都是免费的,许可条款可能适用于其中的许多资产。
-
Kenney.nl 拥有一系列免费和付费的资产,其中许多在别处是找不到的。捐款总是受欢迎的,以支持免费资产,这些资产都被授权用于商业游戏。
-
Itch.io 是一个面向专注于独立游戏开发的数字创作者的市场。在这里,你可以找到任何用途的数字资产,包括免费和付费的,还有完整的游戏。个人创作者在这里控制他们自己的内容,所以你总是直接与有才华的个人一起工作。
第三方提供的大多数资产都带有许可条款,规定了资产的正确和允许使用。作为这些资产的用户,您有责任阅读、理解并遵守资产所有者定义的许可条款。如果您对这些条款有任何疑问或疑虑,请咨询法律专业人士寻求帮助。
本文引用的游戏中使用的所有资产都符合各自的许可要求。
结论
恭喜你,伟大的游戏设计现在触手可及!多亏了 Python 和一系列高性能的 Python 游戏引擎,你可以比以前更容易地创作出高质量的电脑游戏。在本教程中,您已经探索了几个这样的游戏引擎,学习了开始制作自己的 Python 视频游戏所需的信息!
到目前为止,您已经看到了一些顶级 Python 游戏引擎的运行,并且您已经:
- 探究了几种流行的 Python 游戏引擎的优缺点
- 体验了他们如何将与单机游戏引擎进行比较
- 了解其他可用的 Python 游戏引擎
如果你想查看本教程中游戏的代码,你可以点击下面的链接:
获取源代码: 点击此处获取您将使用试用 Python 游戏引擎的源代码。
现在你可以根据你的目的选择最好的 Python 游戏引擎。你还在等什么?出去写些游戏吧!*************