RealPython 中文系列教程(一百二十八)

原文:RealPython

协议:CC BY-NC-SA 4.0

使用 Flask 的基于令牌的认证

原文:https://realpython.com/token-based-authentication-with-flask/

本教程采用测试优先的方法,使用 JSON Web 令牌(jwt)在 Flask 应用程序中实现基于令牌的认证。

更新:

目标

本教程结束时,您将能够…

  1. 讨论使用 jwt 与会话和 cookies 进行身份验证的优势
  2. 用 JWTs 实现用户认证
  3. 必要时将用户令牌列入黑名单
  4. 编写测试来创建和验证 jwt 和用户认证
  5. 实践测试驱动的开发

免费奖励: 点击此处获得免费的 Flask + Python 视频教程,向您展示如何一步一步地构建 Flask web 应用程序。

Remove ads

简介

JSON Web 令牌(或 JWTs)提供了一种从客户端向服务器传输信息的方式,这是一种安全的无状态的方式。

在服务器上,jwt 是通过使用秘密密钥对用户信息进行签名而生成的,然后安全地存储在客户机上。这种形式的身份验证与现代的单页面应用程序配合得很好。有关这方面的更多信息,以及使用 JWTs 与会话和基于 cookie 的身份验证的优缺点,请查看以下文章:

  1. 饼干 vs 代币:权威指南
  2. 令牌认证与 cookie
  3. 在 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

Remove ads

迁移

在“服务器”目录中添加一个 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

Remove ads

编码令牌

将下面的方法添加到项目/服务器/模型. 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。如果无效,可能有两种例外情况:

  1. 过期签名:当令牌过期后被使用时,它抛出一个ExpiredSignatureError异常。这意味着有效载荷的exp字段中指定的时间已经过期。
  2. 无效令牌:当提供的令牌不正确或格式不正确时,就会引发一个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)

确保在继续之前通过测试。

**注意:**我们稍后将通过将无效令牌列入黑名单来处理它们。

Remove ads

路线设置

现在,我们可以使用测试优先的方法来配置授权路由:

  • /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)

在进入下一条路线之前,再次进行测试。一切都会过去。

Remove ads

登录路线

再次,从一个测试开始。为了验证登录 API,让我们测试两种情况:

  1. 注册用户登录
  2. 非注册用户登录

注册用户登录

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

还有一条路要走!

Remove ads

注销路由测试

测试有效注销:

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

现在,代码必须:

  1. 验证身份验证令牌
  2. 将令牌列入黑名单(当然,如果有效的话)

在编写路由处理程序之前,让我们为黑名单令牌创建一个新模型…

黑名单

将以下代码添加到项目/服务器/模型. 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

Remove ads

重构

最后,我们需要确保令牌没有被列入黑名单,就在令牌被解码之后- 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

Remove ads

代码气味

最后看一下 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 游戏引擎

原文:https://realpython.com/top-python-game-engines/

和很多人一样,也许你刚开始学编码的时候想写视频游戏。但是那些游戏和你玩过的游戏一样吗?可能你刚开始的时候没有 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 游戏引擎的源代码。

下载完源代码后,您就可以开始了。

Remove ads

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 提供了实现游戏循环的方法和函数,但是它没有自动提供。游戏作者应该实现游戏循环的功能。

游戏循环的每次迭代被称为一个。每一帧,游戏执行四个重要动作:

  1. 处理用户输入。使用事件模型处理 Pygame 中的用户输入。鼠标和键盘输入会生成事件,这些事件可以被读取和处理,也可以被忽略。Pygame 本身不提供任何事件处理程序。

  2. 更新游戏对象的状态。游戏对象可以使用任何 Pygame 数据结构或特殊的 Pygame 类来表示。诸如精灵图像、字体和颜色等对象可以在 Python 中创建和扩展,以提供尽可能多的状态信息。

  3. 更新显示和音频输出。 Pygame 提供了对显示器和声音硬件的抽象访问。displaymixermusic模块允许游戏作者在游戏设计和实现中具有灵活性。

  4. 保持游戏速度。 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 程序!

Remove ads

高级应用程序

当然,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事件,定时器在线 91coin_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。

Remove ads

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 行上的常量WIDTHHEIGHT指的是窗口的大小,并自动使用这些尺寸来创建窗口。另外,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 中被称为演员,他们有一些需要解释的特征:

  1. Pygame Zero 提供了Actor类。每个Actor至少有一个图像和一个位置。
  2. Pygame Zero 程序中使用的所有图像必须位于名为img/`的子文件夹中,并且只能使用小写字母、数字和下划线命名。
  3. 仅使用图像的基本名称引用图像。例如,如果你的图像被称为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提供的位置绘制精灵图像。

接下来,您将在更高级的游戏中使用这些技术。

Remove ads

高级应用程序

为了演示游戏引擎之间的区别,您将再次访问您在 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 到 113coin_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 处于不断的开发中,在社区中得到很好的支持,并且有一个对问题、错误报告和潜在修复非常敏感的作者。

Remove ads

街机安装

要安装 Arcade 及其依赖项,使用相应的 pip 命令:

(venv) $ python -m pip install arcade

基于您的平台的完整安装说明可用于 WindowsmacOSLinux 。如果你愿意,你甚至可以直接从安装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 在结构上类似,但提供了更强大和更广泛的功能。

Remove ads

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()函数。

玩家输入的命令不区分大小写。玩家可以键入lookLOOKLook,甚至lOOk,adventurelib 会找到正确的命令。

多个命令可以使用相同的功能,如第 78 到 110 行go()功能所示。这个功能由九个独立的命令装饰,允许玩家以几种不同的方式在游戏世界中移动。在下面的游戏示例中,命令southeastnorth都被使用,但是每一个都导致相同的函数被调用:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

有时候玩家输入的命令是针对某个特定的物品的。例如,玩家可能想看某个特定的东西或者朝某个特定的方向走。游戏设计者可以通过在@when()装饰器中指定大写单词来捕获额外的命令上下文。这些被视为变量名,玩家在它们的位置键入的文本就是值。

这可以在第 128 到 137 行的look_at()功能中看到。这个函数定义了一个名为item的字符串参数。在定义look atinspect命令的@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 行,你定义了用于打开客厅和前廊之间的门的keyItem()构造函数接受一个或多个字符串。第一个是项目的默认名称或全名,在打印项目名称时使用。所有其他名称都被用作别名,因此玩家不必键入项目的全名。

这个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时,你试图在行 151take()房间contents包中的物品。如果key被归还,它也会从房间的contents中移除。然后将key添加到玩家的inventory中的行 156 处。

Remove ads

高级应用程序

当然,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中创建了一个名为GameAreaRoom的子类。每个房间中的物品保存在一个名为itemsBag对象中,而角色存储在characters中,在的第 28 行和第 31 行中定义。现在您可以创建GameArea对象,描述它们,并用独特的项目和角色填充它们,这些都在第 9 行和第 12 行中导入。

一些游戏道具是完成游戏所必需的,而其他的只是为了增加趣味。风味项目被识别并放置在行 192 到 194 上,随后是行 197 到 200 上的字符。

你所有的游戏物品都在adventurelib_game_items.py中被定义为Item()类型的对象。游戏物品有定义它们的属性,但是因为你使用了Item基类,一些基本的通用属性被添加到第 9 到 12 行的类中。创建项目时会用到这些属性。例如,apple对象创建于第 15 到 19 行**,并在创建时定义每个通用属性。**

但是,某些项目具有该项目独有的特定属性。例如,wooden_swordsteel_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 确保主要的挑战在于用文字画一幅画。

Remove ads

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,定义了用于对话、菜单和其他游戏输出的样式
  • 这是你开始编写游戏的地方

要运行本教程下载资料中的示例游戏,您将使用以下过程:

  1. 启动 Ren’Py 发射器。
  2. 点击首选项,然后点击项目目录
  3. 将项目目录更改为您下载的存储库中的renpy文件夹。
  4. 点击返回返回到主启动页面。

您会在左侧的项目列表中看到basic_gamegiant_quest_game。选择您希望运行的项目,然后点击启动项目

对于这个例子,您将只修改basic_gamescript.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 函数。现在让我们在一个更高级的应用程序中仔细看看这些功能。

Remove ads

高级应用程序

为了展示 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 文档了解更多完整细节。

Remove ads

其他著名的 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 游戏引擎。你还在等什么?出去写些游戏吧!*************

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值