开源项目Swift-2048学习、分析

开源项目Swift-2048学习、分析

[TOC]

这篇博客写了什么?

刚开始使用swift编写ios程序,花了两三天的时间看了下《The Swift Programming Language》,看了就忘了o(╯□╰)o,于是干脆从一个项目入手,一边开发一边学习。github上面找了一个挺有名的开源项目[swift-2048][id],经过一天半的"刻苦"学习,终于略有小成。
[id]: https://github.com/austinzheng/swift-2048 "gitHub"

项目结构

除去xocde7自动生成的一些文件外,austinzheng(2048项目作者),一共就使用7个.swift文件完成了整个项目,让本人第一感觉这个项目挺简单的。接下来就简单的介绍一下每一个文件的大概用处。

  • GameModel

    全工程最庞大的一个文件,在models文件夹下,这个文件主要是算法的实现(移动合并算法),虽然说是model文件后缀,但是本人实在想不到这个文件和MVC中的model有什么关系。

  • AuxiliaryModels

    里面定义着本项目用到的所有的、用户自定义的结构体与枚举

  • AccessoryViews

    本文件里面定义着代表分数的view

  • GameboardView

    和文件名称一样,这个文件是游戏的主要面板也就是下面这个

  • TileView

    文件名也出卖了它,这个让用户看起来就是2048游戏中的那些可以移动的数字。

  • NumberTileGame

    游戏的主要控制器,几乎所有的逻辑均在这里处理

  • AppearanceProvider

    项目辅助功能,它决定着游戏中数字以及TileView的颜色


代码分析

以文件为单位,对2048项目进行一个简单的分析。

TileView.swift

2048项目里面较为简单的一个文件。TileView也就是2048中可以移动的方块

  • value 属性

    这个属性代表着一个tileView上面所显示的分数的数值,并设置了一个属性观察器didSet,这个属性观察器在每一次value属性被设置新值的时候调用。目的是为了在每一次值改变的时候给TileView的背景颜色、文字颜色、以及TileView上显示的Label数值,这给3个属性重新赋值(2048游戏中,TIleVIew数值的不同,颜色是不一样的,2,4,8的颜色都不一样)

  • delegate 属性

    遵守AppearanceProviderProtocol协议的代理,主要用于更改颜色

  • numberLabel 属性

    TileView上每个数字都是一个Label

  • 构造方法

    无疑是对TileView本身进行一些初始化,包括本身的大小以及UILabel的创建之类

AccessoryViews.swift

前面项目结构以及分析了,这个文件主要负责获得分数的显示.这个文件也十分的简单,里面主要的类就是 ScoreView它遵守了ScoreViewProtocol协议。因为简单,所以不过多解释.

  • score 属性

    游戏总共的得分,同样也有一个didSet属性观察器,再每次score属性发生改变的时候,更新Label的显示

  • scoreChanged 方法
    在每一次分数改变的时候调用,用于跟新分数显示(个人感觉这个方法和协议写的有点多余)

AppearanceProvider.swift

一个辅助用的,主要用于TileView颜色的控制,简单不多解释.(没有这个文件提供的功能,项目一样可以跑,只是丑点)

GameboardView.swift

一个稍稍复杂的文件,代表游戏面板的view,也就是下面这个黑框框(十分明显的九宫格布局)。当然还实现了一些对TileView的移动、插入等操作。接下来只解释一些主要属性。

  • tiles 属性
    一个Dictionary<NSIndexPath, TileView>类型的数据,其实就是OC里面的一个字典,只是key换成了NSIndexPath类型,存储的valueTileView。所以这个字典里面存储的是整个游戏中,所有的TIleView,至于使用NSIndexPath做为key的目的很明显,能够使用类似于(1,3)的方式便捷取出任意一个TileView。

  • provider 属性
    控制颜色用的。。仅仅在创建TIleView的时候作为参赛传入即可

  • xxxTime xxScale
    一系列以这种方式结尾的属性,都是用来控制动画用的。。直接忽视它们吧。。。

  • 构造方法
    构造方法除了对属性的一些初始化,最主要的任务就是创建上图的那个九宫格黑框框,调用的方法便是setupBackground

  • setupBackground 方法
    十分经典的九宫格布局的创建方法。。也没啥可说的,(啥?你不知道什么是九宫格布局?)

  • insertTile 方法
    这个就是创建TileView并且显示的方法,方法接收(pos: (Int, Int), value: Int)2个参数,pos一个元组类型的数据,表示TileView应该插入的位置,Value便是值咯。创建完后利用下面这句话添加进入tiles字典(这下知道为什么tiles类型会是<NSIndexPath, TileView>了吧)

    tiles[NSIndexPath(forRow: row, inSection: col)] = tile
    
  • moveOneTile 方法

    从名字就可以知道,这是移动一个tileView的方法,参数from、to、value的意义也十分明了。整个过程就是,首先利用from取出需要移动的TileView,然后根据to参数计算出目标位置的x,y。

      func moveOneTile(from: (Int, Int), to: (Int, Int), value: Int) { 
        ...
        //这里是计算目标位置的x,y
    finalFrame.origin.x = tilePadding + CGFloat(toCol)*(tileWidth + tilePadding)
    finalFrame.origin.y = tilePadding + CGFloat(toRow)*(tileWidth + tilePadding)
    
    // 将原来的TileView从tiles中剔除
    tiles.removeValueForKey(fromKey)
    // 将拥有新位置的tileView重新放入tiles字典
    tiles[toKey] = tile
    // 到这里,内部逻辑已经将tile平移,但是界面没有显示
    // Animate
    ...//一些动画效果,设置需要移动tile的frame和数值
    }
    
  • moveTwoTiles 方法

    功能几乎和moveOneTile类似,只是它的from有两个来源。为什么会有两个来源?想一想tileView的合并。两个TileView合并过程应该是这样的。

    |[][2][][2]| ==>向左滑动 |[2][2][][]| ==> |[4][][][]|

    所以是先移动,再进行合并!
    整个的逻辑是这样

    1 根据from取出2个需要移动的tile

    2 根据to参数计算出需要移动的位置(2个同时都是移动到那个位置,动画给人一种合并的效果)

    3 将2个tile从tiles字典中移除,并将其中一个(总是里to这个位置最近的那个)tiles添加进tiles字典

    4 在动画中修改两个tiles的frame(营造动画效果)

    5 将其中一个从GameboadView中移除,设置另外一个tileView新的value数值。

    到达这里,完成平移与合并操作。

NumberTileGame.swift

这个文件就是本项目最主要的一个视图控制器的实现。处理着绝大部分的逻辑。

  • model 属性

    这个属性稍后会在GameModel.swift中进行详细介绍,这里先有个初步了解,它处理游戏中平移与合并算法。2048游戏最重要也是最难的便是它的平移和移动算法的实现。

  • 其他属性

    其他的属性几乎都是前面介绍过的。

  • 构造方法

    构造方法中对几个属性进行了初始化,并调用setupSwipeControls方法添加了4个手势识别器。

  • setupGame 方法

    ViewDidLoad调用,用来对游戏界面进行初始化(创建显示分数的ScoreView和游戏面板GameboardView)。值得一提的便是其中的两个用于计算x,y的内嵌方法。xPositionToCenterViewyPositionForViewAtPosition。首先得明白,游戏中"大"的view就只有显示总分数的scoreView和游戏面板GameboardView。而这两个view的位置关系总是scoreView在上GameboardView在下,并且两个view都是居于屏幕最中央(水平且垂直居中)。自己可以换几个不同屏幕大小试一试。而让他们位置有自适应的能力的便就是xPositionToCenterViewyPositionForViewAtPosition两个内嵌方法

    func xPositionToCenterView(v: UIView) -> CGFloat {
        let viewWidth = v.bounds.size.width
        let tentativeX = 0.5*(vcWidth - viewWidth)
        return tentativeX >= 0 ? tentativeX : 0
    }       

这个方法还不算复杂,其目的是:计算出能使传入参数v这个view,在控制器中居中显示的x的值。

        func yPositionForViewAtPosition(order: Int, views: [UIView]) -> CGFloat {
        ...
    //所有控件高度之和(包括间距),views.map({ $0.bounds.size.height })将所有view的高度取出,然后通过.reduce对所有高度进行求和。
      let totalHeight = CGFloat(views.count - 1)*viewPadding + views.map({ $0.bounds.size.height }).reduce(verticalViewOffset, combine: { $0 + $1 })
      // 这便是计算出来的views整体的起点y
      let viewsTop = 0.5*(vcHeight - totalHeight) >= 0 ? 0.5*(vcHeight - totalHeight) : 0

      // 然后根据order,数值0,代表第一个view也就是最上面的;数值1就是第二个view(本项目一共只有2个view所以也就是最下面的view)计算出任意一个view的y值
      var acc: CGFloat = 0
      for i in 0..<order {
        acc += viewPadding + views[i].bounds.size.height
      }
      return viewsTop + acc
    }

yPositionForViewAtPosition就比较复杂了。前面已经说明这两个方法是为了让两个view居中显示。yPositionForViewAtPosition就是为了找到能让任意一个view垂直居中的y值。因为在垂直面上有多个view(这里是2个),所以单独凭借一个view是无法计算的,必须把所有的view都传进来,再根据所有view的高度和计算出scoreView应该距离顶部的位置或者GameboardView距离底部的位置。

scoreVIewGameboardView创建完后,调用insertTileAtRandomLocation插入2个tileVIew结束。

  • followUp 方法

    在每一次成功移动tileView后调用,判定游戏是否结束,如果没有结束随机生成一个tileview

  • upCommand 方法

    手势识别器的监听方法,当上划操作时候调用。这里调用GameModel的queueMove方法,进行移动操作(稍后会有解释)。其他Command方法几乎一样

  • 一堆代理方法

    基本都是调用其他文件内实现的方法.

GameModel

终于来到2048核心所在!先来简单的看一下所包含的属性。

  • gameboard 属性

    它代表的是游戏的逻辑面板,为什么是逻辑面板?在前面已经有一个GameboardView这是一个实际的能人用户看到的游戏面板,空的就是黑黑的框,有数值的就能看到一个个2、4、8之类的数字。这些都是呈现给用户看的。而我们实际进行计算的是在gameboard这个逻辑面板中,定义如下.

    struct SquareGameboard<T> {
    
  let dimension : Int  // 面板大小
  var boardArray : [T] // 这里是存储TileObject类型的数组

  init(dimension d: Int, initialValue: T) {
    dimension = d
    boardArray = [T](count:d*d, repeatedValue:initialValue)
  }
    //下标脚本,这样能够快速访问到boardArray任意一个元素(gameboard[0][1])
  subscript(row: Int, col: Int) -> T {
    get {
      assert(row >= 0 && row < dimension)
      assert(col >= 0 && col < dimension)
      return boardArray[row*dimension + col]
    }
    set {
      assert(row >= 0 && row < dimension)
      assert(col >= 0 && col < dimension)
      boardArray[row*dimension + col] = newValue
    }
  }
    ...
}

附上gameboardGameboardView关系图一张

  • queue 属性

    一个装MoveCommand枚举的数组,这个枚举的意思是:direction这次滑动是哪个方向,completion以及tileView移动完成后需要做些什么。这里定义成一个数组。然而实际这个数组长度是不可能超过1的。。

  • timer 属性

    一个定时器,目的是为了不让因为手指滑动过快而导致tileView过快的移动(实际也没有什么用。。因为你的手速是一般达不到那么快的)

  • queueMove 方法

    NumberTileGame.swift已经提及到,一旦用进行滑动操作,便会调用这个方法。首先先将操作放入quenen数组,然后在看有没有定时器在运行,没有就调用timerFired方法.在属性解释的时候已经说过quenen以及定时器的作用(其实并没什么作用)。

  • timerFired 方法

    进行一些无聊的判断后调用performMove准备进行移动!当然如果有发生移动,那么得重新启动定时器.

  • performMove 方法

    重点终于来了!一进来就看到一个庞大的闭包

        //闭包接收一个整数作为参数,并且返回一个[(int),(int)]装有元组的数组
        let coordinateGenerator: (Int) -> [(Int, Int)] = { (iteration: Int) -> [(Int, Int)] in
        var buffer = Array<(Int, Int)>(count:self.dimension, repeatedValue: (0, 0))
        for i in 0..<self.dimension {
            switch direction {
            case .Up: buffer[i] = (i, iteration)
            case .Down: buffer[i] = (self.dimension - i - 1, iteration)
            case .Left: buffer[i] = (iteration, i)
            case .Right: buffer[i] = (iteration, self.dimension - i - 1)
        }
      }
      return buffer
    }
    
    

    这个方法目的是:根据滑动方向的不同,返回对应的滑动顺序。看不懂?没事,看下面的图解。

接着继续往下走,下面的代码就是把取出来准备进行移动计算的每一列(行),根据其(Int, Int)类型的数据,取出gameboard对应的每一项TIleObject

      // coords数组存放的顺序也就是移动的顺序
      let tiles = coords.map() { (c: (Int, Int)) -> TileObject in
        let (x, y) = c
        return self.gameboard[x, y]
      }

提供一个思考方式

每当获得tiles这个需要的移动的逻辑tiles,便会开始执行合并操作--调用merge方法.这个方法返回bool类型,只有发生移动/合并操作才会返回true.读到这里,笔者推荐先去看看下文介绍的merge方法的实现再继续看下面的解释。

读到这里,笔者默认你已经读完下面解释merge三个步骤

任然接着算法第三步返回的例子,返回值我还记得是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
接着对返回的数据(操作)进行处理,项目使用的是forin的循环。请看代码注释(先感叹一下,好巧妙的映射方式)

      for object in orders {
        switch object {
        case let MoveOrder.SingleMoveOrder(s, d, v, wasMerge):
          // 是不是已经忘记coords里面是什么了?同样在map映射解释那有写哦
          let (sx, sy) = coords[s]//针对我们的例子这里的值是[(1,3),1,2),1,1),1,0)]。  我们算出来都是2个SingleMoveOrder类型,这里分析合并情况即s=1,所以取出来的是(1,2)
          let (dx, dy) = coords[d] //这里取出来的是(1,3)
          if wasMerge {
            score += v    //这个是总得分,这里会触发属性观察器从而调用代理
          }
          gameboard[sx, sy] = TileObject.Empty  // 设置(跟新)逻辑面板状态
          gameboard[dx, dy] = TileObject.Tile(v)// 设置(跟新)逻辑面板状态
          //到这里,逻辑面板已经移动完毕,接下来就是改变UI了,所以调用下面的方法。这个方法在GameboardView.swift中实现.
          delegate.moveOneTile(coords[s], to: coords[d], value: v)
            
        case let MoveOrder.DoubleMoveOrder(s1, s2, d, v):
          // Perform a simultaneous two-tile move
          let (s1x, s1y) = coords[s1]
          let (s2x, s2y) = coords[s2]
          let (dx, dy) = coords[d]
          score += v
          gameboard[s1x, s1y] = TileObject.Empty
          gameboard[s2x, s2y] = TileObject.Empty
          gameboard[dx, dy] = TileObject.Tile(v)
          delegate.moveTwoTiles((coords[s1], coords[s2]), to: coords[d], value: v)
        }
      }
      

SingleMoveOrderDoubleMoveOrder处理上几乎一致,所以继续分析。到这里,整个项目几乎已经分析完。项目中最难理解的一个是合并算法,另外一个笔者便认为是项目作者设计的一种巧妙的映射方式,最后附上全部映射关系图一张.

  • merge 方法

    2048的作者已经将算法分为三步来实现,分别对应3个方法condensecollapseconvert,接下来对这些方法进行逐个解释

  • condense 方法

    在介绍详细算法之前,必须先知道,这个项目的合并算法是先移动再合并!
    而合并其实就是将2个需要合并的tile,一个从GameboardView中删除,另外一个则改变其数值大小,给人一种合并的假象。
    例如
    [2][ ][2][4]--->是先将下标为2的2移动到下标为0的2(重叠)
    哪怕是这样
    [2][2][4][8]--->也是先将标为1的2移动到下标为0的2(重叠)

    合并算法的第一步.

    目的:“去除”数组中的空的项(也就是移动),列如[2][][4][] ---> [2][4]

    var tokenBuffer = [ActionToken]()
    

    ActionToken枚举里面定义了一共四种操作类型,condense只用到两种,后两种便先不仔细介绍.这个方法是为了移动。那怎么样判断是否需要移动呢?

    需要移动的必须满足的条件

    1:本身是非"空"的即tile不是.Empty类型

    2:这个tile前面必须有"位置"能够移动,也就是说,排在这个tile移动顺序之前的tiles内最少有以一个是.Empty类型.

    TileObject有两个类型的值.Empty代表空的。 .tile(value)代表这个是存在Tile的,满足条件1.

    如果是[2][2][2][2]这种情况,tokenBuffer会老老实实的调用tokenBuffer.append(ActionToken.NoAction(source: idx, value: value))把这些全部添加进去。一旦出现了一个为.Empty类型的tile,这次switch会直接执行default,从而导致where tokenBuffer.count == idx这个条件永远为false!.这才会调用tokenBuffer.append(ActionToken.Move(source: idx, value: value))

    这个方法执行完,返回tokenBuffer,此时tokenBuffer中装有是所有tile需要进行的操作要么是Move要么是NoAction。任然用这个例子:

    在我向右滑动上面那个面板,用第二行来举例。condense接收的group内容是
    【Value(2),Value(2),Value(2),Empty】(不知道为什么?返回看上面map映射的解释那张图)在经过本condense计算后返回的tokenBuffer是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】

    这里必须得对ActionToken进行一些解释,这个source参数是代表这个tile的的原位置不是它在tokenBuffer中的位置,一定得记住是tile的的原位置!tile的的原位置!tile的的原位置!

  • collapse

    合并算法第二步:合并相同数值的tile:[2][2][2][4] -> [4][2][4]

    根据代码进行分析,笔者添加了很多注释

    
    func collapse(group: [ActionToken]) -> [ActionToken] {
    
    var tokenBuffer = [ActionToken]()
    var skipNext = false //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并)
    for (idx, token) in group.enumerate() {
      if skipNext {
        //如果发生了合并的操作,那么下一个tile将不进行操作(已经被合并)
        skipNext = false
        continue
      }
      switch token {
        ...
      case let .NoAction(s, v)
        where (idx < group.count-1
          && v == group[idx+1].getValue()
          && GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s)):
         //这种情况应用在tile需要合并但不需要移动的情况,这个方法在一个数组队列中最多调用一次,
        //即tile需要合并但不需要移动的情况只有一次,因为一旦需要合并,后面的tile都需要进行移动操作
        //哪怕是在第一步计算中是不需要移动的
        let next = group[idx+1]
        let nv = v + group[idx+1].getValue()
        skipNext = true
        tokenBuffer.append(ActionToken.SingleCombine(source: next.getSource(), value: nv))
      case let t where (idx < group.count-1 && t.getValue() == group[idx+1].getValue()):
    

//这种情况应用在tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过合并导致的
let next = group[idx+1]
let nv = t.getValue() + group[idx+1].getValue()
skipNext = true
tokenBuffer.append(ActionToken.DoubleCombine(source: t.getSource(), second: next.getSource(), value: nv))
case let .NoAction(s, v) where !GameModel.quiescentTileStillQuiescent(idx, outputLength: tokenBuffer.count, originalPosition: s):
//这种情况应用于需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的
tokenBuffer.append(ActionToken.Move(source: s, value: v))
case let .NoAction(s, v):
//不需要移动且不合并
tokenBuffer.append(ActionToken.NoAction(source: s, value: v))
case let .Move(s, v):
// 仅仅需要移动
tokenBuffer.append(ActionToken.Move(source: s, value: v))
default:
break
}
}
return tokenBuffer
}


    总结一下,合并/移动的分类

    1 tile需要合并但不需要移动的情况,这个种情况在一列/行*tiles*中最多存在一次。   因为因为一旦需要合并,后面的tile都需要进行移动操作。哪怕是在第一步计算中是不需   要移动的。列如[2][2][8][2]---->[4][ ][8][2]--->[4][8][2][],这种情况经   过算法第一步后全是*NoAction*,但是在算法第二步因为前2个[2]会发生合并,所以导   致第二个位置会空从而导致原本NoAction的[8][2]也要移动,所以一旦有发生合并,后   面的都**必须进行移动**.

    2 tile需要移动且需要合并的情况,需要移动的状态包括第一步计算出来的与前面发生过  合并导致的。比如[2][2][4][4]-->[4][ ][4][4]--->[4][8][][]
    
    3 需要移动但不需要合并,而需要移动是因为前面进行过合并操作造成的(参考1)

    4 不需要移动且不合并:列如[2][4][8][16]

    5  仅仅需要移动,比如[2][ ][4][8] -->[2][4][8]

    最后解释一下为什么在第一种分类下是*SingleCombine*而第二种是   *DoubleCombine*。两个类型的不同就在于*DoubleCombine*多了一个*second:*参 数。至于为什么这样?不妨回想一下上面1情况与2情况的分别。没错,区别在与我说的 *tile*是否需要移动,郑重强调:**只要发生合并操作,绝对是需要进行tile的移动 的!绝对需要移动**,是不是感觉奇怪,明明前面说不需要移动。对,我说tile不需要移  动是指*for*循环中**token**代表的当前的那个tile,但是合并是两个tile的事情,    所以*let next = group[idx+1]*取出了下一个tile,而这个利用*idx+1*取出来   的tile是**一定得需要移动的**,而*token*代表的那个tile不一定需要移动,所以: *SingleCombine*是指只要移动一个*tile*的情况,而*DoubleCombine*是值2个需  要合并的*tile*均需要移动!(其实在分析**condense**(算法第一步)的时候已经强 调,算法的步骤是**先移动再合并**)。任然回到算法第一步最后那个例子
    经过第一步计算传入的group内容是【NoAtion(0,2),NoAtion(1,2),NoAtion(2,2)】
    经过第二步计算出来的返回值tokenBuffer是【SingleCombine(1,4),Move(2,2)】

* convert

    这是最后一步了,这里又多出了一个*MoveOrder*枚举,这个枚举把需要进行的操作再度    简化就分为*SingleMoveOrder*与*DoubleMoveOrder*两种操作类别,十分显然 *DoubleMoveOrder*对应的是前面需要进行两个tile移动的且这两个需要合并的操作(    对应算法第二步分类中的2)。*SingleMoveOrder*是单一一个tile的移动操作。啥?你说少了一种合并情况?tile需要合并当不需要移动的操作被吃了?我就问了:算法第二步分类中的1是不是也需要移动一块被合并的tile(用*let next = group[idx+1]*取出的那块)?,这就对了,*SingleMoveOrder*有个参数*wasMerge:*代表的就是需不需要合并。所以*SingleMoveOrder*对应算法第二步中分类的1、3、5。至于4,人家都说了不移动不合并,就让人家好好原地待着。接着例子来,我们看看最后返回的是什么
    经过第二步计算传入的group是【SingleCombine(1,4),Move(2,2)】
    经过第三步计算出返回值moveBuffer是【SingleMoveOrder(1, 0,4,ture),SingleMoveOrder(2,1,2,false)】
    看到这里算法分析基本结束,可以返回去继续看**performMove**方法,看后续操作.
    

## 总结
到这里应该就告一段落了,虽然还有些代码没有分析,但那些是不太重要的东西了。整个项目可以说不难,比较适合初学者,关键是要理解作者设置的映射关系与合并算法,。想要彻底的了解这个2048,最好就是自己从头到尾从零开始写一个。先从搭建界面开始,一步一步慢慢的来。笔者2048项目花了1天办时间重写,而写这篇文章却花了将近三天。如果有时间。可能会继续写关于2048的博文,应该是一步一步的去记录实现2048的步骤。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容