
欢迎来到《我们的2D创造世界》!这是一款基于Web技术打造的多人联机2D沙盒创造游戏,融合了经典沙盒游戏的自由创造与现代化多人联机体验。在这里,你可以与全球玩家实时互动,共同创造属于你们的独特世界。
🎮 核心玩法特色
本游戏采用直观的侧视角2D界面,操作简单却充满无限可能。通过点击鼠标即可放置或破坏方块,25种不同类型的方块任你选择------从基础材料如草地、泥土、石头,到珍贵矿物如钻石、黄金、铁矿,再到装饰性方块如玻璃、砖块、书架,每一种方块都能让你的创造力得以充分展现。
🌍 沉浸式多人体验
游戏最大的亮点在于实时多人联机功能。你可以看到其他玩家在世界的各个角落忙碌着,或建造宏伟城堡,或挖掘地下隧道,或合作搭建大型工程。内置的聊天系统让你随时与伙伴沟通策略,分享建造灵感,让创造不再孤单。
🏗️ 创造的自由度
从简单的茅草屋到复杂的地下城,从宁静的花园到壮观的天空之城,一切皆由你手。《我们的2D创造世界》提供了真正的创造自由------没有任务限制,没有等级要求,只有你和你的想象力。每天登录都会看到世界的新变化,因为其他玩家也在不断创造新的景观和建筑。
🎨 个性化体验
每位玩家都可以从六种不同颜色的皮肤中选择自己喜欢的形象,在众多玩家中脱颖而出。精心设计的用户界面让你轻松切换方块类型,实时查看在线玩家,管理自己的创造项目。
🔧 技术亮点
游戏采用现代化的Web技术栈,后端使用Flask框架确保稳定运行,前端利用Canvas技术实现流畅的2D渲染,通过WebSocket技术实现实时多人同步。数据库持久化保存所有玩家的建造成果,确保你的心血不会被遗忘。
🌟 社区驱动
除了游戏本身,我们还建立了完整的社区系统------论坛交流、个人资料展示、好友互动等功能一应俱全。你可以分享自己的建造心得,欣赏其他玩家的杰作,甚至举办建造比赛。
无论你是想独自建造一个宁静的避世之所,还是与朋友共同打造一个繁华的虚拟城市,《我们的2D创造世界》都能满足你的创造欲望。无需下载,打开浏览器即可开始你的创造之旅。世界等待着你的第一块方块!
创造无限可能,连接你我世界------我们的2D创造世界,期待你的加入!
欢迎来到ourcraft.xin网站上游玩,每一个玩家都是创造者,每一个世界都是大家的
注:本项目为开源代码,以下内容是开源的,希望大家能够点赞关注收藏
python
import eventlet
eventlet.monkey_patch(all=True) # 全局打补丁(必须!否则异步/协程相关功能异常)
# 2. 导入标准库和第三方基础模块
import logging
import os
from datetime import datetime
import uuid
import json
import threading
from werkzeug.utils import secure_filename # 用于文件上传安全处理
# 3. 导入 Flask 核心及扩展模块(必须在 Eventlet 补丁后)
from flask import (
Flask,
render_template,
request,
session,
jsonify,
url_for,
send_from_directory,
abort,
redirect,
flash # 可选:如果需要Flash消息提示
)
from flask_socketio import SocketIO, emit, join_room, leave_room, disconnect # WebSocket核心库
from flask_sqlalchemy import SQLAlchemy # ORM数据库
from flask_migrate import Migrate # 数据库迁移工具
from flask_wtf.csrf import CSRFProtect # CSRF保护
from flask_cors import CORS # 跨域资源共享支持
from flask import current_app
from werkzeug.security import generate_password_hash, check_password_hash
online_players = {} # {user_id: {'username': str, 'x': int, 'y': int, 'sid': str}}
SECRET_KEY = os.environ.get('SECRET_KEY', 'your_fixed_dev_secret_key_here')
def create_app():
# ----------------------
# 配置
# ----------------------
# 项目根目录
base_dir = os.path.abspath(os.path.dirname(__file__))
app = Flask(
__name__,
template_folder=os.path.join(base_dir, 'templates'), # 绝对路径指向 templates 目录
static_folder=os.path.join(base_dir, 'static') # 绝对路径指向 static 目录
)
# 密钥(生产环境应从环境变量获取)
app.config['SECRET_KEY'] = 'your_strong_secret_key_here'
# 数据库配置(SQLite)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(base_dir, "app.db")}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False # 关闭不必要的警告
# CSRF 保护(启用)
app.config['WTF_CSRF_ENABLED'] = True
# 其他通用配置
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板自动重载(开发环境有用)
app.config['JSON_AS_ASCII'] = False # JSON 不转义非 ASCII 字符
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 上传文件最大 16MB
# ----------------------
# 扩展初始化
# ----------------------
csrf = CSRFProtect(app)
db = SQLAlchemy(app)
migrate = Migrate(app, db)
CORS(app,
supports_credentials=True, # 允许跨域携带凭证
resources={r"/*": {"origins": "http://localhost:5000"}}) # 替换为你的前端实际地址
# ----------------------
# 数据库模型
# ----------------------
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
register_time = db.Column(db.DateTime, default=datetime.utcnow)
last_login_time = db.Column(db.DateTime)
failed_login_count = db.Column(db.Integer, default=0)
locked = db.Column(db.Boolean, default=False)
_avatar_url = db.Column('avatar_url', db.String(200), default='default.png')
signature = db.Column(db.Text, default="这个人很懒,还没写个性签名~")
is_admin = db.Column(db.Boolean, default=False)
@property
def password(self):
raise AttributeError('密码不可直接读取')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
# ✅ 将 avatar_url 定义为 @property
@property
def avatar_url(self):
if not self._avatar_url or self._avatar_url == 'default.png':
return None
# ✅ 正确:返回完整的 URL,通过 url_for
return url_for('static', filename=f'avatars/{self._avatar_url}')
# ✅ 定义 safe_avatar 作为另一个 @property
@property
def safe_avatar(self):
"""安全获取头像URL,确保有默认值"""
if not self._avatar_url or self._avatar_url == 'default.png':
return url_for('static', filename='avatars/default.png')
return url_for('static', filename=f'avatars/{self._avatar_url}')
# ✅ 为 avatar_url 设置 setter
@avatar_url.setter
def avatar_url(self, value):
if not value:
self._avatar_url = 'default.png'
else:
filename = os.path.basename(value) # ✅ 提取纯文件名,如 '3ed8f47d-...png'
self._avatar_url = filename
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
content = db.Column(db.Text, nullable=False)
time = db.Column(db.DateTime, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
author = db.relationship('User', backref=db.backref('posts', lazy='dynamic'))
likes = db.Column(db.Integer, default=0)
views = db.Column(db.Integer, default=0)
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
time = db.Column(db.DateTime, default=datetime.utcnow)
author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
author = db.relationship('User', backref=db.backref('comments', lazy='dynamic'))
post = db.relationship('Post', backref=db.backref('comments', lazy='dynamic', cascade="all, delete-orphan"))
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
nickname = db.Column(db.String(50), nullable=False)
content = db.Column(db.Text, nullable=False)
time = db.Column(db.DateTime, default=datetime.utcnow)
class Favorite(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
time = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', backref='favorites')
post = db.relationship('Post', backref='favorited_by')
class Like(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
time = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', backref='likes')
post = db.relationship('Post', backref='post_likes')
# 在数据库模型后添加
class WorldBlock(db.Model):
id = db.Column(db.Integer, primary_key=True)
x = db.Column(db.Integer, nullable=False)
y = db.Column(db.Integer, nullable=False)
block_type = db.Column(db.String(20), default='grass') # 方块类型字符串
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('x', 'y', name='unique_block_position'),
)
# ---------------------- 新增:排行榜模型 ----------------------
class Leaderboard(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), nullable=False) # 玩家名
survive_time = db.Column(db.Integer, nullable=False) # 存活时间(秒)
reason = db.Column(db.String(200), nullable=False) # 死亡原因
created_at = db.Column(db.DateTime, default=datetime.utcnow) # 记录时间
def __repr__(self):
return f'<Leaderboard {self.username}>'
# ==================== 重置地图函数 ====================
def reset_world():
"""重置地图为侧视2D,上面是天,下面是地,最下面是基岩 - 修改后"""
with app.app_context():
try:
# 清空现有世界数据
WorldBlock.query.delete()
# 生成新的侧视2D世界 - 修改:只生成一个连续世界
world_width = 500 # 世界宽度
world_height = 100 # 世界高度
# 生成连续的地形,从y=60开始是地面
for x in range(world_width):
for y in range(world_height):
# y越大表示越下方,y=0是最顶部(天空)
if y < 60: # 最上面60层是空气,不存储
continue
elif y == 60: # 第60层是草方块(地面最上面一层)
block_type = 'grass'
elif y < 70: # 下面是泥土层
block_type = 'dirt'
elif y < 95: # 再下面是石头层
block_type = 'stone'
else: # 最下面5层是基岩
block_type = 'obsidian'
block = WorldBlock(x=x, y=y, block_type=block_type)
db.session.add(block)
db.session.commit()
print(f"地图已重置为侧视2D格式:{world_width}x{world_height},连续地形")
# 生成一些资源
import random
resources = [
(random.randint(20, 480), random.randint(70, 94), 'coal'),
(random.randint(20, 480), random.randint(70, 94), 'iron'),
(random.randint(20, 480), random.randint(70, 94), 'gold'),
(random.randint(20, 480), random.randint(70, 94), 'diamond'),
]
for x, y, resource_type in resources:
block = WorldBlock.query.filter_by(x=x, y=y).first()
if block and block.block_type == 'stone':
block.block_type = resource_type
# 生成一些实心树木
for _ in range(30): # 生成30棵树
tree_x = random.randint(20, 480)
# 确保树长在草地上
tree_y = 59 # 草地是y=60,树干从y=59开始(草方块上方)
# 检查这个位置是否适合种树(下面必须是草地)
ground_block = WorldBlock.query.filter_by(x=tree_x, y=60).first()
if not ground_block or ground_block.block_type != 'grass':
continue
# 树干(4-6格高)
trunk_height = random.randint(4, 6)
for i in range(trunk_height):
block_y = tree_y - i # 向上生成树干
# 检查是否已有方块
existing_block = WorldBlock.query.filter_by(x=tree_x, y=block_y).first()
if existing_block:
existing_block.block_type = 'wood'
else:
block = WorldBlock(x=tree_x, y=block_y, block_type='wood')
db.session.add(block)
# 实心树叶(不再空心)
leaf_radius = 3
for dx in range(-leaf_radius, leaf_radius + 1):
for dy in range(-leaf_radius, leaf_radius + 1):
# 计算树叶坐标
leaf_x = tree_x + dx
leaf_y = (tree_y - trunk_height) + dy
# 确保不超出世界范围
if leaf_x < 0 or leaf_x >= world_width or leaf_y < 0 or leaf_y >= world_height:
continue
# 检查是否已有方块
existing_block = WorldBlock.query.filter_by(x=leaf_x, y=leaf_y).first()
if existing_block:
existing_block.block_type = 'leaves'
else:
block = WorldBlock(x=leaf_x, y=leaf_y, block_type='leaves')
db.session.add(block)
# 添加一些水坑和熔岩池(确保在地面下方)
for _ in range(8):
water_x = random.randint(20, 480)
water_y = 61 # 地面下一层
# 创建3x3的水坑
for dx in range(-1, 2):
for dy in range(0, 3): # 向下延伸
wx = water_x + dx
wy = water_y + dy
if 0 <= wx < world_width and 0 <= wy < world_height:
block = WorldBlock.query.filter_by(x=wx, y=wy).first()
if block and block.block_type in ['dirt', 'grass']:
block.block_type = 'water'
for _ in range(4):
lava_x = random.randint(20, 480)
lava_y = random.randint(85, 94) # 深层
# 创建熔岩池
for dx in range(-2, 3):
for dy in range(-1, 2):
lx = lava_x + dx
ly = lava_y + dy
if 0 <= lx < world_width and 0 <= ly < world_height:
block = WorldBlock.query.filter_by(x=lx, y=ly).first()
if block and block.block_type == 'stone':
block.block_type = 'water' # 暂时用water表示熔岩
db.session.commit()
print("世界生成完成,包含实心树木")
except Exception as e:
db.session.rollback()
print(f"重置地图失败: {str(e)}")
raise
# 初始化世界(首次运行时生成基础地形)
def init_world():
with app.app_context():
if WorldBlock.query.first() is None:
reset_world()
print("玩家初始位置:世界中央草坪上方10格 (x=250, y=50)")
# ---------------------- 上下文处理器 ----------------------
def get_current_user():
user = None
if 'user_id' in session:
user = User.query.get(session['user_id'])
return user
@app.context_processor
def inject_current_user():
return {'current_user': get_current_user()}
@app.context_processor
def inject_models():
return {'Comment': Comment}
# ---------------------- 模板过滤器 ----------------------
@app.template_filter('safe_avatar')
def safe_avatar(path):
return url_for('static', filename=f'avatars/{path or "default.png"}', _external=True)
@app.template_filter('datetimeformat')
def datetimeformat(value, format='%Y-%m-d %H:%M'):
return value.strftime(format) if value else ''
# ---------------------- 响应头设置 ----------------------
@app.after_request
def add_headers(response):
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, DELETE, OPTIONS'
return response
# ---------------------- 页面路由 ----------------------
@app.route('/')
def index():
user = get_current_user()
return render_template('index.html', current_user=user)
@app.route('/admin')
def admin_page():
return render_template('admin.html')
@app.route('/minecraft.html')
def minecraft():
return render_template('minecraft.html')
@app.route('/game.html')
def game():
return render_template('game.html')
@app.route('/home.html')
def home():
user = get_current_user()
return render_template('home.html', current_user=user)
@app.route('/forum.html')
def forum():
user = get_current_user()
# 获取当前页码,默认第1页,类型为整数
page = request.args.get('page', 1, type=int)
# 每页显示 5 个帖子
per_page = 5
# 按时间倒序排列,并进行分页查询
posts_pagination = Post.query.order_by(Post.time.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template(
'forum.html',
current_user=user,
posts=posts_pagination.items, # ✅ 当前页的帖子列表(只5个)
pagination=posts_pagination # ✅ 传递整个分页对象,用于前端生成分页导航
)
@app.route('/messages.html')
def messages():
user = get_current_user()
# 每页显示 5 条留言,获取页码参数,默认第1页
page = request.args.get('page', 1, type=int)
per_page = 5
# 按时间倒序,分页查询留言
messages_pagination = Message.query.order_by(Message.time.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template(
'messages.html',
current_user=user,
messages=messages_pagination.items, # 当前页的留言列表(最多5条)
pagination=messages_pagination # 分页对象,用于生成分页导航
)
@app.route('/news.html')
def news():
user = get_current_user()
return render_template('news.html', current_user=user)
@app.route('/articles.html')
def articles():
user = get_current_user()
return render_template('articles.html', current_user=user)
@app.route('/profile.html')
def profile():
user = get_current_user()
if not user:
return redirect(url_for('login'))
# 修改计数方式
post_count = user.posts.count() if user.posts else 0
comment_count = user.comments.count() if user.comments else 0
favorite_count = Favorite.query.filter_by(user_id=user.id).count() # 修改这一行
like_count = Like.query.filter_by(user_id=user.id).count() if user else 0
# 查询该用户发布过的所有帖子(按时间倒序)
user_posts = Post.query.filter_by(author_id=user.id).order_by(Post.time.desc()).all()
# 查询该用户收藏的所有帖子(通过 Favorite 表关联 Post)
favorite_posts = []
if user.favorites:
favorite_post_ids = [f.post_id for f in user.favorites]
favorite_posts = Post.query.filter(Post.id.in_(favorite_post_ids)).order_by(Post.time.desc()).all()
return render_template('profile.html',
current_user=user,
post_count=post_count,
comment_count=comment_count,
favorite_count=favorite_count, # 使用修改后的计数
like_count=like_count,
user_posts=user_posts,
favorite_posts=favorite_posts
)
@app.route('/user_profile.html')
def user_profile():
# 从 URL 参数中获取 user_id
user_id = request.args.get('user_id')
if not user_id:
# 如果没有传 user_id,可以默认显示当前用户,或者跳转到首页/报错
user = get_current_user()
if not user:
return redirect(url_for('login'))
return render_template('user_profile.html', user=user)
# 根据 user_id 查询数据库中的用户
try:
user_id = int(user_id) # 确保是整数
except (TypeError, ValueError):
return render_template('404.html', error="无效的用户ID"), 404
user = User.query.get(user_id)
if not user:
return render_template('404.html', error="用户不存在"), 404
# ✅ 新增:查询该用户发布的所有帖子,并按时间倒序排列
user_posts = Post.query.filter_by(author_id=user.id).order_by(Post.time.desc()).all()
return render_template('user_profile.html', user=user, user_posts=user_posts)
@app.route('/command.html')
def command():
user = get_current_user()
return render_template('command.html', current_user=user)
@app.route('/2d_minecraft.html')
def two_d_minecraft():
"""2D创造游戏页面"""
user = get_current_user()
if not user:
return redirect(url_for('login') + '?next=/2d_minecraft.html')
# 生成CSRF token
from flask_wtf.csrf import generate_csrf
csrf_token = generate_csrf()
return render_template('2d_minecraft.html',
current_user=user,
csrf_token=csrf_token)
# ==================== 新增:重置地图路由 ====================
@app.route('/api/reset_world', methods=['POST'])
def api_reset_world():
"""API路由:重置游戏世界"""
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
user = User.query.get(session['user_id'])
if not user:
return jsonify({'error': '用户不存在'}), 404
# 检查权限:只有管理员可以重置地图
if not user.is_admin:
return jsonify({'error': '需要管理员权限'}), 403
reset_world()
return jsonify({
'success': True,
'message': '地图已重置为侧视2D格式'
}), 200
except Exception as e:
return jsonify({'error': f'重置地图失败: {str(e)}'}), 500
# ---------------------- API 路由 ----------------------
#管理员模式
@app.route('/api/admin/user/<int:user_id>', methods=['GET'])
def get_user_info_admin(user_id):
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
user = User.query.get(user_id)
if not user:
return jsonify({'error': '用户不存在'}), 404
return jsonify({
'id': user.id,
'username': user.username,
'email': user.email,
'is_admin': user.is_admin,
'locked': user.locked,
'register_time': user.register_time.strftime('%Y-%m-%d %H:%M:%S') if user.register_time else None,
'post_count': user.posts.count(),
'comment_count': user.comments.count()
})
@app.route('/api/admin/users', methods=['GET'])
def get_all_users():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
try:
# 获取分页参数,默认第1页,每页10条
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 分页查询用户
users_pagination = User.query.order_by(User.id).paginate(
page=page, per_page=per_page, error_out=False
)
users_data = []
for u in users_pagination.items:
users_data.append({
'id': u.id,
'username': u.username,
'email': u.email,
'is_admin': u.is_admin,
'locked': u.locked,
'register_time': u.register_time.strftime('%Y-%m-%d %H:%M:%S') if u.register_time else None,
'post_count': u.posts.count(),
'comment_count': u.comments.count()
})
return jsonify({
'users': users_data,
'pagination': {
'current_page': users_pagination.page,
'total_pages': users_pagination.pages,
'total_items': users_pagination.total,
'per_page': per_page,
'has_next': users_pagination.has_next,
'has_prev': users_pagination.has_prev
}
})
except Exception as e:
app.logger.error(f"获取用户列表失败: {e}")
return jsonify({'error': '获取用户列表失败'}), 500
@app.route('/api/admin/delete_user', methods=['DELETE'])
def delete_user():
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
user_id = request.args.get('user_id')
if not user_id:
return jsonify({'error': '用户ID不能为空'}), 400
target_user = User.query.get(user_id)
if not target_user:
return jsonify({'error': '用户不存在'}), 404
# 禁止删除自己
if target_user.id == current_user.id:
return jsonify({'error': '不能删除自己的账户'}, 400)
# 禁止删除管理员账户
if target_user.is_admin:
return jsonify({'error': '不能删除管理员账户'}, 400)
# 删除用户相关数据
Post.query.filter_by(author_id=target_user.id).delete()
Comment.query.filter_by(author_id=target_user.id).delete()
db.session.delete(target_user)
db.session.commit()
return jsonify({
'success': True,
'message': f"用户 {target_user.username} 已删除"
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"删除用户失败: {str(e)}")
return jsonify({'error': '操作失败,请重试'}), 500
@app.route('/api/admin/posts/<int:post_id>', methods=['DELETE'])
def admin_delete_post(post_id):
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
post = Post.query.get(post_id)
if not post:
return jsonify({'error': '帖子不存在'}), 404
Like.query.filter_by(post_id=post_id).delete()
Comment.query.filter_by(post_id=post_id).delete()
Favorite.query.filter_by(post_id=post_id).delete()
db.session.delete(post)
db.session.commit()
return jsonify({'success': True, 'message': f'帖子 {post_id} 已删除'})
@app.route('/api/admin/comments/<int:comment_id>', methods=['DELETE'])
def admin_delete_comment(comment_id):
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
comment = Comment.query.get(comment_id)
if not comment:
return jsonify({'error': '评论不存在'}), 404
db.session.delete(comment)
db.session.commit()
return jsonify({'success': True, 'message': f'评论 {comment_id} 已删除'})
#普通用户
@app.route('/api/favorite/<int:post_id>', methods=['POST', 'DELETE'])
def toggle_favorite(post_id):
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
post = Post.query.get(post_id)
if not post:
return jsonify({'error': '帖子不存在'}), 404
user = User.query.get(session['user_id'])
favorite = Favorite.query.filter_by(user_id=user.id, post_id=post_id).first()
is_favorite = False
if request.method == 'POST' and not favorite:
new_favorite = Favorite(user_id=user.id, post_id=post_id)
db.session.add(new_favorite)
is_favorite = True
elif request.method == 'DELETE' and favorite:
db.session.delete(favorite)
is_favorite = False
db.session.commit()
favorite_count = Favorite.query.filter_by(post_id=post_id).count()
return jsonify({
'success': True,
'is_favorite': is_favorite,
'count': favorite_count
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"收藏操作失败: {str(e)}")
return jsonify({'error': '操作失败,请重试'}), 500
@app.route('/api/like/<int:post_id>', methods=['POST', 'DELETE'])
def toggle_like(post_id):
# 必须登录才能点赞
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
user = User.query.get(session['user_id'])
if not user:
return jsonify({'error': '用户不存在'}), 404
post = Post.query.get(post_id)
if not post:
return jsonify({'error': '帖子不存在'}), 404
# 查找是否已经点过赞
like = Like.query.filter_by(user_id=user.id, post_id=post_id).first()
is_liked = False
if request.method == 'POST' and not like:
# 点赞:新增记录
new_like = Like(user_id=user.id, post_id=post_id)
db.session.add(new_like)
is_liked = True
elif request.method == 'DELETE' and like:
# 取消点赞:删除记录
db.session.delete(like)
is_liked = False
db.session.commit()
# 获取该帖子的总点赞数(可选,前端可以不用)
like_count = Like.query.filter_by(post_id=post_id).count()
return jsonify({
'success': True,
'is_liked': is_liked,
'like_count': like_count
})
@app.route('/api/update_password', methods=['POST'])
def update_password():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
user = User.query.get(session['user_id'])
if not user:
return jsonify({'error': '用户不存在'}), 404
data = request.get_json()
if not data or 'old_password' not in data or 'new_password' not in data:
return jsonify({'error': '旧密码和新密码不能为空'}), 400
old_password = data['old_password']
new_password = data['new_password']
if len(new_password) < 6:
return jsonify({'error': '新密码长度不能少于6位'}), 400
if not user.verify_password(old_password):
return jsonify({'error': '当前密码错误'}), 400
# 设置新密码
user.password = new_password
db.session.commit()
return jsonify({
'success': True,
'message': '密码修改成功,请重新登录'
})
@app.route('/api/update_profile', methods=['POST'])
def update_profile():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
user = User.query.get(session['user_id'])
if not user:
return jsonify({'error': '用户不存在'}), 404
data = request.get_json()
if not data or 'signature' not in data:
return jsonify({'error': '签名内容不能为空'}), 400
new_signature = data['signature'].strip()
if not new_signature:
return jsonify({'error': '签名内容不能为空'}), 400
user.signature = new_signature
db.session.commit()
return jsonify({
'success': True,
'user': {
'id': user.id,
'username': user.username,
'signature': user.signature
}
})
# 允许的文件类型
ALLOWED_AVATAR_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_AVATAR_EXTENSIONS
@app.route('/api/upload_avatar', methods=['POST'])
def upload_avatar():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
user = User.query.get(session['user_id'])
if not user:
return jsonify({'error': '用户不存在'}), 404
if 'avatar' not in request.files:
return jsonify({'error': '未选择文件'}), 400
file = request.files['avatar']
if file.filename == '':
return jsonify({'error': '未选择文件'}), 400
if not allowed_file(file.filename):
return jsonify({'error': '不支持的文件格式,请上传 PNG、JPG、JPEG 或 GIF'}), 400
filename = secure_filename(file.filename)
# 可选:防止文件名冲突,可以使用 uuid
unique_filename = f"{uuid.uuid4().hex}_{filename}"
static_dir = os.path.join(base_dir, 'static')
filepath = os.path.join(static_dir, 'avatars', unique_filename)
try:
# 确保目录存在
os.makedirs(os.path.dirname(filepath), exist_ok=True)
file.save(filepath)
except Exception as e:
app.logger.error(f"保存头像失败: {e}")
return jsonify({'error': '头像保存失败'}), 500
# 更新用户的头像URL
user.avatar_url = unique_filename # 直接存文件名
db.session.commit()
avatar_url = url_for('static', filename=f'avatars/{unique_filename}')
return jsonify({
'success': True,
'avatar_url': avatar_url
})
@app.route('/api/admin/set_user_role', methods=['POST'])
def set_user_role():
try:
# 权限检查:必须是管理员
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
data = request.get_json() or request.form.to_dict()
target_user_id = data.get('user_id')
is_admin = data.get('is_admin', False)
if not target_user_id:
return jsonify({'error': '用户ID不能为空'}), 400
target_user = User.query.get(target_user_id)
if not target_user:
return jsonify({'error': '用户不存在'}), 404
# 禁止管理员取消自己的权限
if target_user.id == current_user.id and not is_admin:
return jsonify({'error': '不能取消自己的管理员权限'}), 400
target_user.is_admin = bool(is_admin)
db.session.commit()
return jsonify({
'success': True,
'message': f"用户 {target_user.username} 已{'设置为' if is_admin else '取消'}管理员",
'user': {
'id': target_user.id,
'username': target_user.username,
'is_admin': target_user.is_admin
}
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"设置用户权限失败: {str(e)}")
return jsonify({'error': '操作失败,请重试'}), 500
@app.route('/api/admin/toggle_user_lock', methods=['POST'])
def toggle_user_lock():
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
data = request.get_json() or request.form.to_dict()
target_user_id = data.get('user_id')
if not target_user_id:
return jsonify({'error': '用户ID不能为空'}), 400
target_user = User.query.get(target_user_id)
if not target_user:
return jsonify({'error': '用户不存在'}), 404
# 禁止锁定自己
if target_user.id == current_user.id:
return jsonify({'error': '不能锁定自己的账户'}, 400)
# 禁止锁定任何管理员账户
if target_user.is_admin:
return jsonify({'error': '不能锁定管理员账户'}, 400)
target_user.locked = not target_user.locked
db.session.commit()
return jsonify({
'success': True,
'message': f"用户 {target_user.username} 已{'锁定' if target_user.locked else '解锁'}",
'user': {
'id': target_user.id,
'username': target_user.username,
'locked': target_user.locked
}
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"锁定用户失败: {str(e)}")
return jsonify({'error': '操作失败,请重试'}), 500
# ========== 删除留言 ==========
@app.route('/api/admin/messages/<int:message_id>', methods=['DELETE'])
def admin_delete_message(message_id):
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
message = Message.query.get(message_id)
if not message:
return jsonify({'error': '留言不存在'}), 404
db.session.delete(message)
db.session.commit()
return jsonify({'success': True, 'message': f'留言 {message_id} 已删除'})
# ========== 获取留言列表(带分页) ==========
@app.route('/api/admin/messages', methods=['GET'])
def get_all_messages():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
try:
# 获取分页参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 按时间倒序,分页查询所有留言
messages_pagination = Message.query.order_by(Message.time.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
messages_data = []
for m in messages_pagination.items:
messages_data.append({
'id': m.id,
'nickname': m.nickname,
'content': m.content[:100] + '...' if len(m.content) > 100 else m.content, # 内容预览
'time': m.time.strftime('%Y-%m-%d %H:%M:%S') if m.time else None
})
return jsonify({
'messages': messages_data,
'pagination': {
'current_page': messages_pagination.page,
'total_pages': messages_pagination.pages,
'total_items': messages_pagination.total,
'per_page': per_page,
'has_next': messages_pagination.has_next,
'has_prev': messages_pagination.has_prev
}
})
except Exception as e:
app.logger.error(f"获取留言列表失败: {e}")
return jsonify({'error': '获取留言列表失败'}), 500
@app.route('/api/admin/comments', methods=['GET'])
def get_all_comments():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
try:
# ✅ 新增分页参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# ✅ 使用 paginate 进行分页查询
comments_pagination = Comment.query.order_by(Comment.time.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
comments_data = []
for c in comments_pagination.items:
author = c.author
post = c.post
comments_data.append({
'id': c.id,
'content': c.content[:100] + '...' if len(c.content) > 100 else c.content,
'author_username': author.username if author else '未知',
'post_title': post.title if post else '未知帖子',
'time': c.time.strftime('%Y-%m-%d %H:%M:%S') if c.time else None,
'author_id': c.author_id,
'post_id': c.post_id
})
return jsonify({
'comments': comments_data,
'pagination': {
'current_page': comments_pagination.page,
'total_pages': comments_pagination.pages,
'total_items': comments_pagination.total,
'per_page': per_page,
'has_next': comments_pagination.has_next,
'has_prev': comments_pagination.has_prev
}
})
except Exception as e:
app.logger.error(f"获取评论列表失败: {e}")
return jsonify({'error': '获取评论列表失败'}), 500
@app.route('/api/admin/posts', methods=['GET'])
def get_all_posts():
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '没有管理员权限'}), 403
try:
# ✅ 新增分页参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# ✅ 使用 paginate 进行分页查询
posts_pagination = Post.query.order_by(Post.time.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
posts_data = []
for p in posts_pagination.items:
author = p.author
posts_data.append({
'id': p.id,
'title': p.title,
'author_username': author.username if author else '未知',
'time': p.time.strftime('%Y-%m-%d %H:%M:%S') if p.time else None,
'views': p.views,
'likes': p.likes,
'comment_count': len(p.comments.all()), # 评论数
'author_id': p.author_id
})
return jsonify({
'posts': posts_data,
'pagination': {
'current_page': posts_pagination.page,
'total_pages': posts_pagination.pages,
'total_items': posts_pagination.total,
'per_page': per_page,
'has_next': posts_pagination.has_next,
'has_prev': posts_pagination.has_prev
}
})
except Exception as e:
app.logger.error(f"获取帖子列表失败: {e}")
return jsonify({'error': '获取帖子列表失败'}), 500
@app.route('/post/<int:post_id>')
def post_detail(post_id):
current_user = get_current_user()
post = Post.query.get_or_404(post_id)
post.views = (post.views or 0) + 1
db.session.commit()
comments = Comment.query.filter_by(post_id=post_id).order_by(Comment.time.desc()).all()
author = post.author
author_posts_count = author.posts.count() if author else 0
author_comments_count = author.comments.count() if author else 0
is_favorite = False
if current_user:
is_favorite = Favorite.query.filter_by(
user_id=current_user.id, post_id=post_id
).first() is not None
is_liked = False
if current_user:
is_liked = Like.query.filter_by(
user_id=current_user.id, post_id=post_id
).first() is not None
# ✅ 新增:计算该帖子总共被点赞多少次
like_count = Like.query.filter_by(post_id=post_id).count()
return render_template(
'post.html',
post=post,
comments=comments,
current_user=current_user,
is_favorite=is_favorite,
is_liked=is_liked,
author_posts_count=author_posts_count,
author_comments_count=author_comments_count,
like_count=like_count # ✅ 传入模板
)
# 其他原有API(注册、登录、发帖等)与原始代码完全一致...
@app.route('/register', methods=['POST'])
@csrf.exempt
def register():
try:
data = request.get_json() or request.form.to_dict()
if not data:
return jsonify({'error': '请提交数据'}), 400
username = data.get('username')
email = data.get('email')
password = data.get('password')
if not all([username, email, password]):
return jsonify({'error': '用户名、邮箱、密码不能为空'}), 400
if User.query.filter_by(username=username).first():
return jsonify({'error': '用户名已存在'}), 400
new_user = User(username=username, email=email)
new_user.password = password
db.session.add(new_user)
db.session.commit()
session['user_id'] = new_user.id
return jsonify({
'success': True,
'message': '注册成功',
'redirect': '/index.html',
'user': {
'id': new_user.id,
'username': new_user.username,
'avatar_url': new_user.avatar_url,
'signature': new_user.signature
}
}), 201
except Exception as e:
db.session.rollback()
app.logger.error(f"注册失败: {str(e)}")
return jsonify({'error': f'注册失败:{str(e)}'}), 500
# 原登录路由处理函数(部分代码)
@app.route('/login', methods=['POST'])
@csrf.exempt
def login():
try:
data = request.get_json() or request.form.to_dict()
if not data:
return jsonify({'error': '请提交数据'}), 400
username = data.get('username')
password = data.get('password')
user = User.query.filter_by(username=username).first()
# 保留管理员手动锁定的检查(关键)
if user and user.locked:
return jsonify({'error': '账户已被管理员锁定'}), 403
# 统一错误提示(安全优化:避免暴露用户是否存在)
if not user or not user.verify_password(password):
return jsonify({'error': '用户不存在或密码错误'}), 401
# 登录成功处理(新增:更新最后登录时间)
user.last_login_time = datetime.utcnow() # ✅ 关键修改
db.session.commit() # ✅ 提交数据库更改
session['user_id'] = user.id
next_page = request.args.get('next', 'index.html')
return jsonify({
'success': True,
'message': '登录成功',
'redirect': next_page,
'user': {
'id': user.id,
'username': user.username,
'avatar_url': user.avatar_url,
'signature': user.signature,
'last_login_time': user.last_login_time.strftime('%Y-%m-%d %H:%M:%S') # 可选:返回给前端
}
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"登录失败: {str(e)}")
return jsonify({'error': f'登录失败:{str(e)}'}), 500
@app.route('/logout', methods=['GET', 'POST']) # 允许 GET 和 POST
def logout():
try:
session.pop('user_id', None)
return jsonify({
'success': True,
'message': '已成功登出'
}), 200
except Exception as e:
app.logger.error(f"登出失败: {str(e)}")
return jsonify({'error': '登出失败'}), 500
@app.route('/forum/posts', methods=['POST'])
def create_post():
try:
app.logger.debug(f"收到发帖请求: {request.method} {request.path}")
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
data = request.get_json() or request.form.to_dict()
if not data.get('title') or not data.get('content'):
return jsonify({'error': '标题和内容不能为空'}), 400
user = User.query.get(session['user_id'])
new_post = Post(
title=data['title'],
content=data['content'],
author_id=user.id
)
db.session.add(new_post)
db.session.commit()
return jsonify({
'success': True,
'message': '帖子发布成功',
'post_id': new_post.id
}), 201
except Exception as e:
db.session.rollback()
app.logger.error(f"发布帖子失败: {str(e)}", exc_info=True)
return jsonify({'error': '发布失败,请重试'}), 500
@app.route('/forum/posts/<int:post_id>', methods=['DELETE'])
def delete_post(post_id):
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user:
return jsonify({'error': '用户不存在'}), 404
post = Post.query.get(post_id)
if not post:
return jsonify({'error': '帖子不存在'}), 404
if post.author_id != current_user.id:
return jsonify({'error': '您没有权限删除此帖子'}), 403
# ✅ 先删除所有关联的点赞记录(避免 NOT NULL 错误)
Like.query.filter_by(post_id=post_id).delete()
# ✅ 再删除关联的评论和收藏
Comment.query.filter_by(post_id=post_id).delete()
Favorite.query.filter_by(post_id=post_id).delete()
# ✅ 最后删除帖子本身
db.session.delete(post)
db.session.commit()
return jsonify({'success': True, 'message': '帖子已删除'})
except Exception as e:
db.session.rollback()
app.logger.error(f"删除帖子失败: {str(e)}")
return jsonify({'error': '删除失败,请重试'}), 500
@app.route('/forum/posts/<int:post_id>/comments', methods=['POST'])
def create_comment(post_id):
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
data = request.get_json() or request.form.to_dict()
content = data.get('content', '').strip()
if not content:
return jsonify({'error': '评论内容不能为空'}), 400
post = Post.query.get(post_id)
if not post:
return jsonify({'error': '帖子不存在'}), 404
user = User.query.get(session['user_id'])
new_comment = Comment(
content=content,
author_id=user.id,
post_id=post_id
)
db.session.add(new_comment)
db.session.commit()
return jsonify({
'success': True,
'message': '评论发布成功',
'comment': {
'id': new_comment.id,
'content': new_comment.content,
'user_id': user.id,
'user_name': user.username,
'user_avatar': user.avatar_url,
'create_time': new_comment.time.strftime('%Y-%m-%d %H:%M:%S')
}
}), 201
except Exception as e:
db.session.rollback()
app.logger.error(f"发布评论失败: {str(e)}")
return jsonify({'error': '评论失败,请重试'}), 500
@app.route('/forum/comments/<int:comment_id>', methods=['DELETE'])
def delete_comment(comment_id):
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
comment = Comment.query.get(comment_id)
if not comment:
return jsonify({'error': '评论不存在'}), 404
current_user = User.query.get(session['user_id'])
# 管理员可删除任意评论
if comment.author_id != current_user.id and not current_user.is_admin:
return jsonify({'error': '没有权限删除此评论'}), 403
db.session.delete(comment)
db.session.commit()
return jsonify({
'success': True,
'message': '评论已删除'
}), 200
except Exception as e:
db.session.rollback()
app.logger.error(f"删除评论失败: {str(e)}")
return jsonify({'error': '删除评论失败,请重试'}), 500
# ==================== 修改:获取世界方块数据API ====================
@app.route('/api/world_blocks', methods=['GET'])
def get_world_blocks():
"""获取世界方块数据"""
try:
print("正在获取世界方块数据...")
# 从数据库查询所有方块数据
blocks = WorldBlock.query.all()
print(f"查询到 {len(blocks)} 个方块")
# 将数据转换为前端需要的格式
world_data = {}
block_type_map = {
'air': 0, 'grass': 1, 'dirt': 2, 'stone': 3,
'wood': 4, 'leaves': 5, 'water': 6, 'sand': 7,
'glass': 8, 'brick': 9, 'snow': 10, 'obsidian': 11,
'diamond': 12, 'gold': 13, 'iron': 14, 'coal': 15
}
for block in blocks:
# 使用chunk系统组织数据
chunk_x = block.x // 16
chunk_y = block.y // 16
block_x = block.x % 16
block_y = block.y % 16
chunk_key = f"{chunk_x},{chunk_y}"
block_key = f"{block_x},{block_y}"
if chunk_key not in world_data:
world_data[chunk_key] = {}
# 获取方块数字ID
block_id = block_type_map.get(block.block_type, 0)
world_data[chunk_key][block_key] = block_id
print(f"转换完成,共 {len(world_data)} 个区块")
return jsonify({
'success': True,
'world_data': world_data
}), 200
except Exception as e:
import traceback
app.logger.error(f"获取世界数据失败: {str(e)}\n{traceback.format_exc()}")
return jsonify({'error': f'获取世界数据失败: {str(e)}'}), 500
@app.route('/api/update_block', methods=['POST'])
def update_block():
"""更新方块数据"""
try:
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
data = request.get_json()
if not data:
return jsonify({'error': '数据不能为空'}), 400
x = data.get('x')
y = data.get('y')
block_type = data.get('blockType') # 这是数字ID
if None in [x, y, block_type]:
return jsonify({'error': '缺少必要参数'}), 400
# 将数字ID转换为字符串类型
block_type_map = {
0: 'air', 1: 'grass', 2: 'dirt', 3: 'stone',
4: 'wood', 5: 'leaves', 6: 'water', 7: 'sand',
8: 'glass', 9: 'brick', 10: 'snow', 11: 'obsidian',
12: 'diamond', 13: 'gold', 14: 'iron', 15: 'coal'
}
block_type_str = block_type_map.get(block_type, 'air')
# 查找现有的方块记录
block = WorldBlock.query.filter_by(x=x, y=y).first()
if block_type_str == 'air': # 如果是空气,则删除方块记录
if block:
db.session.delete(block)
else: # 更新或创建方块记录
if block:
block.block_type = block_type_str
else:
block = WorldBlock(x=x, y=y, block_type=block_type_str)
db.session.add(block)
db.session.commit()
return jsonify({
'success': True,
'message': '方块更新成功'
}), 200
except Exception as e:
db.session.rollback()
import traceback
app.logger.error(f"更新方块失败: {str(e)}\n{traceback.format_exc()}")
return jsonify({'error': '更新方块失败'}), 500
# ---------------------- 新增:排行榜API ----------------------
@app.route('/api/leaderboard', methods=['GET'])
def get_leaderboard():
"""获取游戏排行榜数据"""
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
current_user = User.query.get(session['user_id'])
if not current_user or not current_user.is_admin:
return jsonify({'error': '无权限访问排行榜'}), 403
# 从数据库查询排行榜数据(示例:按存活时间排序)
leaderboard = Leaderboard.query.order_by(Leaderboard.survive_time.desc()).limit(10).all()
leaderboard_data = [{
'rank': idx+1,
'username': item.username,
'survive_time': item.survive_time,
'reason': item.reason
} for idx, item in enumerate(leaderboard)]
return jsonify(leaderboard_data)
@app.route('/api/save-leaderboard', methods=['POST'])
def save_leaderboard():
"""保存游戏结果到排行榜"""
if 'user_id' not in session:
return jsonify({'error': '请先登录'}), 401
data = request.get_json()
if not data:
return jsonify({'error': '数据不能为空'}), 400
# 验证数据完整性
required_fields = ['name', 'time', 'reason']
if not all(field in data for field in required_fields):
return jsonify({'error': '缺少必要字段'}), 400
# 创建排行榜记录
new_record = Leaderboard(
username=data['name'],
survive_time=data['time'],
reason=data['reason']
)
db.session.add(new_record)
db.session.commit()
return jsonify({'success': True, 'message': '成绩已保存到排行榜'}), 201
@app.route('/api/userinfo')
def get_user_info():
try:
if 'user_id' not in session:
return jsonify({'logged_in': False}), 401
user = User.query.get(session['user_id'])
if not user:
session.pop('user_id', None)
return jsonify({'logged_in': False}), 401
return jsonify({
'logged_in': True,
'username': user.username,
'avatar_url': user.avatar_url,
'register_time': user.register_time.strftime('%Y-%m-%d %H:%M:%S'),
'post_count': user.posts.count(),
'comment_count': user.comments.count(),
'signature': user.signature,
'is_admin': user.is_admin
})
except Exception as e:
app.logger.error(f"获取用户信息失败: {str(e)}")
return jsonify({'error': '获取用户信息失败', 'logged_in': False}), 500
@app.route('/process_message', methods=['POST'])
def process_message():
try:
# 1. 获取表单数据
data = request.form.to_dict()
nickname = data.get('nickname').strip()
message_content = data.get('message').strip()
captcha = data.get('captcha').strip()
real_captcha = data.get('captcha_real')
# 2. 校验验证码
if captcha.upper() != real_captcha.upper():
return jsonify({
'success': False,
'error': '验证码错误'
}), 400
# 3. 校验昵称和留言内容
if not nickname or len(nickname) > 20:
return jsonify({
'success': False,
'error': '昵称必填且不超过20字符'
}), 400
if not message_content or len(message_content) > 500:
return jsonify({
'success': False,
'error': '留言内容必填且不超过500字符'
}), 400
# 4. 创建并保存留言
new_message = Message(
nickname=nickname,
content=message_content,
time=datetime.utcnow()
)
db.session.add(new_message)
db.session.commit()
# 5. 返回成功响应
return jsonify({
'success': True,
'message': '留言提交成功'
}), 201
except Exception as e:
db.session.rollback()
app.logger.error(f"提交留言失败: {str(e)}")
return jsonify({
'success': False,
'error': '服务器内部错误'
}), 500
# ---------------------- SocketIO 事件处理 ----------------------
@app.route('/socket.io/')
def socketio_test():
return "Socket.IO endpoint"
# ---------------------- 错误处理器 ----------------------
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html', error=str(e)), 404
@app.errorhandler(500)
def internal_server_error(e):
app.logger.error(f"服务器内部错误: {str(e)}")
return render_template('500.html', error="服务器内部错误,请稍后重试"), 500
return app, db, migrate
# ---------------------- 主程序入口 ----------------------
# 创建一个全局变量来存储app实例
app, db, migrate = create_app()
# 初始化SocketIO
socketio = SocketIO(
app,
async_mode='eventlet',
cors_allowed_origins="*",
ping_timeout=60,
ping_interval=25,
logger=True,
engineio_logger=True
)
# ==================== 游戏状态存储 ====================
online_players = {} # {username: {data}}
world_lock = threading.Lock() # 线程锁
# SocketIO事件处理
@socketio.on('connect')
def handle_connect():
"""处理客户端连接"""
print(f"Client connected: {request.sid}")
emit('connected', {'status': 'connected', 'sid': request.sid})
@socketio.on('disconnect')
def handle_disconnect():
"""处理客户端断开连接"""
print(f"Client disconnected: {request.sid}")
# 从在线玩家中移除
username_to_remove = None
for username, player_data in online_players.items():
if player_data.get('sid') == request.sid:
username_to_remove = username
break
if username_to_remove:
del online_players[username_to_remove]
# 广播玩家离开
socketio.emit('player_left', {
'username': username_to_remove
})
# 更新其他玩家的在线列表
socketio.emit('players_list', {
'players': list(online_players.values())
})
@socketio.on('player_join')
def handle_player_join(data):
"""处理玩家加入游戏"""
try:
username = data.get('username')
if not username:
return
print(f"玩家加入: {username}")
# 检查玩家是否已经在线
if username in online_players:
# 更新连接信息
online_players[username]['sid'] = request.sid
else:
# 添加新玩家
online_players[username] = {
'username': username,
'x': data.get('x', 100),
'y': data.get('y', 200),
'color': data.get('color', '#3d85c6'),
'selectedBlock': data.get('selectedBlock', 1),
'sid': request.sid
}
# 广播玩家加入
socketio.emit('player_joined', {
'username': username,
'x': data.get('x', 100),
'y': data.get('y', 200),
'color': data.get('color', '#3d85c6')
})
# 发送当前在线玩家列表
socketio.emit('players_list', {
'players': list(online_players.values())
}, room=request.sid)
print(f"当前在线玩家: {list(online_players.keys())}")
except Exception as e:
print(f"Error in player_join: {str(e)}")
import traceback
traceback.print_exc()
@socketio.on('player_update')
def handle_player_update(data):
"""处理玩家位置更新"""
try:
username = None
# 查找这个sid对应的用户名
for uname, player_data in online_players.items():
if player_data.get('sid') == request.sid:
username = uname
break
if not username:
return
# 更新玩家位置
online_players[username]['x'] = data.get('x', 0)
online_players[username]['y'] = data.get('y', 0)
# 广播给其他玩家
socketio.emit('player_updated', {
'username': username,
'x': data.get('x', 0),
'y': data.get('y', 0)
}, skip_sid=request.sid)
except Exception as e:
print(f"Error in player_update: {str(e)}")
@socketio.on('block_update')
def handle_block_update(data):
"""处理方块更新 - 现在只用于实时同步"""
try:
x = data.get('x')
y = data.get('y')
block_type = data.get('blockType')
if None in [x, y, block_type]:
return
# 广播方块更新给所有玩家
socketio.emit('block_updated', {
'x': x,
'y': y,
'blockType': block_type
})
print(f"方块更新广播: ({x}, {y}) -> {block_type}")
except Exception as e:
print(f"Error in block_update: {str(e)}")
@socketio.on('chat_message')
def handle_chat_message(data):
"""处理聊天消息"""
try:
message = data.get('message', '').strip()
sender = data.get('sender', '')
if not message or not sender:
return
# 广播聊天消息
socketio.emit('chat_message', {
'sender': sender,
'message': message
})
print(f"聊天消息: {sender}: {message}")
except Exception as e:
print(f"Error in chat_message: {str(e)}")
if __name__ == '__main__':
# 首先确保数据库和表已创建
with app.app_context():
# 确保数据库目录存在
db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')
db_dir = os.path.dirname(db_path)
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
# 创建所有表
db.create_all()
# 创建默认管理员账户(如果不存在)
# ✅ 注意:现在在app上下文中,可以直接访问User类
if not db.session.query(User).filter_by(is_admin=True).first():
admin = User(
username='admin',
email='admin@example.com',
password_hash=generate_password_hash('ourcraftadminyyds'),
is_admin=True,
register_time=datetime.utcnow()
)
db.session.add(admin)
db.session.commit()
print("默认管理员账户已创建: admin / ourcraftadminyyds")
# 初始化世界
from app import reset_world # ✅ 引入重置函数
if WorldBlock.query.first() is None:
reset_world()
# 启动应用
print("=" * 50)
print("启动服务器...")
print("访问地址: http://localhost:5000")
print("2D创造游戏: http://localhost:5000/2d_minecraft.html")
print("=" * 50)
socketio.run(app, debug=True, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2D创造世界 - 多人联机版</title>
<!-- CSRF Token Meta -->
<meta name="csrf-token" content="{{ csrf_token }}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
height: 100vh;
overflow: hidden;
}
#game-container {
position: relative;
width: 100%;
height: 100%;
display: none;
}
#game-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #7ec0ee 0%, #87ceeb 50%, #7ec0ee 100%);
cursor: crosshair;
}
#login-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
#login-form {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 40px;
border-radius: 15px;
width: 90%;
max-width: 400px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
text-align: center;
}
.form-group { margin-bottom: 20px; text-align: left; }
.form-group label { display: block; margin-bottom: 8px; color: #ccc; }
.form-group input {
width: 100%;
padding: 12px 15px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: white;
font-size: 16px;
}
#login-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
#login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(78, 84, 200, 0.4);
}
#login-message {
margin-top: 15px;
color: #ff6b6b;
font-size: 14px;
min-height: 20px;
}
#ui-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
}
#game-info {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 15px;
border-radius: 8px;
z-index: 101;
display: flex;
flex-direction: column;
gap: 5px;
font-family: 'Courier New', monospace;
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: auto;
}
.info-item { display: flex; align-items: center; gap: 10px; }
.info-label { font-size: 12px; opacity: 0.8; min-width: 70px; }
.info-value { font-size: 14px; font-weight: bold; }
#block-selector {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
padding: 10px 20px;
border-radius: 10px;
z-index: 101;
display: flex;
gap: 10px;
border: 2px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
pointer-events: auto;
}
.block-option {
width: 40px;
height: 40px;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: center;
align-items: center;
font-size: 12px;
pointer-events: auto;
}
.block-option.selected {
border-color: #4e54c8;
box-shadow: 0 0 10px rgba(78, 84, 200, 0.5);
transform: translateY(-3px);
}
/* 修改:聊天框始终显示 */
#chat-box {
position: absolute;
bottom: 80px;
right: 20px;
width: 300px;
height: 350px;
background: rgba(0, 0, 0, 0.8);
border-radius: 10px;
z-index: 101;
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
pointer-events: auto;
/* 修改:移除display: none */
}
#chat-header {
padding: 10px 15px;
background: rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-weight: bold;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 10px 10px 0 0;
}
#chat-messages {
flex: 1;
padding: 10px;
overflow-y: auto;
max-height: 250px;
min-height: 250px;
}
.chat-message {
margin-bottom: 5px;
padding: 5px 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 12px;
word-wrap: break-word;
}
#chat-input {
padding: 10px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: white;
font-size: 14px;
border-radius: 0 0 10px 10px;
pointer-events: auto;
}
#players-list {
position: absolute;
top: 20px;
right: 340px; /* 给聊天框留出空间 */
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 15px;
border-radius: 8px;
z-index: 101;
display: flex;
flex-direction: column;
gap: 5px;
font-family: 'Courier New', monospace;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 150px;
pointer-events: auto;
}
.player-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px;
border-radius: 4px;
}
.player-color {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.control-btn {
position: absolute;
z-index: 101;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: 'Courier New', monospace;
font-size: 14px;
transition: all 0.2s;
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#reset-world-btn {
top: 20px;
left: 200px;
}
/* 移除聊天按钮,因为聊天框始终显示 */
#chat-btn {
display: none;
}
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
flex-direction: column;
gap: 20px;
display: none;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid rgba(255, 255, 255, 0.1);
border-top: 5px solid #4e54c8;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 皮肤选择器 */
#skin-selector {
position: absolute;
bottom: 80px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 10px;
border-radius: 10px;
z-index: 101;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: auto;
}
.skin-option {
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.skin-option.selected {
border-color: white;
transform: scale(1.2);
}
</style>
</head>
<body>
<!-- 登录界面 -->
<div id="login-overlay">
<div id="login-form">
<h2>2D创造世界</h2>
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" placeholder="请输入用户名" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" placeholder="请输入密码" required>
</div>
<button id="login-btn">进入游戏</button>
<div id="login-message"></div>
</div>
</div>
<!-- 加载界面 -->
<div id="loading-screen">
<div class="loading-spinner"></div>
<div id="loading-text">正在连接服务器...</div>
</div>
<!-- 游戏界面 -->
<div id="game-container">
<canvas id="game-canvas"></canvas>
<div id="ui-overlay">
<!-- 游戏信息 -->
<div id="game-info">
<div class="info-item">
<div class="info-label">玩家:</div>
<div id="player-name" class="info-value">未登录</div>
</div>
<div class="info-item">
<div class="info-label">位置:</div>
<div id="player-pos" class="info-value">0, 0</div>
</div>
<div class="info-item">
<div class="info-label">在线:</div>
<div id="online-count" class="info-value">1</div>
</div>
</div>
<!-- 玩家列表 -->
<div id="players-list">
<div style="font-weight: bold; margin-bottom: 10px;">在线玩家</div>
</div>
<!-- 皮肤选择器 -->
<div id="skin-selector">
<div style="font-size: 10px; margin-bottom: 5px; opacity: 0.8;">选择皮肤</div>
<div style="display: flex; gap: 8px;">
<div class="skin-option" style="background: #3d85c6;" data-color="#3d85c6"></div>
<div class="skin-option" style="background: #e06666;" data-color="#e06666"></div>
<div class="skin-option" style="background: #93c47d;" data-color="#93c47d"></div>
<div class="skin-option" style="background: #ffd966;" data-color="#ffd966"></div>
<div class="skin-option" style="background: #6fa8dc;" data-color="#6fa8dc"></div>
<div class="skin-option" style="background: #8e7cc3;" data-color="#8e7cc3"></div>
</div>
</div>
<!-- 方块选择器 -->
<div id="block-selector"></div>
<!-- 聊天框(始终显示) -->
<div id="chat-box">
<div id="chat-header">
<span>聊天</span>
<span style="font-size: 10px; opacity: 0.7;">按T键聚焦</span>
</div>
<div id="chat-messages"></div>
<input type="text" id="chat-input" placeholder="输入消息 (按Enter发送)">
</div>
<!-- 控制按钮 -->
<button id="reset-world-btn" class="control-btn">重置世界</button>
</div>
</div>
<!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>
<script>
// ==================== 游戏核心配置 ====================
const canvas = document.getElementById('game-canvas');
const ctx = canvas.getContext('2d');
let CANVAS_WIDTH = 1200;
let CANVAS_HEIGHT = 700;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
// 游戏常量
const BLOCK_SIZE = 32;
const CHUNK_SIZE = 16;
const PLAYER_SPEED = 5;
const PLAYER_WIDTH = 24;
const PLAYER_HEIGHT = 36;
const PLAYER_INTERACTION_RANGE = 5 * BLOCK_SIZE;
// 扩展方块类型定义(增加更多方块)
const BLOCK_TYPES = {
0: { id: 0, name: "空气", color: "transparent", solid: false },
1: { id: 1, name: "草地", color: "#5a9c5a", solid: true },
2: { id: 2, name: "泥土", color: "#8B4513", solid: true },
3: { id: 3, name: "石头", color: "#808080", solid: true },
4: { id: 4, name: "木材", color: "#4d2600", solid: true },
5: { id: 5, name: "树叶", color: "#228B22", solid: true },
6: { id: 6, name: "水", color: "#1E90FF", solid: false },
7: { id: 7, name: "沙子", color: "#C2B280", solid: true },
8: { id: 8, name: "玻璃", color: "#87CEEB", solid: true, transparent: true },
9: { id: 9, name: "砖块", color: "#B22222", solid: true },
10: { id: 10, name: "雪地", color: "#F0F8FF", solid: true },
11: { id: 11, name: "基岩", color: "#191970", solid: true },
12: { id: 12, name: "钻石矿", color: "#00FFFF", solid: true },
13: { id: 13, name: "金矿", color: "#FFD700", solid: true },
14: { id: 14, name: "铁矿", color: "#D3D3D3", solid: true },
15: { id: 15, name: "煤矿", color: "#2F4F4F", solid: true },
16: { id: 16, name: "红石", color: "#FF0000", solid: true },
17: { id: 17, name: "青金石", color: "#4169E1", solid: true },
18: { id: 18, name: "绿宝石", color: "#32CD32", solid: true },
19: { id: 19, name: "下界岩", color: "#8B0000", solid: true },
20: { id: 20, name: "灵魂沙", color: "#8B7355", solid: true },
21: { id: 21, name: "萤石", color: "#FFFF99", solid: true },
22: { id: 22, name: "粘土", color: "#8B7355", solid: true },
23: { id: 23, name: "冰", color: "#87CEEB", solid: true },
24: { id: 24, name: "书架", color: "#8B4513", solid: true },
25: { id: 25, name: "南瓜", color: "#FFA500", solid: true }
};
// 游戏状态
let gameStarted = false;
let socket = null;
let currentUser = null;
// 玩家对象 - 修改:初始位置在世界中央草坪上方10格
let player = {
username: "",
x: 250 * BLOCK_SIZE, // 世界中央 (世界宽度500/2 = 250)
y: 50 * BLOCK_SIZE, // 草坪在y=60,上方10格就是y=50
color: "#3d85c6",
selectedBlock: 1
};
// 其他玩家
let otherPlayers = {};
// 世界数据
let worldData = {};
// 按键状态
let keys = { w: false, a: false, s: false, d: false };
// 相机
let camera = { x: 0, y: 0 };
// 鼠标位置
let mouse = { x: 0, y: 0 };
// ==================== 初始化函数 ====================
function init() {
resizeCanvas();
setupEventListeners();
initBlockSelector();
initSkinSelector();
// 检查是否已经登录
checkLoginStatus();
}
function resizeCanvas() {
CANVAS_WIDTH = window.innerWidth;
CANVAS_HEIGHT = window.innerHeight;
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
}
function setupEventListeners() {
window.addEventListener('resize', resizeCanvas);
// 登录
document.getElementById('login-btn').addEventListener('click', handleLogin);
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') handleLogin();
});
// 游戏控制
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = true;
// T键打开聊天
if (key === 't' || key === 'T') {
e.preventDefault();
document.getElementById('chat-input').focus();
}
// 数字键选择方块
if (key >= '0' && key <= '9') {
const num = parseInt(key);
if (num <= 9) selectBlock(num);
}
// 扩展方块选择(使用shift+数字)
if (e.shiftKey && key >= '0' && key <= '9') {
const num = parseInt(key) + 10;
if (BLOCK_TYPES[num]) selectBlock(num);
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
if (key in keys) keys[key] = false;
});
// 鼠标控制
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
});
// 按钮控制
document.getElementById('reset-world-btn').addEventListener('click', resetWorld);
// 聊天输入
document.getElementById('chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const message = e.target.value.trim();
if (message) {
sendChatMessage(message);
e.target.value = '';
}
}
});
}
function initBlockSelector() {
const selector = document.getElementById('block-selector');
selector.innerHTML = '';
// 创建两行方块选择器
for (let row = 0; row < 2; row++) {
const rowDiv = document.createElement('div');
rowDiv.style.display = 'flex';
rowDiv.style.gap = '5px';
rowDiv.style.marginBottom = '5px';
for (let i = 1; i <= 9; i++) {
const blockId = row * 9 + i;
if (BLOCK_TYPES[blockId]) {
const option = document.createElement('div');
option.className = 'block-option';
option.style.backgroundColor = BLOCK_TYPES[blockId].color;
option.title = `${BLOCK_TYPES[blockId].name} (${blockId})`;
option.dataset.blockId = blockId;
const number = document.createElement('span');
number.textContent = blockId;
number.style.color = 'white';
number.style.fontSize = '10px';
option.appendChild(number);
option.addEventListener('click', () => selectBlock(blockId));
rowDiv.appendChild(option);
}
}
selector.appendChild(rowDiv);
}
selectBlock(1);
}
function initSkinSelector() {
const skinOptions = document.querySelectorAll('.skin-option');
skinOptions.forEach(option => {
option.addEventListener('click', () => {
const color = option.dataset.color;
player.color = color;
// 更新选中状态
skinOptions.forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
// 通知服务器皮肤变更
if (socket && socket.connected) {
socket.emit('player_update', {
x: player.x,
y: player.y,
color: player.color
});
}
});
});
// 默认选中第一个皮肤
skinOptions[0].classList.add('selected');
}
function checkLoginStatus() {
fetch('/api/userinfo', {
method: 'GET',
credentials: 'include'
})
.then(response => response.json())
.then(data => {
if (data.logged_in) {
document.getElementById('login-overlay').style.display = 'none';
showLoading('加载世界数据...');
loadWorldData().then(() => {
showLoading(false);
initGame(data.username);
});
}
})
.catch(error => {
console.log('用户未登录,需要重新登录');
});
}
// ==================== 登录系统 ====================
function handleLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value.trim();
const messageDiv = document.getElementById('login-message');
if (!username || !password) {
showMessage('用户名和密码不能为空', 'error');
return;
}
showLoading('正在登录...');
fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({ username, password }),
credentials: 'include'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showLoading('加载世界数据...');
return loadWorldData().then(() => {
document.getElementById('login-overlay').style.display = 'none';
showLoading(false);
initGame(username);
});
} else {
throw new Error(data.error || '登录失败');
}
})
.catch(error => {
showLoading(false);
showMessage(error.message, 'error');
});
}
function getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute('content') : '';
}
function showMessage(message, type = 'error') {
const messageDiv = document.getElementById('login-message');
messageDiv.textContent = message;
messageDiv.style.color = type === 'error' ? '#ff6b6b' : '#4CAF50';
}
function showLoading(text = null) {
const loadingScreen = document.getElementById('loading-screen');
if (text) {
loadingScreen.style.display = 'flex';
document.getElementById('loading-text').textContent = text;
} else {
loadingScreen.style.display = 'none';
}
}
// ==================== 游戏初始化 ====================
function initGame(username) {
player.username = username;
document.getElementById('player-name').textContent = username;
// 显示游戏界面
document.getElementById('game-container').style.display = 'block';
// 连接Socket.IO
connectSocketIO();
// 开始游戏循环
gameStarted = true;
gameLoop();
}
function connectSocketIO() {
socket = io();
socket.on('connect', () => {
console.log('Socket.IO连接成功');
// 发送玩家加入
socket.emit('player_join', {
username: player.username,
color: player.color,
x: player.x,
y: player.y,
selectedBlock: player.selectedBlock
});
});
socket.on('connected', (data) => {
console.log('服务器确认连接', data);
});
socket.on('player_joined', (data) => {
if (data.username !== player.username) {
addChatMessage('系统', `${data.username} 加入了游戏`, true);
otherPlayers[data.username] = {
username: data.username,
x: data.x,
y: data.y,
color: data.color || '#3d85c6'
};
updatePlayersList();
}
});
socket.on('player_left', (data) => {
if (data.username in otherPlayers) {
addChatMessage('系统', `${data.username} 离开了游戏`, true);
delete otherPlayers[data.username];
updatePlayersList();
}
});
socket.on('player_updated', (data) => {
if (data.username !== player.username && data.username in otherPlayers) {
otherPlayers[data.username].x = data.x;
otherPlayers[data.username].y = data.y;
if (data.color) otherPlayers[data.username].color = data.color;
}
});
socket.on('block_updated', (data) => {
const blockX = data.x;
const blockY = data.y;
const blockType = data.blockType;
const chunkX = Math.floor(blockX / CHUNK_SIZE);
const chunkY = Math.floor(blockY / CHUNK_SIZE);
const localX = blockX % CHUNK_SIZE;
const localY = blockY % CHUNK_SIZE;
const chunkKey = `${chunkX},${chunkY}`;
const blockKey = `${localX},${localY}`;
if (!worldData[chunkKey]) worldData[chunkKey] = {};
worldData[chunkKey][blockKey] = blockType;
});
socket.on('chat_message', (data) => {
if (data.sender !== player.username) {
addChatMessage(data.sender, data.message);
}
});
socket.on('players_list', (data) => {
otherPlayers = {};
data.players.forEach(p => {
if (p.username !== player.username) {
otherPlayers[p.username] = p;
}
});
updatePlayersList();
});
}
function loadWorldData() {
return fetch('/api/world_blocks', {
method: 'GET',
headers: {
'X-CSRFToken': getCSRFToken()
},
credentials: 'include'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data.success) {
worldData = data.world_data;
console.log('世界数据加载完成,区块数:', Object.keys(worldData).length);
} else {
throw new Error(data.error || '加载世界数据失败');
}
})
.catch(error => {
console.error('加载世界数据失败:', error);
worldData = {};
});
}
function resetWorld() {
if (confirm('确定要重置世界吗?所有方块将被重置为默认地形。')) {
fetch('/api/reset_world', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
credentials: 'include'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('世界已重置,正在重新加载...');
loadWorldData().then(() => {
location.reload();
});
} else {
alert('重置失败: ' + data.error);
}
})
.catch(error => {
console.error('重置世界失败:', error);
alert('重置失败,请检查网络连接');
});
}
}
// ==================== 游戏逻辑 ====================
function updatePlayer() {
if (!gameStarted) return;
let moveX = 0;
let moveY = 0;
if (keys['w']) moveY -= PLAYER_SPEED;
if (keys['s']) moveY += PLAYER_SPEED;
if (keys['a']) moveX -= PLAYER_SPEED;
if (keys['d']) moveX += PLAYER_SPEED;
if (moveX !== 0 && moveY !== 0) {
moveX *= 0.7071;
moveY *= 0.7071;
}
// 修改:取消重力,只用WASD控制
// 检查碰撞
if (moveX !== 0) {
const newX = player.x + moveX;
if (!checkCollision(newX, player.y, PLAYER_WIDTH, PLAYER_HEIGHT)) {
player.x = newX;
}
}
if (moveY !== 0) {
const newY = player.y + moveY;
if (!checkCollision(player.x, newY, PLAYER_WIDTH, PLAYER_HEIGHT)) {
player.y = newY;
}
}
// 更新UI
document.getElementById('player-pos').textContent =
`${Math.floor(player.x)}, ${Math.floor(player.y)}`;
// 更新相机
camera.x = player.x - CANVAS_WIDTH / 2;
camera.y = player.y - CANVAS_HEIGHT / 2;
// 发送位置更新
if (socket && socket.connected) {
socket.emit('player_update', {
x: player.x,
y: player.y,
color: player.color
});
}
}
function checkCollision(x, y, width, height) {
const left = Math.floor(x / BLOCK_SIZE);
const right = Math.floor((x + width) / BLOCK_SIZE);
const top = Math.floor(y / BLOCK_SIZE);
const bottom = Math.floor((y + height) / BLOCK_SIZE);
for (let bx = left; bx <= right; bx++) {
for (let by = top; by <= bottom; by++) {
const blockType = getBlockAt(bx, by);
const block = BLOCK_TYPES[blockType];
if (block && block.solid) {
const blockX = bx * BLOCK_SIZE;
const blockY = by * BLOCK_SIZE;
if (x + width > blockX &&
x < blockX + BLOCK_SIZE &&
y + height > blockY &&
y < blockY + BLOCK_SIZE) {
return true;
}
}
}
}
return false;
}
function handleMouseDown(e) {
if (!gameStarted || !socket) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left + camera.x;
const mouseY = e.clientY - rect.top + camera.y;
const blockX = Math.floor(mouseX / BLOCK_SIZE);
const blockY = Math.floor(mouseY / BLOCK_SIZE);
// 检查距离
const distance = Math.sqrt(
Math.pow(player.x - blockX * BLOCK_SIZE, 2) +
Math.pow(player.y - blockY * BLOCK_SIZE, 2)
);
if (distance > PLAYER_INTERACTION_RANGE) {
return;
}
// 修改:简化点击逻辑
// 左键:如果有方块就破坏,没有方块就放置
// 右键:如果有方块就破坏,没有方块就放置
const currentBlock = getBlockAt(blockX, blockY);
if (currentBlock !== 0) {
// 破坏方块(替换为空气)
updateBlock(blockX, blockY, 0, true);
} else {
// 放置方块
if (player.selectedBlock !== 0) {
updateBlock(blockX, blockY, player.selectedBlock, true);
}
}
}
function updateBlock(blockX, blockY, blockType, broadcast = false) {
const chunkX = Math.floor(blockX / CHUNK_SIZE);
const chunkY = Math.floor(blockY / CHUNK_SIZE);
const localX = blockX % CHUNK_SIZE;
const localY = blockY % CHUNK_SIZE;
const chunkKey = `${chunkX},${chunkY}`;
const blockKey = `${localX},${localY}`;
if (!worldData[chunkKey]) worldData[chunkKey] = {};
worldData[chunkKey][blockKey] = blockType;
fetch('/api/update_block', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCSRFToken()
},
body: JSON.stringify({
x: blockX,
y: blockY,
blockType: blockType
}),
credentials: 'include'
})
.then(response => response.json())
.then(data => {
if (!data.success) {
console.error('保存方块失败:', data.error);
}
})
.catch(error => {
console.error('保存方块请求失败:', error);
});
if (broadcast && socket && socket.connected) {
socket.emit('block_update', {
x: blockX,
y: blockY,
blockType: blockType
});
}
}
function getBlockAt(worldX, worldY) {
const chunkX = Math.floor(worldX / CHUNK_SIZE);
const chunkY = Math.floor(worldY / CHUNK_SIZE);
const blockX = worldX % CHUNK_SIZE;
const blockY = worldY % CHUNK_SIZE;
const chunkKey = `${chunkX},${chunkY}`;
const blockKey = `${blockX},${blockY}`;
if (worldData[chunkKey] && worldData[chunkKey][blockKey] !== undefined) {
return worldData[chunkKey][blockKey];
}
// 默认生成地形(如果没有数据)
if (worldY >= 95) return 11; // 基岩
if (worldY >= 70) return 3; // 石头
if (worldY >= 65) return 2; // 泥土
if (worldY === 60) return 1; // 草地
return 0; // 空气
}
function selectBlock(blockId) {
if (BLOCK_TYPES[blockId]) {
player.selectedBlock = blockId;
const options = document.querySelectorAll('.block-option');
options.forEach(option => {
option.classList.remove('selected');
if (parseInt(option.dataset.blockId) === blockId) {
option.classList.add('selected');
}
});
}
}
// ==================== 渲染系统 ====================
function render() {
if (!gameStarted) return;
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
drawSky();
drawWorld();
drawOtherPlayers();
drawPlayer();
// 绘制方块轮廓(如果鼠标在可交互范围内)
drawBlockOutline();
}
function drawSky() {
const gradient = ctx.createLinearGradient(0, 0, 0, CANVAS_HEIGHT);
gradient.addColorStop(0, '#87CEEB');
gradient.addColorStop(0.5, '#7ec0ee');
gradient.addColorStop(1, '#5aa9e6');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
function drawWorld() {
const startChunkX = Math.floor(camera.x / (CHUNK_SIZE * BLOCK_SIZE)) - 1;
const endChunkX = Math.floor((camera.x + CANVAS_WIDTH) / (CHUNK_SIZE * BLOCK_SIZE)) + 1;
const startChunkY = Math.floor(camera.y / (CHUNK_SIZE * BLOCK_SIZE)) - 1;
const endChunkY = Math.floor((camera.y + CANVAS_HEIGHT) / (CHUNK_SIZE * BLOCK_SIZE)) + 1;
for (let cx = startChunkX; cx <= endChunkX; cx++) {
for (let cy = startChunkY; cy <= endChunkY; cy++) {
drawChunk(cx, cy);
}
}
}
function drawChunk(chunkX, chunkY) {
const screenX = chunkX * CHUNK_SIZE * BLOCK_SIZE - camera.x;
const screenY = chunkY * CHUNK_SIZE * BLOCK_SIZE - camera.y;
for (let bx = 0; bx < CHUNK_SIZE; bx++) {
for (let by = 0; by < CHUNK_SIZE; by++) {
const worldX = chunkX * CHUNK_SIZE + bx;
const worldY = chunkY * CHUNK_SIZE + by;
const blockType = getBlockAt(worldX, worldY);
if (blockType === 0) continue;
const block = BLOCK_TYPES[blockType];
if (!block) continue;
const x = screenX + bx * BLOCK_SIZE;
const y = screenY + by * BLOCK_SIZE;
if (x + BLOCK_SIZE < 0 || x > CANVAS_WIDTH ||
y + BLOCK_SIZE < 0 || y > CANVAS_HEIGHT) {
continue;
}
ctx.fillStyle = block.color;
ctx.fillRect(x, y, BLOCK_SIZE, BLOCK_SIZE);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, BLOCK_SIZE, BLOCK_SIZE);
}
}
}
function drawPlayer() {
const screenX = player.x - camera.x;
const screenY = player.y - camera.y;
ctx.fillStyle = player.color;
ctx.fillRect(screenX, screenY, PLAYER_WIDTH, PLAYER_HEIGHT);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(screenX, screenY, PLAYER_WIDTH, PLAYER_HEIGHT);
ctx.fillStyle = 'white';
ctx.fillRect(screenX + PLAYER_WIDTH - 8, screenY + 10, 4, 4);
ctx.fillRect(screenX + 4, screenY + 10, 4, 4);
}
function drawOtherPlayers() {
Object.values(otherPlayers).forEach(otherPlayer => {
const screenX = otherPlayer.x - camera.x;
const screenY = otherPlayer.y - camera.y;
if (screenX + PLAYER_WIDTH < 0 || screenX > CANVAS_WIDTH ||
screenY + PLAYER_HEIGHT < 0 || screenY > CANVAS_HEIGHT) {
return;
}
ctx.fillStyle = otherPlayer.color || '#3d85c6';
ctx.fillRect(screenX, screenY, PLAYER_WIDTH, PLAYER_HEIGHT);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(screenX, screenY, PLAYER_WIDTH, PLAYER_HEIGHT);
ctx.fillStyle = 'white';
ctx.fillRect(screenX + PLAYER_WIDTH - 8, screenY + 10, 4, 4);
ctx.fillRect(screenX + 4, screenY + 10, 4, 4);
ctx.fillStyle = 'white';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(otherPlayer.username, screenX + PLAYER_WIDTH / 2, screenY - 5);
});
}
function drawBlockOutline() {
const mouseWorldX = mouse.x + camera.x;
const mouseWorldY = mouse.y + camera.y;
const blockX = Math.floor(mouseWorldX / BLOCK_SIZE);
const blockY = Math.floor(mouseWorldY / BLOCK_SIZE);
const screenX = blockX * BLOCK_SIZE - camera.x;
const screenY = blockY * BLOCK_SIZE - camera.y;
// 检查距离是否在交互范围内
const distance = Math.sqrt(
Math.pow(player.x - blockX * BLOCK_SIZE, 2) +
Math.pow(player.y - blockY * BLOCK_SIZE, 2)
);
if (distance <= PLAYER_INTERACTION_RANGE) {
ctx.strokeStyle = '#FFFF00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.strokeRect(screenX, screenY, BLOCK_SIZE, BLOCK_SIZE);
ctx.setLineDash([]);
}
}
// ==================== UI系统 ====================
function addChatMessage(sender, message, isSystem = false) {
const chatMessages = document.getElementById('chat-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'chat-message';
if (isSystem) {
messageDiv.textContent = `[系统] ${message}`;
messageDiv.style.color = '#8f94fb';
messageDiv.style.fontStyle = 'italic';
} else {
messageDiv.textContent = `${sender}: ${message}`;
}
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function sendChatMessage(message) {
addChatMessage(player.username, message);
if (socket && socket.connected) {
socket.emit('chat_message', {
sender: player.username,
message: message
});
}
}
function updatePlayersList() {
const playersList = document.getElementById('players-list');
const onlineCount = Object.keys(otherPlayers).length + 1;
document.getElementById('online-count').textContent = onlineCount;
playersList.innerHTML = `
<div style="font-weight: bold; margin-bottom: 10px;">
在线玩家 (${onlineCount})
</div>
<div class="player-item">
<span class="player-color" style="background: ${player.color}"></span>
<span>${player.username} (你)</span>
</div>
`;
Object.values(otherPlayers).forEach(otherPlayer => {
const playerItem = document.createElement('div');
playerItem.className = 'player-item';
playerItem.innerHTML = `
<span class="player-color" style="background: ${otherPlayer.color || '#3d85c6'}"></span>
<span>${otherPlayer.username}</span>
`;
playersList.appendChild(playerItem);
});
}
// ==================== 游戏主循环 ====================
function gameLoop() {
if (!gameStarted) return;
updatePlayer();
render();
requestAnimationFrame(gameLoop);
}
// ==================== 启动游戏 ====================
window.addEventListener('load', init);
</script>
</body>
</html>
特别注意,此项目是基于服务器运行的,单机无法运行,所以,还是来到ourcraft.xin,注册一个账号,来到"自由建造"栏目吧 

可以看到是可以联机的
只有知名度提升上去了,才有更多的玩家来玩,才可以联机