【译】2D简易移动游戏开发指南—Flutter和Flame使用详解(Part1/5)

你有没有想过开发一款电子游戏?那你来对地方了。这是一系列持续更新的2D简易移动游戏的开发教程。

这篇教程是之前一篇入门简介
的延续。这篇文章,我们的目标是写一款具有可玩性的打地鼠风格的游戏。

这个游戏叫做打苍蝇(Langaw),任务是在苍蝇接触到垃圾堆之前尽可能多的拍死它们,你必须从苍蝇手里保护垃圾堆,毕竟,谁会愿意它飞满苍蝇呢。

用户的操作方式仍然是简单的点击。在这一部分,我们仍然不会使用图片文件所以苍蝇们看起来只是一些绿色的小方块,当它们被点击到时,它们变成红色然后落到屏幕底部。

本文所有的代码都在该Github目录

1. 创建一个Flame/Flutter游戏

创建项目

VS Code中的Flutter插件有一个生成Flutter项目的命令,只需要在VS Code中同时按下Ctrl + Shift + P然后输入"flutter"。从下拉选项中选择Flutter: New Project,输入项目名称,然后选择项目目录。


同样的,你可以在使用以下命令在命令行中当前目录创建名为langaw的Flutter项目:

$ flutter create langaw

你也可以把langaw改为其他名字。
等到命令运行完成,使用VS Code打开项目,进入下一步。

清理无关代码

就像上一节处理的一样,我们会删除test目录,因为介绍测试内容将会花掉比这整个系列更多的内容。

image.png

最后,删除Flutter的启动逻辑,打开./lib/main.dart然后删掉void main声明下的所有内容,经过处理之后的main.dart文件应该看起来像下面这样:

import 'package:flutter/material.dart';

void main() {}

我们留下了material库的引用,因为接下来还会用到它的runApp方法。

引入Flame插件

下一步,我们要安装Flame插件,打开./pubspec.yaml文件,然后在cupertino_icons: ^0.1.2行下面加上flame: ^0.10.2

可选步骤:删除该文件中所有#开头的注释。

此时该文件应该如下所示:



完成并保存之后,需要运行如下命令安装引入的包:

$ flutter packages get

注意:如果你在VS Code中安装了Flutter和Dart的插件,每当你保存pubspec.yaml时,它都会自动运行flutter packages get命令,无需你手动执行。

游戏初始化

我们需要让我们的游戏始终保持竖屏,同样也要开启全屏,没有顶部通知栏和底部按钮栏。

我们将用Flame库中的Util类来实现上述功能。回到./lib/main.dart文件,使用下列代码引入util库和Flutter的service库。service库给我们提供了DeviceOrientation类,通过它我们能获取设备的放置方向。

import 'package:flame/util.dart';
import 'package:flutter/services.dart';

然后我们将main函数加上async关键字,将它转化为异步函数,以便我们在函数体中使用await关键字,然后在函数体中加入执行全屏和竖屏的语句:

Util flameUtil = Util();
await flameUtil.fullScreen();
await flameUtil.setOrientation(DeviceOrientation.portraitUp);

main.dart文件现在应该像这样:

虽然我们的游戏还没有任何内容,但你现在运行的话,如果没有任何报错,并且全屏开启,那说明你的步骤正确。

可以通过在VS Code中按下F5,你同样也可以在命令行的当前目录下执行如下命令来运行项目:

$ flutter run

如下图所示的白色屏幕就是正常的,只要没有任何报错:


2. 为Game类配置主循环

我们需要一个具有游戏主循环逻辑的Game类,它是游戏的核心,包含了所有非玩家输入的逻辑。

Game类

接下来我们创建一个文件./lib/langaw-game.dart,并加入下列代码:

import 'dart:ui';
import 'package:flame/game.dart';

class LangawGame extends Game {
  Size screenSize;

  void render(Canvas canvas) {}

  void update(double t) {}

  void resize(Size size) {}
}

简析:我们引入了Dartui库,以使用CanvasSize类。为了使用Flame的游戏主循环框架,我们引入了Flame的game库。然后我们创建了一个继承自Flame的Game类的LangawGame类。这个类包含了覆写自Game类的3个同名函数。同时,我们声明了一个名为screenSize的Size类用于存储屏幕尺寸。

该文件应该如下图所示:


引入和运行game类

现在我们需要把game类导入到main函数中,当游戏运行时,它会从game的实例LangawGame类启动。让我们回到./lib/main.dart然后导入我们新创建的langaw-game.dart文件:

import 'package:langaw/langaw-game.dart';

然后,创建一个LangawGame类的实例,然后将LangawGame类的widget属性传入runApp方法:

LangawGame game = LangawGame();
runApp(game.widget);

现在,./lib/main.dart如下图所示:

image

屏幕尺寸

让我们回到./lib/langaw-game.dart中继续完善我们的游戏。我们现在要获取屏幕尺寸,为后面绘制画面作准备。
有一些特定的事件会引起Flutter重新计算Canvas暴露给应用的尺寸(在本例中为LangawGame)。其中一个事件就是游戏开始运行时。其他的事件比如横置屏幕会引起屏幕的横竖尺寸互换。我们现在制作的游戏支支持竖屏模式,所以我们不用考虑这个问题。
有的手机支持多种分辨率所以玩家可以在他们游玩的时候更改分辨率。我们需要做些准备,适配这一特性,以保证当Canvas的尺寸改变后,Flutter通过resize方法通知我们时,我们能重新计算我们的尺寸。
顺带一提,Canvas类就是我们绘制包括背景,敌人和用户界面在内的各种元素的地方。
现在,我们把下面这行代码加入到resize方法中:

screenSize = size;

这行代码的作用是当屏幕尺寸改变时,将改变后的尺寸赋值给screenSize,以便于后面在游戏主循环中调用。

image

绘制背景

首先我们要在屏幕上绘制背景。
把下列代码置于render方法中:

# 使用`Rect`的`fromLTWH`方法创建了一个`Rect`类的实例`bgRect`,它的左上位置与坐标为(0, 0),它的尺寸与屏幕的长宽一致
Rect bgRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height);
# 创建Paint类的实例,并将传入了色号的Color对象赋给color属性
Paint bgPaint = Paint();
bgPaint.color = Color(0xff576574);
#调用canvas的drawRect方法,传入图形和绘制参数
canvas.drawRect(bgRect, bgPaint);

现在文件应该如下图所示:


image

现在运行游戏,游戏的背景应该变成如下颜色:


image

适配不同尺寸的手机

在我们创建我们的第一个游戏组件之前,有一个需要处理的问题:
手机尺寸
根据这篇文章的统计,在2015年一共有超过24000款不同的安卓设备,更不用讲现在这个数字会扩大到什么程度。
不同的机型往往有着不同的尺寸和高宽比,我们需要想办法尽可能的去适配它们。
现在来介绍下高宽比,它是设备宽和高的比率,它会在你切换横竖屏的时候发生切换。
现在市面上的手机有很多不同的高宽比,比如3:24:38:55:316:9甚至18.5:9,最常见的比例是16:9,我们将这个比例当作基准。

因为我们只使用竖屏模式,我们事实上是使用9:16作为基准,更具体的,我们把宽度9作为基准,将其他的宽高比转化为9:13.59:129:14.49:159:16以及 9:18.5
这样的话,我们的游戏界面始终保持9只苍蝇的宽度,无论手机是短是长,都只影响苍蝇上下飞的距离长短。
重要的是,无论屏幕的尺寸和宽高比是多少,一只苍蝇的宽度始终保持在屏幕的1/9,在这样的设计下,9只苍蝇总能横向占满屏幕。

实现网格系统

为了获取网格(即一只苍蝇的占位)的尺寸,我们需要增加一个tileSize参数来记录网格的大小,将下列声明代码置于screenSize声明行之下:

double tileSize;

这个变量将会保存屏幕宽度1/9的长度值,便于我们在game类的任何位置访问,因此我们需要把tileSize的赋值语句置于resize方法中,以便于我们能在屏幕尺寸变化的第一时间更新它的参数。

void resize(Size size) {
  screenSize = size;
  tileSize = screenSize.width / 9;
}

现在game类应该如下图所示:

image

3. 创建苍蝇组件

现在我们要来创建我们的第一个游戏组件了。
组件,有时候被称作对象或游戏对象,是在游戏中担任某种角色的对象,比如游戏的主角,敌人,土地,地图,UI的一部分,子弹等等。有些组件往往会在它的位置覆盖图像,以便于玩家知道它们的位置。
并不是所有的组件都具有位置和绘画属性。有的组件只是为了实现某个功能,并不会在屏幕上展示,这种组件被称作controllers(控制器)。控制器用于控制游戏的行为。

为什么要使用组件?

想想你之前玩过的较大的游戏,如果把所有的逻辑都放在一个类里面,那它应该至少有成千上万行代码,这样的项目维护起来一定很头痛。
另外一点就是,有了组件,我们可以利用dart面向对象的特性。我们可以在游戏主循环中创建类的实例,使用其中封装好的方法和数据,而不用过多的考虑其内部的逻辑。
你可以把一个组件想象成一个小的主循环,或者是主循环的一部分,它们同样具有updaterender方法。

我们的第一个组件

我们需要创建目录./lib/components来存放组件,在该目录中,创建一个新文件fly.dart
打开./lib/components/fly.dart然后将下列代码写入该文件:

import 'dart:ui';

class Fly {
  void render(Canvas c) {}

  void update(double t) {}
}

文件内容和游戏主循坏非常相似。原因是当这个组件需要更新和渲染时,游戏主循坏将会调用组件的上述两个方法。

位置和尺寸

苍蝇(fly)组件需要知道它的位置和大小,所以我们要创建变量来存储这些信息。
我们可以用double xdouble ydouble widthdouble height四个变量来保存上述信息,但是我们还可以更优化存入的数据。
对于传入的数据结构,我们有太多的选择,我们可以传x / ywidth / height,也可以传dart:ui库里的SizeOffset类,但这些组合还是包含了两个变量,我们能不能把变量优雅的压缩到一个呢?
答案是肯定的,还记得我们绘制背景时用到的Rect类吗?在构建这个类的实例的时候,我们就通过fromLTWH工厂函数传入了所需的x, y, width, height全部参数。
唯一的缺点是Rect实例是不可变的(immutable)。意味着你不能通过赋值的方式改变它的任何属性(无论是top或者left)。但是你可以通过它的shifttranslate方法来移动它。
我们来创建一个名为flyRectRect类的实例:

Rect flyRect;

然后我们需要引入game类以便使用它的screenSize之类的参数:

import 'package:langaw/langaw-game.dart';

接下来,在flyRect行下面,加入另一行实例变量作为父级game类的引用:

final LangawGame game;

注意:final变量相当于作用域中的常量。它的值是不可变的。因为我们的苍蝇在它的生命周期里只存在于一个game类中,它的父类不需要是动态的。
最后一件事,我们需要创建一个constructor来初始化这些实例变量的值。

Constructors是当类的实例创建时运行的方法。它只会运行一次所以它最适合用来执行初始化命令。在变量声明语句下面加入下面的代码:

# 定义一个与class同名的constructor
Fly(this.game, double x, double y) {
  flyRect = Rect.fromLTWH(x, y, game.tileSize, game.tileSize);
}

这个constructor接收3个参数,一个是父级的game类,将它绑定给刚刚创建的game变量,剩下的xy代表苍蝇出现的位置。
constructor中,我们使用传入的xy以及父类game中的tileSize作为长和宽创建了一个新的矩形,并将它赋值给了flyRect
现在这个文件应该如下所示:
!()[https://jap.alekhin.io/wp-content/uploads/2019/02/fly-class.jpg]

绘制苍蝇

这一步,我们需要实现绘制苍蝇组件的代码。
我们已经知道了,绘制一个矩形,需要一个Rect类(上一步已经生成了)和一个Paint类,为了避免在render方法中反复初始化Paint类,我们把它作为一个实例变量来存储。
flyRect的声明中增加下面这行代码:

Paint flyPaint;

然后在constructor中初始化flyPaint变量:

flyPaint = Paint();
flyPaint.color = Color(0xff6ab04c);

现在我们可以开始绘制了,在render方法中加入下列代码:

c.drawRect(flyRect, flyPaint);

就是这样,当创建一个Fly实例并执行它的render方法时,一只苹果绿色的方块就会出现在屏幕上指定的位置。
现在Fly类应该如下图所示:

image

4. 召唤苍蝇

在我们随心所欲的召唤苍蝇之前,我们先要解决几个技术问题。当我们的游戏运行时,初始的screenSizenull,我们需要等到resize方法触发才知道屏幕的尺寸,那么它初次触发的时机时什么时候呢?
如果我们回到./lib/langaw-game.dart,你将看到当render方法运行的时候,screenSize已经被赋值了,这是因为它们之间的调用顺序是这样的:

  1. class实例被创建(constructor运行,但是因为该类中没有没有该方法所以跳略过);
  2. Flutter调用resize方法然后screenSize被赋值;
  3. 游戏主循坏开始;
  4. 游戏主循坏:update方法被调用;
  5. 游戏主循坏:render方法被调用;
  6. 游戏主循坏结束,回到步骤3。
    这种流程带来的影响有好处也有问题。理想状态下,我们希望初始化的代码在constructor中执行,因为我们只希望它运行一次。
    好处:resize方法在对象创建之后几乎是立即就执行了,所以我们可以在resize方法中执行初始化逻辑。但是...
    坏处:resize方法在屏幕尺寸变化时会再次被触发。如果我们把初始化逻辑放在这里,那么它有可能被执行多次。再次强调,初始化逻辑只应该被执行一次。想象一下你的游戏主角,在你翻转屏幕的时候,resize方法被触发了,在主角出生的位置又出现了一个主角,或者主角的位置和状态被重置到了启动时的状态,这多糟糕啊。
    有一种不优雅的做法,仍然在resize中执行初始化逻辑,额外使用一个bool类型的变量来记录初始化逻辑是否执行过,若它为false则执行初始化逻辑,并在初始化逻辑中将该bool值置为true,若它为true则表明初始化逻辑已经执行过,跳过执行。
    下面我来介绍Flame提供的一个函数,可以更优雅的处理这个问题。

在初始化时等待尺寸参数

打开./lib/langaw-game.dart文件,在LangawGame类中创建两个新的方法constructorinitialize
constructor方法只包含一行代码:对initialize方法的调用。
我们要使用一个异步函数来等待屏幕尺寸的值所以我们要给initialize函数加上async关键字将其异步化。
这也是为什么我们没有在constructor中执行初始化逻辑,而是直接调用了initialize方法,因为constructor不能是异步的
要在LangawGame类中加入的代码如下:

LangawGame() {
  initialize();
}

void initialize() async {}

接下来,我们需要用到Flame Util中的initialDimensions函数,所以需要引入flame库:

import 'package:flame/flame.dart';

然后在initialize方法中加入下面这行代码:

# Flame.util.initialDimensions()的返回值类型为`Size`
resize(await Flame.util.initialDimensions());

我们的resize方法接收一个Size类型的参数,当Flame.util.initialDimensions()方法返回Size参数时,resize就会接收到参数并执行。
此时./lib/langaw-game.dart文件应该如下图所示:

image

准备生成苍蝇

还记得我们前面提到的控制器(controllers)吗?那些不具有形态和位置的组件。在我们的这个游戏中,召唤的逻辑过于简单所以我们不会创建一个专门的组件来处理它而是把它放在LangawGame类中。
我们需要引用fly.dart文件以调用Fly组件:

import 'package:langaw/components/fly.dart';

在Dart中,没有数组的数据类型,但我们有和它很相似甚至可能更好的List类型,现在我们来创建一个List变量:

List<Fly> flies;

然后在initialize中初始化它的值:

flies = List<Fly>();

新的更改如下图所示:

image

尽管我们现在的flies参数还为空,我们已经可以使用forEach方法循环调用该List中各元素的updaterender方法。
这也是为什么我们要在initialize中尽快初始化flies的参数,因为null值不能调用forEach方法,会导致报错。
元素绘制的顺序会直接影响它的样子。苍蝇们之间没有重合部分,所以不会互相影响,但是需要展示在背景的上层,所以绘制要在背景之后。因此我们在绘制背景的代码后面加入下列代码:

flies.forEach((Fly fly) => fly.render(canvas));

将下列代码加入到update方法

flies.forEach((Fly fly) => fly.update(t));

现在,./lib/langaw-game.dart增加的代码如下:

image

生成苍蝇

到这一步,我们终于可以开始生成苍蝇了,将下列方法加入到game类中:

void spawnFly() {
  flies.add(Fly(this, 50, 50));
}

简析:创建一个Fly类,并将它加入到flies这个List中。如果你还记得的话,我们构建Fly实例需要传入3个变量:LangawGame的实例,x坐标参数,y坐标参数。
现在我们可以在initialize方法中调用它了,将它放置在resize函数后面:

spawnFly();

这一步骤我们添加的代码如下图:

image

运行游戏,现在我们能在屏幕左上角看到我们加上的苍蝇小方块:
image

在我们进入到下一步之前,我们在spawnFly方法中加入一点有趣的东西,将苍蝇的位置xy改为随机数,为此我们需要引入Dart的math库:

import 'dart:math';

然后我们创建一个实例变量来存储math库中的Random类:

Random rnd;

initialize中创建Random的实例,并赋值给rnd

rnd = Random();

现在该文件应该如下图所示:


image

Random类有一个方法nextDouble,它会返回一个01之间的double类型的浮点数。我们要做的就是将这个随机数与苍蝇的xy的最大值相乘,即可得到xy的两个随机值,因为我们目前使用的定位是左上对齐的,在苍蝇方块不溢出屏幕的前提下,x的最大值为screenSize.width - tileSizey的最大值为screenSize.height - tileSize,所以计算最大值的代码如下:

double x = rnd.nextDouble() * (screenSize.width - tileSize);
double y = rnd.nextDouble() * (screenSize.height - tileSize);

将上面的代码置入spawnFly方法中:

image

现在你每次运行游戏,绿色小方块都会出现在不同的位置:
image

5. 击落苍蝇

在编写逻辑之前,我们先要能接收玩家的输入。我们开头已经规定了,当玩家点击苍蝇时,苍蝇方块应该变成红色然后下落到屏幕外。因此我们要在game类中编写一个函数来处理点击事件。我们需要处理的onTapDown事件带有TapDownDetails类的参数,为此我们需要引入flutter的gestures库:

import 'package:flutter/gestures.dart';

然后将点击的回调函数加入到game类中:

void onTapDown(TapDownDetails d) {}

如下图所示:

image

image

现在回到./lib/main.dart文件,把gestures库也加入到该文件中:

import 'package:flutter/gestures.dart';

然后生成一个手势识别器,将game中的onTapDown回调方法绑定在它的onTapDown属性上。

TapGestureRecognizer tapper = TapGestureRecognizer();
tapper.onTapDown = game.onTapDown;
flameUtil.addGestureRecognizer(tapper);

如下图所示:


image

现在打开./lib/components/fly.dart,给Fly实例增加一个点击的回调函数。

void onTapDown() {}

如下图:

image

然后我们回到./lib/langaw-game.dart 文件。在game类的onTapDown方法中,我们要循环遍历List flies中每个Fly实例判断点击的位置是否在该方块的矩形范围内。
Rect类中有个有用的方法contains,这个方法接收Offset对象作为参数,返回这个Offset是否在矩形范围内的bool值。
onTapDown的回调函数中传入的TapDownDetails实例对象中有一个叫做globalPosition的属性,它就是记录了点击位置的Offset类型变量。
由此我们便可以将globalPosition传入Rectcontains方法来判断该次点击是否命中当前Fly实例对象所在的矩形区域:

flies.forEach((Fly fly) {
  if (fly.flyRect.contains(d.globalPosition)) {
    # 点击位置命中当前Fly的矩形范围,执行Fly类的onTapDown的方法,执行点击逻辑
    fly.onTapDown();
  }
});

这一步的改动如下图所示:


image

拍扁苍蝇

现在是时候来编写苍蝇被击中的处理逻辑了。
首先我们要把目标的颜色改为红色Color(0xffff4757),在Fly类的onTapDown处理函数中加入下列语句:

flyPaint.color = Color(0xffff4757);

如图所示:


image

现在运行游戏,当你点击绿色苍蝇,它会变为红色。

击落苍蝇

为了实现苍蝇落地的效果,我们需要使用到我们一直忽略的游戏主循环的一个部分。
update方法用于处理游戏的变化逻辑中非用户输入引发的部分。苍蝇的坠落动画就属于这一部分。由于苍蝇只有死了才需要执行坠落动画,所以我们需要在./lib/components/fly.dartFly类中加入一个bool变量来记录这个状态:

bool isDead = false;

当点击当前实例时,需要把isDead置为true,在onTapDown回调中加入下列代码:

isDead = true;

然后我们要开始编辑Fly类的update方法。当我们判断该实例的isDead变量为true时,我们需要更改当前矩形对象的top坐标值来使它下落。
所以我们在update方法中加入下列逻辑:

if (isDead) {
  flyRect = flyRect.translate(0, game.tileSize * 12 * t);
}

简析:先强调一点,主循坏的update方法每帧执行一次。所以当苍蝇死了之后,我们相当于每帧调用一次Recttranslate方法,将它向下平移一段距离。所以game.tileSize * 12 * t就是每次向下平移的距离。现在我们来介绍t值,t被称为时间增量,实际上是1/FPS的比值。在这个例子中每秒钟苍蝇下落的距离是game.tileSize * 12,乘以这个系数t之后我们就能得到每帧下落的距离。
最后,我们的文件应该如下图所示:

image

召唤更多

为了让游戏更具有可玩性,我们需要在一个苍蝇被击落后,重新召唤一个苍蝇,所以我们在onTapDown回调中加入下列代码:

game.spawnFly();

如下图所示:


image

现在我们还有最后一个问题:已经被击落的苍蝇会一直下落下去,虽然从表面上看它会飞出屏幕,并不影响游戏逻辑,但是出于回收内存和减少函数执行等优化考虑,我们需要清除已经飞出屏幕的Fly实例。
我们需要给Fly类增加一个bool类型的参数:

bool isOffScreen = false;

若当前实例的flyRect中的top属性超过了屏幕高度,则将isOffScreen置为true

if (flyRect.top > game.screenSize.height) {
  isOffScreen = true;
}

这一步的改动如下图所示:

image

最后,我们需要清除ListisOffScreentrueFly实例项,我们将使用List中的removeWhere方法,该方法会遍历List中的每一项,然后删除回调函数中返回值为true的项,我们在./lib/langaw-game.dartupdate方法中加入下列代码:

flies.removeWhere((Fly fly) => fly.isOffScreen);

这一步的更改如下图所示:


image

到此,我们这一节的开发任务就全部完成了,可以开始试玩游戏了!

总结

经过这段有些冗长的教程,我们创建了一个比上一篇交互性更强一点的游戏。在这一节的内容中,游戏主循环的概念应该更加清晰了,另外我们还正式使用了update方法。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容